diff --git a/app.py b/app.py index c9665fb..d6cf526 100644 --- a/app.py +++ b/app.py @@ -77,6 +77,14 @@ def init_db(): if 'reference_notes' not in of_cols: cursor.execute("ALTER TABLE official_formulas ADD COLUMN reference_notes TEXT") + # herb_items 마이그레이션: product_type, standard_code 컬럼 추가 (OTC 재고 구분) + cursor.execute("PRAGMA table_info(herb_items)") + hi_cols = {row[1] for row in cursor.fetchall()} + if 'product_type' not in hi_cols: + cursor.execute("ALTER TABLE herb_items ADD COLUMN product_type TEXT DEFAULT 'HERB'") + if 'standard_code' not in hi_cols: + cursor.execute("ALTER TABLE herb_items ADD COLUMN standard_code TEXT") + # 100처방 원방 마스터 시드 데이터 로드 cursor.execute("SELECT COUNT(*) FROM official_formulas") if cursor.fetchone()[0] == 0: @@ -290,6 +298,8 @@ def get_herbs(): h.insurance_code, h.herb_name, h.is_active, + h.product_type, + h.standard_code, COALESCE(SUM(il.quantity_onhand), 0) as current_stock, GROUP_CONCAT(DISTINCT het.tag_name) as efficacy_tags FROM herb_items h @@ -300,7 +310,7 @@ def get_herbs(): LEFT JOIN herb_item_tags hit ON COALESCE(h.ingredient_code, hp.ingredient_code) = hit.ingredient_code LEFT JOIN herb_efficacy_tags het ON hit.tag_id = het.tag_id WHERE h.is_active = 1 - GROUP BY h.herb_item_id, h.insurance_code, h.herb_name, h.is_active + GROUP BY h.herb_item_id, h.insurance_code, h.herb_name, h.is_active, h.product_type, h.standard_code ORDER BY h.herb_name """) @@ -1291,6 +1301,127 @@ def create_manual_receipt(): except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 +@app.route('/api/purchase-receipts/from-cart', methods=['POST']) +def create_receipt_from_cart(): + """의약품 마스터 장바구니 → 입고 처리 (standard_code 기반, OTC 포함)""" + try: + data = request.get_json() + + supplier_id = data.get('supplier_id') + receipt_date = data.get('receipt_date') + notes = data.get('notes', '') + lines = data.get('lines', []) + + if not supplier_id: + return jsonify({'success': False, 'error': '도매상을 선택해주세요.'}), 400 + if not receipt_date: + return jsonify({'success': False, 'error': '입고일을 입력해주세요.'}), 400 + if not lines or len(lines) == 0: + return jsonify({'success': False, 'error': '입고 품목을 1개 이상 추가해주세요.'}), 400 + + with get_db() as conn: + cursor = conn.cursor() + + # 도매상 확인 + cursor.execute("SELECT name FROM suppliers WHERE supplier_id = ?", (supplier_id,)) + if not cursor.fetchone(): + return jsonify({'success': False, 'error': '유효하지 않은 도매상입니다.'}), 400 + + # 입고장 번호 생성 (PR-YYYYMMDD-XXXX) + date_str = receipt_date.replace('-', '') + cursor.execute(""" + SELECT MAX(CAST(SUBSTR(receipt_no, -4) AS INTEGER)) + FROM purchase_receipts WHERE receipt_no LIKE ? + """, (f'PR-{date_str}-%',)) + max_num = cursor.fetchone()[0] + receipt_no = f"PR-{date_str}-{(max_num or 0) + 1:04d}" + + # 총 금액 계산 + total_amount = sum( + float(line.get('qty', 0)) * float(line.get('unit_price', 0)) + for line in lines + ) + + # 입고장 헤더 + cursor.execute(""" + INSERT INTO purchase_receipts (supplier_id, receipt_date, receipt_no, total_amount, source_file, notes) + VALUES (?, ?, ?, ?, 'CART', ?) + """, (supplier_id, receipt_date, receipt_no, total_amount, notes)) + receipt_id = cursor.lastrowid + + processed_count = 0 + for line in lines: + standard_code = line.get('standard_code') + product_name = line.get('product_name', '') + company_name = line.get('company_name', '') + spec_grams = float(line.get('spec_grams', 0)) + qty = int(line.get('qty', 0)) + unit_price = float(line.get('unit_price', 0)) + origin_country = line.get('origin_country', '') + + if not standard_code or qty <= 0 or unit_price <= 0: + continue + + # g 환산 + quantity_g = spec_grams * qty if spec_grams > 0 else 0 + unit_price_per_g = unit_price / spec_grams if spec_grams > 0 else 0 + line_total = qty * unit_price + + # herb_items에서 standard_code로 조회 + cursor.execute("SELECT herb_item_id FROM herb_items WHERE standard_code = ?", (standard_code,)) + herb = cursor.fetchone() + + if not herb: + # 새 herb_item 자동 생성 (OTC) + cursor.execute(""" + INSERT INTO herb_items (herb_name, specification, product_type, standard_code) + VALUES (?, ?, 'OTC', ?) + """, (product_name, company_name, standard_code)) + herb_item_id = cursor.lastrowid + else: + herb_item_id = herb[0] + + # 입고장 라인 + cursor.execute(""" + INSERT INTO purchase_receipt_lines + (receipt_id, herb_item_id, origin_country, quantity_g, unit_price_per_g, line_total) + VALUES (?, ?, ?, ?, ?, ?) + """, (receipt_id, herb_item_id, origin_country, quantity_g, unit_price_per_g, line_total)) + line_id = cursor.lastrowid + + # 재고 로트 생성 + cursor.execute(""" + INSERT INTO inventory_lots + (herb_item_id, supplier_id, receipt_line_id, received_date, origin_country, + unit_price_per_g, quantity_received, quantity_onhand) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (herb_item_id, supplier_id, line_id, receipt_date, + origin_country, unit_price_per_g, quantity_g, quantity_g)) + lot_id = cursor.lastrowid + + # 재고 원장 + cursor.execute(""" + INSERT INTO stock_ledger + (event_type, herb_item_id, lot_id, quantity_delta, unit_cost_per_g, + reference_table, reference_id) + VALUES ('RECEIPT', ?, ?, ?, ?, 'purchase_receipts', ?) + """, (herb_item_id, lot_id, quantity_g, unit_price_per_g, receipt_id)) + + processed_count += 1 + + return jsonify({ + 'success': True, + 'message': '입고가 완료되었습니다.', + 'receipt_no': receipt_no, + 'summary': { + 'item_count': processed_count, + 'total_amount': f"{total_amount:,.0f}원" + } + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + # ==================== 입고장 조회/관리 API ==================== @app.route('/api/purchase-receipts', methods=['GET']) @@ -1406,6 +1537,8 @@ def get_purchase_receipt_detail(receipt_id): h.herb_name, h.insurance_code, h.ingredient_code, + h.product_type, + h.standard_code, il.lot_id, il.quantity_onhand as current_stock, il.display_name, @@ -2467,6 +2600,8 @@ def get_inventory_summary(): h.insurance_code, h.herb_name, h.ingredient_code, + h.product_type, + h.standard_code, COALESCE(SUM(il.quantity_onhand), 0) as total_quantity, COUNT(DISTINCT il.lot_id) as lot_count, COUNT(DISTINCT il.origin_country) as origin_count, @@ -2476,7 +2611,7 @@ def get_inventory_summary(): COALESCE(SUM(il.quantity_onhand * il.unit_price_per_g), 0) as total_value FROM herb_items h LEFT JOIN inventory_lots il ON h.herb_item_id = il.herb_item_id AND {where_clause} - GROUP BY h.herb_item_id, h.insurance_code, h.herb_name, h.ingredient_code + GROUP BY h.herb_item_id, h.insurance_code, h.herb_name, h.ingredient_code, h.product_type, h.standard_code HAVING total_quantity > 0 ORDER BY h.herb_name """) @@ -2595,7 +2730,7 @@ def get_inventory_detail(herb_item_id): # 약재 기본 정보 cursor.execute(""" - SELECT herb_item_id, insurance_code, herb_name + SELECT herb_item_id, insurance_code, herb_name, product_type, standard_code FROM herb_items WHERE herb_item_id = ? """, (herb_item_id,)) @@ -2719,6 +2854,8 @@ def get_stock_adjustment_detail(adjustment_id): sad.*, h.herb_name, h.insurance_code, + h.product_type, + h.standard_code, il.origin_country, s.name as supplier_name FROM stock_adjustment_details sad @@ -3780,6 +3917,7 @@ def search_medicine_master(): try: q = request.args.get('q', '').strip() category = request.args.get('category', '') # 전문일반구분 필터 + package_type = request.args.get('package_type', '') # 포장형태 필터 limit = min(int(request.args.get('limit', 50)), 200) if not q or len(q) < 2: @@ -3795,6 +3933,10 @@ def search_medicine_master(): where_clauses.append("m.category = ?") params.append(category) + if package_type: + where_clauses.append("m.package_type = ?") + params.append(package_type) + # 취소된 제품 제외 where_clauses.append("(m.cancel_date IS NULL OR m.cancel_date = '')") diff --git a/database/schema_backup_20260219.sql b/database/schema_backup_20260219.sql new file mode 100644 index 0000000..f768542 --- /dev/null +++ b/database/schema_backup_20260219.sql @@ -0,0 +1,258 @@ +-- 한약 재고관리 시스템 데이터베이스 스키마 +-- SQLite 기준 + +-- 1) 도매상/공급업체 +CREATE TABLE IF NOT EXISTS suppliers ( + supplier_id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + business_no TEXT, + contact_person TEXT, + phone TEXT, + address TEXT, + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 2) 약재 마스터 (보험코드 9자리 기준) +CREATE TABLE IF NOT EXISTS herb_items ( + herb_item_id INTEGER PRIMARY KEY AUTOINCREMENT, + insurance_code TEXT UNIQUE, -- 보험코드 (9자리) + herb_name TEXT NOT NULL, -- 약재명 + specification TEXT, -- 규격/품질 + default_unit TEXT DEFAULT 'g', -- 기본 단위 + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 3) 환자 정보 +CREATE TABLE IF NOT EXISTS patients ( + patient_id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + phone TEXT NOT NULL, + jumin_no TEXT, -- 주민번호 (암호화 필요) + gender TEXT CHECK(gender IN ('M', 'F')), + birth_date DATE, + address TEXT, + notes TEXT, + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(phone, name) +); + +-- 4) 입고장 헤더 +CREATE TABLE IF NOT EXISTS purchase_receipts ( + receipt_id INTEGER PRIMARY KEY AUTOINCREMENT, + supplier_id INTEGER NOT NULL, + receipt_date DATE NOT NULL, + receipt_no TEXT, -- 입고 번호/전표번호 + vat_included INTEGER DEFAULT 1, -- 부가세 포함 여부 + vat_rate REAL DEFAULT 0.10, -- 부가세율 + total_amount REAL, -- 총 입고액 + source_file TEXT, -- Excel 파일명 + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (supplier_id) REFERENCES suppliers(supplier_id) +); + +-- 5) 입고장 상세 라인 +CREATE TABLE IF NOT EXISTS purchase_receipt_lines ( + line_id INTEGER PRIMARY KEY AUTOINCREMENT, + receipt_id INTEGER NOT NULL, + herb_item_id INTEGER NOT NULL, + origin_country TEXT, -- 원산지 + quantity_g REAL NOT NULL, -- 구입량(g) + unit_price_per_g REAL NOT NULL, -- g당 단가 (VAT 포함) + line_total REAL, -- 라인 총액 + expiry_date DATE, -- 유효기간 + lot_number TEXT, -- 로트번호 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (receipt_id) REFERENCES purchase_receipts(receipt_id), + FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id) +); + +-- 6) 재고 로트 (입고 라인별 재고 관리) +CREATE TABLE IF NOT EXISTS inventory_lots ( + lot_id INTEGER PRIMARY KEY AUTOINCREMENT, + herb_item_id INTEGER NOT NULL, + supplier_id INTEGER NOT NULL, + receipt_line_id INTEGER NOT NULL, + received_date DATE NOT NULL, + origin_country TEXT, + unit_price_per_g REAL NOT NULL, + quantity_received REAL NOT NULL, -- 입고 수량 + quantity_onhand REAL NOT NULL, -- 현재 재고 + expiry_date DATE, + lot_number TEXT, + is_depleted INTEGER DEFAULT 0, -- 소진 여부 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id), + FOREIGN KEY (supplier_id) REFERENCES suppliers(supplier_id), + FOREIGN KEY (receipt_line_id) REFERENCES purchase_receipt_lines(line_id) +); + +-- 7) 재고 원장 (모든 재고 변동 기록) +CREATE TABLE IF NOT EXISTS stock_ledger ( + ledger_id INTEGER PRIMARY KEY AUTOINCREMENT, + event_time DATETIME DEFAULT CURRENT_TIMESTAMP, + event_type TEXT NOT NULL CHECK(event_type IN ('RECEIPT', 'CONSUME', 'ADJUST', 'DISCARD', 'RETURN')), + herb_item_id INTEGER NOT NULL, + lot_id INTEGER, + quantity_delta REAL NOT NULL, -- 증감량 (+입고, -사용) + unit_cost_per_g REAL, + reference_table TEXT, -- 참조 테이블 (compounds, adjustments 등) + reference_id INTEGER, -- 참조 ID + notes TEXT, + created_by TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id), + FOREIGN KEY (lot_id) REFERENCES inventory_lots(lot_id) +); + +-- 8) 처방 마스터 (약속 처방) +-- 8-1) 100처방 원방 마스터 +CREATE TABLE IF NOT EXISTS official_formulas ( + official_formula_id INTEGER PRIMARY KEY AUTOINCREMENT, + formula_number INTEGER NOT NULL UNIQUE, -- 연번 (1~100) + formula_name TEXT NOT NULL, -- 처방명 (예: 쌍화탕) + formula_name_hanja TEXT, -- 한자명 (예: 雙和湯) + source_text TEXT, -- 출전 (예: 화제국방) + description TEXT, -- 설명/효능 + reference_notes TEXT, -- 상담참고자료 (OTC 대비 차별점, 구성 해설, 업셀링 포인트 등) + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 8-2) 100처방 원방 구성 약재 (성분코드 기반) +CREATE TABLE IF NOT EXISTS official_formula_ingredients ( + ingredient_id INTEGER PRIMARY KEY AUTOINCREMENT, + official_formula_id INTEGER NOT NULL, + ingredient_code TEXT NOT NULL, -- herb_masters.ingredient_code 기준 + grams_per_cheop REAL NOT NULL, -- 1첩당 그램수 + notes TEXT, -- 역할 (예: 군약, 신약, 좌약, 사약) + sort_order INTEGER DEFAULT 0, + FOREIGN KEY (official_formula_id) REFERENCES official_formulas(official_formula_id), + UNIQUE (official_formula_id, ingredient_code) +); + +-- 8-3) 운영 처방 (조제에 사용) +CREATE TABLE IF NOT EXISTS formulas ( + formula_id INTEGER PRIMARY KEY AUTOINCREMENT, + formula_code TEXT UNIQUE, -- 처방 코드 + formula_name TEXT NOT NULL, -- 처방명 (예: 쌍화탕) + formula_type TEXT DEFAULT 'CUSTOM', -- CUSTOM(약속처방), STANDARD 등 + official_formula_id INTEGER, -- 100처방 마스터 참조 (원방 기반인 경우) + base_cheop INTEGER DEFAULT 20, -- 기본 첩수 (1제 기준) + base_pouches INTEGER DEFAULT 30, -- 기본 파우치수 (1제 기준) + description TEXT, + is_active INTEGER DEFAULT 1, + created_by TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (official_formula_id) REFERENCES official_formulas(official_formula_id) +); + +-- 9) 처방 구성 약재 +CREATE TABLE IF NOT EXISTS formula_ingredients ( + ingredient_id INTEGER PRIMARY KEY AUTOINCREMENT, + formula_id INTEGER NOT NULL, + herb_item_id INTEGER NOT NULL, + grams_per_cheop REAL NOT NULL, -- 1첩당 그램수 + notes TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (formula_id) REFERENCES formulas(formula_id), + FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id), + UNIQUE (formula_id, herb_item_id) +); + +-- 10) 조제 작업 (처방 실행) +CREATE TABLE IF NOT EXISTS compounds ( + compound_id INTEGER PRIMARY KEY AUTOINCREMENT, + patient_id INTEGER, + formula_id INTEGER, + compound_date DATE NOT NULL, + je_count REAL NOT NULL, -- 제수 (1제, 0.5제 등) + cheop_total REAL NOT NULL, -- 총 첩수 + pouch_total REAL NOT NULL, -- 총 파우치수 + cost_total REAL, -- 원가 총액 + sell_price_total REAL, -- 판매 총액 + prescription_no TEXT, -- 처방전 번호 + status TEXT DEFAULT 'PREPARED', -- PREPARED, DISPENSED, CANCELLED + notes TEXT, + created_by TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (patient_id) REFERENCES patients(patient_id), + FOREIGN KEY (formula_id) REFERENCES formulas(formula_id) +); + +-- 11) 조제 약재 구성 (실제 조제시 사용된 약재 - 가감 포함) +CREATE TABLE IF NOT EXISTS compound_ingredients ( + compound_ingredient_id INTEGER PRIMARY KEY AUTOINCREMENT, + compound_id INTEGER NOT NULL, + herb_item_id INTEGER NOT NULL, + grams_per_cheop REAL NOT NULL, -- 1첩당 그램수 (가감 반영) + total_grams REAL NOT NULL, -- 총 사용량 + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (compound_id) REFERENCES compounds(compound_id), + FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id) +); + +-- 12) 조제 소비 내역 (로트별 차감) +CREATE TABLE IF NOT EXISTS compound_consumptions ( + consumption_id INTEGER PRIMARY KEY AUTOINCREMENT, + compound_id INTEGER NOT NULL, + herb_item_id INTEGER NOT NULL, + lot_id INTEGER NOT NULL, + quantity_used REAL NOT NULL, -- 사용량(g) + unit_cost_per_g REAL NOT NULL, -- 단가 + cost_amount REAL, -- 원가액 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (compound_id) REFERENCES compounds(compound_id), + FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id), + FOREIGN KEY (lot_id) REFERENCES inventory_lots(lot_id) +); + +-- 인덱스 생성 +CREATE INDEX IF NOT EXISTS idx_herb_items_name ON herb_items(herb_name); +CREATE INDEX IF NOT EXISTS idx_herb_items_code ON herb_items(insurance_code); +CREATE INDEX IF NOT EXISTS idx_inventory_lots_herb ON inventory_lots(herb_item_id, is_depleted); +CREATE INDEX IF NOT EXISTS idx_stock_ledger_herb ON stock_ledger(herb_item_id, event_time); +CREATE INDEX IF NOT EXISTS idx_compounds_patient ON compounds(patient_id); +CREATE INDEX IF NOT EXISTS idx_compounds_date ON compounds(compound_date); +CREATE INDEX IF NOT EXISTS idx_patients_phone ON patients(phone); + +-- 뷰 생성 (자주 사용되는 조회) +-- 현재 재고 현황 +CREATE VIEW IF NOT EXISTS v_current_stock AS +SELECT + h.herb_item_id, + h.insurance_code, + h.herb_name, + SUM(il.quantity_onhand) as total_quantity, + COUNT(DISTINCT il.lot_id) as lot_count, + AVG(il.unit_price_per_g) as avg_unit_price +FROM herb_items h +LEFT JOIN inventory_lots il ON h.herb_item_id = il.herb_item_id AND il.is_depleted = 0 +GROUP BY h.herb_item_id, h.insurance_code, h.herb_name; + +-- 처방별 구성 약재 뷰 +CREATE VIEW IF NOT EXISTS v_formula_details AS +SELECT + f.formula_id, + f.formula_name, + f.formula_code, + h.herb_name, + fi.grams_per_cheop, + h.insurance_code +FROM formulas f +JOIN formula_ingredients fi ON f.formula_id = fi.formula_id +JOIN herb_items h ON fi.herb_item_id = h.herb_item_id +WHERE f.is_active = 1 +ORDER BY f.formula_id, fi.sort_order; \ No newline at end of file diff --git a/docs/DB_매핑_의약품마스터_한약재코드.md b/docs/DB_매핑_의약품마스터_한약재코드.md new file mode 100644 index 0000000..2368677 --- /dev/null +++ b/docs/DB_매핑_의약품마스터_한약재코드.md @@ -0,0 +1,124 @@ +# DB 매핑 구조: 의약품 마스터 ↔ 한약재 코드 테이블 + +> 의약품 마스터(medicine_master.db)와 기존 한약재 테이블(kdrug.db)은 +> **`representative_code`(대표코드)** 로 연결된다. + +--- + +## 1. 테이블 관계도 + +``` +herb_masters (454개 약재, 공통 성분 마스터) + │ ingredient_code (PK) 예: 3033H1AHM = 계지 + │ + ├─→ herb_products (53,769건, 제품별 코드) + │ ingredient_code (FK) + │ representative_code ← 매핑 키 + │ standard_code + │ product_code (보험코드 9자리) + │ + └─→ herb_items (29건, 우리 약국 보유 약재) + ingredient_code (FK) + insurance_code + herb_name 예: "휴먼계지" + +medicine_master.db (305,522건, 의약품표준코드 마스터) + │ representative_code ← 매핑 키 + │ standard_code + │ item_std_code (품목기준코드) + │ product_name, company_name, spec, category, ... +``` + +--- + +## 2. 매핑 키: `representative_code` (대표코드) + +| 구분 | herb_products | medicine_master | +|------|:---:|:---:| +| 대표코드 수 | 17,356 | 13,771 (한약재) | +| **매칭 수** | **13,188 (95.8%)** | | +| 표준코드 수 | 53,769 | - | +| 표준코드 매칭 | **40,364 (75.1%)** | | + +- 풀체인 매칭 약재: **447 / 454개 (98.5%)** +- medicine_master에만 있는 한약재 대표코드: 6개 (무시 가능) +- herb_products에만 있는 대표코드: 4,168개 (취소/폐지 제품 등) + +--- + +## 3. 매핑 예시: 계지 + +```sql +-- herb_masters: 공통명 +ingredient_code = '3033H1AHM', herb_name = '계지' + +-- herb_products: 제품들 (57개 제조사) + 휴먼계지 rep: 8800624003904 std: 8800624003911 (500g) + 대효계지 rep: 8800628002200 std: 8800628002224 (500g) + 광명계지 rep: 8800640000505 std: 8800640000512 (500g) + ... + +-- medicine_master: 동일 대표코드로 매칭 + 휴먼계지 rep: 8800624003904 item_std: 200406389 category: 한약재 + 대효계지 rep: 8800628002200 item_std: 200406525 category: 한약재 + ... +``` + +--- + +## 4. 매핑 SQL + +### 특정 약재의 전체 제품 + 의약품 마스터 정보 조회 +```sql +SELECT hm.herb_name AS 공통명, + hp.product_name, hp.company_name, + hp.representative_code, hp.standard_code, + mm.spec, mm.form_type, mm.package_type, + mm.item_std_code, mm.category +FROM herb_masters hm +JOIN herb_products hp ON hp.ingredient_code = hm.ingredient_code +LEFT JOIN med_master.medicine_master mm + ON mm.representative_code = hp.representative_code + AND (mm.cancel_date IS NULL OR mm.cancel_date = '') +WHERE hm.herb_name = '계지'; +``` + +### herb_items(보유 약재) → 의약품 마스터 연결 +```sql +SELECT hi.herb_name, hi.ingredient_code, + mm.item_std_code, mm.representative_code, mm.standard_code, + mm.spec, mm.package_type +FROM herb_items hi +JOIN herb_products hp ON hp.ingredient_code = hi.ingredient_code +JOIN med_master.medicine_master mm + ON mm.representative_code = hp.representative_code + AND mm.product_name = hi.herb_name +WHERE mm.category = '한약재' + AND (mm.cancel_date IS NULL OR mm.cancel_date = ''); +``` + +--- + +## 5. 코드 체계 정리 + +| 코드 | 출처 | 예시 | 설명 | +|------|------|------|------| +| `ingredient_code` | herb_masters | 3033H1AHM | 성분코드 (약재 공통) | +| `product_code` | herb_products | 062400390 | 보험 제품코드 9자리 | +| `representative_code` | 양쪽 | 8800624003904 | **대표 바코드 (매핑 키)** | +| `standard_code` | 양쪽 | 8800624003911 | 규격별 바코드 | +| `item_std_code` | medicine_master | 200406389 | 식약처 품목기준코드 | +| `insurance_code` | herb_items | 062400390 | = product_code와 동일 체계 | + +--- + +## 6. 활용 가능 시나리오 + +1. **바코드 스캔 입고**: 제품 바코드(standard_code) → herb_products → ingredient_code → herb_items 자동 매칭 +2. **제품 비교**: 같은 ingredient_code의 다른 업체 제품 가격/규격 비교 +3. **마스터 검색 → 입고 연동**: 의약품 마스터에서 검색 → representative_code로 herb_products 조회 → 입고 등록 +4. **유효성 검증**: 취소(cancel_date) 제품 식별, 현행 유통 제품만 필터링 + +--- + +*최종 수정: 2026-02-19* diff --git a/docs/경방신약_주문매핑_20260219.md b/docs/경방신약_주문매핑_20260219.md new file mode 100644 index 0000000..e978aee --- /dev/null +++ b/docs/경방신약_주문매핑_20260219.md @@ -0,0 +1,72 @@ +# 경방신약 주문 매핑 결과 (2026-02-19) + +## 조건 +- 제약사: 경방신약(주) +- 규격: 500그램 +- 포장: 병/통 +- DB: medicine_master (의약품 표준코드) + +## 매핑 결과: 38/38건 전체 매칭 완료 (한방 OTC 37건 + 일반 OTC 1건) + +### 한방 OTC (37건) — 소계 1,531,300원 + +| # | 주문 약품명 | 수량 | 단가(원) | 매칭 제품명 | 표준코드 | 비고 | +|---|-----------|------|---------|-----------|---------|------| +| 1 | 가미귀비탕 | 1 | 53,400 | 진경안신엑스과립(가미귀비탕엑스과립) | 8806613031439 | 500g 통 (수동매핑) | +| 2 | 가미소요산 | 1 | 35,000 | 경방가미소요산엑스과립 | 8806613001012 | | +| 3 | 가미온담탕 | 1 | 69,600 | 자양심간탕엑스과립(가미온담탕) | 8806613030616 | | +| 4 | 갈근탕 | 1 | 27,700 | 치감엑스과립(갈근탕엑스과립) | 8806613032337 | | +| 5 | 계지복령환 | 1 | 35,000 | 모시나엑스과립(계지복령환) | 8806613023816 | | +| 6 | 곽향정기산 | 1 | 31,800 | 씨즌쿨엑스과립(곽향정기산) | 8806613027531 | | +| 7 | 당귀작약산 | 1 | 36,200 | 경방당귀작약산엑스과립 | 8806613004716 | | +| 8 | 대시호탕 | 1 | 37,300 | 경방대시호탕엑스과립 | 8806613004914 | | +| 9 | 마행감석탕 | 1 | 28,500 | 사브엑스과립(마행감석탕) | 8806613025438 | | +| 10 | 맥문동탕 | 1 | 35,800 | 윤폐탕엑스과립(맥문동탕엑스과립) | 8806613030234 | | +| 11 | 반하백출천마탕 | 1 | 57,200 | 경방반하백출천마탕엑스과립 | 8806613007717 | | +| 12 | 반하사심탕 | 1 | 49,000 | 스토마큐(반하사심탕엑스과립) | 8806613026541 | | +| 13 | 방풍통성산 | 1 | 33,600 | 경방방풍통성산엑스과립 | 8806613008219 | | +| 14 | 배농산급탕 | 1 | 26,700 | 오메가엑스과립(배농산급탕) | 8806613028439 | | +| 15 | 보중익기탕 | 1 | 58,800 | 경방보중익기탕엑스과립 | 8806613008912 | | +| 16 | 사물탕 | 1 | 35,000 | 경방사물탕엑스과립 | 8806613009612 | | +| 17 | 소건중탕 | 1 | 25,000 | 포키드엑스과립(소건중탕) | 8806613033617 | | +| 18 | 소경활혈탕 | 1 | 40,300 | 경방소경활혈탕엑스과립 | 8806613011110 | | +| 19 | 소시호탕 | 1 | 55,000 | 정해탕(소시호탕엑스과립) | 8806613030937 | | +| 20 | 소청룡탕 | 1 | 41,400 | 소폐탕엑스과립(소청룡탕) | 8806613026138 | 500g 통 (수동매핑) | +| 21 | 시함탕 | 1 | 49,800 | 폐활탕엑스과립(시함탕) | 8806613033433 | | +| 22 | 시호가용골모려탕 | 1 | 46,200 | 브아피엑스과립(시호가용골모려탕엑스) | 8806613024738 | | +| 23 | 십미패독산 | 1 | 48,700 | 해스킨엑스과립(십미패독탕) | 8806613034430 | 산→탕 명칭차이 (수동매핑) | +| 24 | 오령산 | 1 | 37,200 | 자리투엑스과립(오령산엑스과립) | 8806613030531 | | +| 25 | 오적산 | 1 | 50,400 | 경방오적산엑스과립 | 8806613014814 | | +| 26 | 용담사간탕 | 1 | 37,100 | 용패탕(용담사간탕엑스과립) | 8806613029436 | | +| 27 | 월비가출탕 | 1 | 40,400 | 마이진탕엑스과립(월비가출탕) | 8806613023618 | | +| 28 | 육미지황탕 | 1 | 40,300 | 경방육미지황탕엑스과립 | 8806613015613 | | +| 29 | 인삼패독산 | 1 | 52,300 | 가쁘레엑스과립(인삼패독산) | 8806613000138 | 500g 통 (수동매핑) | +| 30 | 자음강화탕 | 1 | 41,500 | 자활탕(자음강화탕엑스과립) | 8806613030715 | | +| 31 | 저령탕 | 1 | 36,700 | 바디스엑스과립(저령탕엑스) | 8806613024332 | | +| 32 | 청화보음탕 | 1 | 34,200 | 디휘버엑스과립(청화보음탕) | 8806613023038 | | +| 33 | 팔미지황환 | 1 | 41,700 | 경방팔미지황환엑스과립 | 8806613019710 | | +| 34 | 평위산 | 1 | 28,000 | 위제나과립(평위산엑스과립) | 8806613029818 | | +| 35 | 향사육군자탕 | 1 | 58,400 | 스토비엑스과립(향사육군자탕) | 8806613026817 | | +| 36 | 형개연교탕 | 1 | 35,600 | 노넥스엑스과립(형개연교탕) | 8806613022314 | | +| 37 | 황련해독탕 | 1 | 40,500 | 오브스과립(황련해독탕엑스과립) | 8806613028613 | | + +### 일반 OTC (1건) — 소계 30,500원 + +| # | 주문 약품명 | 수량 | 단가(원) | 매칭 제품명 | 표준코드 | 비고 | +|---|-----------|------|---------|-----------|---------|------| +| 1 | 미소그린에스과립 | 1 | 30,500 | 미소그린에스과립 | 8806613066615 | 420g 병. 한방 OTC 아님 | + +### 총액: 1,561,800원 (한방 OTC 1,531,300 + 일반 OTC 30,500) + +--- + +### 수동 매핑 처리 완료 (4건) + +초기 자동매칭 시 누락되었으나, 수동 확인으로 전부 매칭 완료. + +| # | 주문 약품명 | 매칭 제품명 | 표준코드 | 사유 | +|---|-----------|-----------|---------|------| +| 1 | 가미귀비탕 | 진경안신엑스과립(가미귀비탕엑스과립) | 8806613031439 | 500g 통. 브랜드명이 달라 자동매칭 실패 | +| 2 | 소청룡탕 | 소폐탕엑스과립(소청룡탕) | 8806613026138 | 500g 통. 포장이 "통"이라 "병" 필터에서 누락 | +| 3 | 십미패독산 | 해스킨엑스과립(십미패독탕) | 8806613034430 | 500g 병. DB에 "산"이 아닌 "탕"으로 등록 | +| 4 | 인삼패독산 | 가쁘레엑스과립(인삼패독산) | 8806613000138 | 500g 통. 포장이 "통"이라 "병" 필터에서 누락 | diff --git a/docs/주문마이페이지.md b/docs/주문마이페이지.md new file mode 100644 index 0000000..eeaa6d8 --- /dev/null +++ b/docs/주문마이페이지.md @@ -0,0 +1,59 @@ +약재명 보험코드 원산지 수량 단가 금액 현재고 +휴먼갈근 +갈근.각 062401050 한국 2500g ₩17 ₩42,000 2500g +휴먼감초 +감초.1호[야생](1kg) 062400740 중국 5000g ₩22 ₩110,500 4610g +휴먼건강 +건강 062400730 페루 5000g ₩12 ₩62,000 4880g +휴먼건강 +건강.土 062400730 한국 1500g ₩51 ₩77,100 1390g +휴먼계지 +계지 062400390 베트남 2500g ₩6 ₩14,500 2500g +휴먼구기자 +구기자(영하)(1kg) 062400090 중국 3000g ₩18 ₩53,700 3000g +휴먼길경 +길경.片[특] 062400980 중국 1500g ₩11 ₩15,900 1500g +휴먼대추 +대추(절편)(1kg) 062401120 한국 5000g ₩20 ₩100,000 4650g +휴먼마황 +마황(1kg) 062401400 중국 5000g ₩10 ₩48,000 4970g +휴먼반하생강백반제 +반하생강백반제(1kg) 062401790 중국 3000g ₩34 ₩101,100 2850g +휴먼백출 +백출.당[1kg] 062402150 중국 2000g ₩12 ₩23,600 1560g +휴먼복령 +복령(1kg) 062402310 중국 5000g ₩12 ₩57,500 4760g +휴먼석고 +석고[통포장](kg) 062402650 중국 4000g ₩5 ₩18,800 4000g +휴먼세신 +세신.中 062402860 중국 1500g ₩129 ₩193,500 1500g +신흥숙지황 +숙지황(9증)(신흥.1kg)[완] 060600050 중국 5000g ₩20 ₩100,000 4360g +휴먼오미자 +오미자<토매지>(1kg) 062402800 중국 2000g ₩18 ₩35,000 2000g +휴먼용안육 +용안육.名品(1kg) 062403120 태국 3000g ₩21 ₩62,100 3000g +휴먼육계 +육계.YB 062403210 베트남 2500g ₩15 ₩36,500 2400g +휴먼일당귀 +일당귀.中(1kg) 062403450 중국 5000g ₩13 ₩64,500 4640g +휴먼자소엽 +자소엽.土 062403350 한국 1500g ₩14 ₩20,700 1410g +휴먼작약 +작약(1kg) 062403380 한국 3000g ₩19 ₩56,100 2460g +휴먼작약주자 +작약주자.土[酒炙] 062403470 한국 1500g ₩25 ₩36,900 1500g +휴먼전호 +전호[재배] 062403490 중국 1500g ₩14 ₩21,000 1500g +휴먼지각 +지각 062402220 중국 1500g ₩10 ₩15,000 1500g +휴먼지황 +지황.건[회](1kg) 062402030 중국 1000g ₩12 ₩11,500 1000g +휴먼진피(陳皮) +진피.비열[非熱](1kg) 062401950 한국 5000g ₩14 ₩68,500 5000g +휴먼창출 +창출[북창출.재배](1kg) 062401830 중국 3000g ₩14 ₩40,500 3000g +휴먼천궁 +천궁.일<토매지>(1kg) 062401810 중국 3000g ₩12 ₩35,700 2560g +휴먼황기 +황기(직절.小)(1kg) 062400040 중국 3000g ₩10 ₩29,700 2500g \ No newline at end of file diff --git a/static/app.js b/static/app.js index bd1ca0c..0d84b0c 100644 --- a/static/app.js +++ b/static/app.js @@ -116,6 +116,9 @@ $(document).ready(function() { case 'herb-info': loadHerbInfo(); break; + case 'medicine-master': + if (typeof loadMedicineMaster === 'function') loadMedicineMaster(); + break; } } @@ -2132,9 +2135,18 @@ $(document).ready(function() { totalValue += item.total_value || 0; if (item.total_quantity > 0) herbsInStock++; + const isOTC = item.product_type === 'OTC'; + const typeBadge = isOTC + ? 'OTC' + : '한약재'; + const codeDisplay = isOTC + ? (item.standard_code || '-') + : (item.insurance_code || '-'); + tbody.append(` - ${item.insurance_code || '-'} + ${typeBadge} + ${codeDisplay} ${item.herb_name}${originBadge}${efficacyTags} ${item.total_quantity.toFixed(1)} ${item.lot_count} @@ -2253,7 +2265,8 @@ $(document).ready(function() { @@ -2293,9 +2306,14 @@ $(document).ready(function() { tbody.empty(); response.data.forEach(herb => { + const hIsOTC = herb.product_type === 'OTC'; + const hCode = hIsOTC ? (herb.standard_code || '-') : (herb.insurance_code || '-'); + const hTypeBadge = hIsOTC + ? 'OTC' + : '한약재'; tbody.append(` - ${herb.insurance_code || '-'} + ${hTypeBadge} ${hCode} ${herb.herb_name} ${herb.specification || '-'} ${herb.current_stock ? herb.current_stock.toFixed(1) + 'g' : '0g'} @@ -2394,14 +2412,24 @@ $(document).ready(function() { if (line.processing) variantBadges += `${line.processing}`; if (line.grade) variantBadges += `${line.grade}`; + const lineIsOTC = line.product_type === 'OTC'; + const lineTypeBadge = lineIsOTC + ? 'OTC' + : '한약재'; + const lineCode = lineIsOTC + ? (line.standard_code || '-') + : (line.insurance_code || '-'); + const lineCodeLabel = lineIsOTC ? '표준' : '보험'; + linesHtml += ` + ${lineTypeBadge}
${line.herb_name}
${line.display_name ? `${line.display_name}` : ''} ${variantBadges} - ${line.insurance_code || '-'} + ${lineCodeLabel}
${lineCode} ${line.origin_country || '-'} ${line.quantity_g}g ${formatCurrency(line.unit_price_per_g)} @@ -2428,8 +2456,8 @@ $(document).ready(function() { - - + + @@ -3468,7 +3496,7 @@ $(document).ready(function() { itemsBody.append(` - + diff --git a/static/medicine_master.js b/static/medicine_master.js index 2b2fd0c..b9aa705 100644 --- a/static/medicine_master.js +++ b/static/medicine_master.js @@ -367,13 +367,19 @@ return; } - // 규격 미파싱 경고 + // 규격 미파싱 경고 (spec_grams=0이면 g 환산 불가) const noSpec = cart.filter(c => !c.spec_grams); if (noSpec.length > 0) { - alert(`규격(g)을 파싱할 수 없는 항목이 ${noSpec.length}건 있습니다.\n해당 항목은 g당단가가 계산되지 않습니다.\n\n${noSpec.map(c => '- ' + c.product_name).join('\n')}`); + const ok = confirm( + `규격(g)을 파싱할 수 없는 항목이 ${noSpec.length}건 있습니다.\n` + + `해당 항목은 g당단가가 계산되지 않습니다.\n\n` + + noSpec.map(c => '- ' + c.product_name + ' (' + c.spec + ')').join('\n') + + '\n\n그래도 진행하시겠습니까?' + ); + if (!ok) return; } - // 요약 표시 + // 확인 요약 const supplierName = $('#cartSupplier option:selected').text(); let totalAmt = 0; cart.forEach(item => { @@ -381,25 +387,65 @@ }); const summary = [ - `[입고장 요약]`, `도매상: ${supplierName}`, `입고일: ${receiptDate}`, `품목 수: ${cart.length}건`, `총 금액: ${totalAmt.toLocaleString()}원`, - notes ? `비고: ${notes}` : '', - '', - '--- 품목 ---', - ...cart.map((c, i) => { - const amt = c.qty * c.unit_price; - const ppg = c.spec_grams ? (c.unit_price / c.spec_grams).toFixed(1) : '?'; - const totalG = c.spec_grams ? (c.spec_grams * c.qty).toLocaleString() + 'g' : '?'; - return `${i+1}. ${c.product_name} (${c.spec}) ×${c.qty} @${c.unit_price.toLocaleString()}원 = ${amt.toLocaleString()}원 [${totalG}, ${ppg}원/g]`; - }), - '', - '(DB 연동은 추후 구현 예정입니다)' - ].filter(Boolean).join('\n'); + ].join('\n'); - alert(summary); + if (!confirm(`입고장을 생성하시겠습니까?\n\n${summary}`)) return; + + // API 요청 데이터 구성 + const payload = { + supplier_id: parseInt(supplierId), + receipt_date: receiptDate, + notes: notes, + lines: cart.map(c => ({ + standard_code: c.standard_code, + product_name: c.product_name, + company_name: c.company_name, + spec: c.spec, + spec_grams: c.spec_grams, + qty: c.qty, + unit_price: c.unit_price, + origin_country: c.origin_country + })) + }; + + // 버튼 비활성화 + const btn = $('#createReceiptBtn'); + btn.prop('disabled', true).html(' 처리중...'); + + $.ajax({ + url: '/api/purchase-receipts/from-cart', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(payload), + success: function(response) { + if (response.success) { + alert( + `입고 완료!\n\n` + + `입고장 번호: ${response.receipt_no}\n` + + `품목: ${response.summary.item_count}건\n` + + `총 금액: ${response.summary.total_amount}` + ); + // 장바구니 초기화 + cart = []; + renderCart(); + // 검색결과 버튼 전부 재활성화 + $('.med-add-cart-btn').prop('disabled', false); + } else { + alert('입고 실패: ' + response.error); + } + }, + error: function(xhr) { + const msg = xhr.responseJSON?.error || '서버 오류'; + alert('입고 실패: ' + msg); + }, + complete: function() { + btn.prop('disabled', false).html(' 입고장 생성'); + } + }); } // ─── 도매상 로드 ─────────────────────────────────────── diff --git a/templates/index.html b/templates/index.html index 585f949..f602089 100644 --- a/templates/index.html +++ b/templates/index.html @@ -127,6 +127,11 @@ 약재 정보 + @@ -915,8 +920,9 @@
약재명보험코드품목명코드 원산지 수량 단가
${item.herb_name}${item.insurance_code || '-'}${item.product_type === 'OTC' ? (item.standard_code || '-') : (item.insurance_code || '-')} ${item.origin_country || '-'} #${item.lot_id} ${item.quantity_before.toFixed(1)}g
- - + + + @@ -1154,8 +1160,8 @@
보험코드약재명구분코드품목명 현재 재고(g) 로트 수 평균 단가
- - + + @@ -1479,6 +1485,10 @@ + + + {% include 'medicine_master.html' %} + @@ -2107,6 +2117,7 @@ +
약재명보험코드품목명코드 원산지 로트ID 보정 전