diff --git a/app.py b/app.py index 5663edc..e44e2fe 100644 --- a/app.py +++ b/app.py @@ -125,18 +125,36 @@ def create_patient(): @app.route('/api/herbs', methods=['GET']) def get_herbs(): - """약재 목록 조회""" + """약재 목록 조회 (효능 태그 포함)""" try: with get_db() as conn: cursor = conn.cursor() cursor.execute(""" - SELECT h.*, COALESCE(s.total_quantity, 0) as current_stock + SELECT + h.herb_item_id, + h.insurance_code, + h.herb_name, + h.is_active, + COALESCE(SUM(il.quantity_onhand), 0) as current_stock, + GROUP_CONCAT(DISTINCT et.tag_name) as efficacy_tags FROM herb_items h - LEFT JOIN v_current_stock s ON h.herb_item_id = s.herb_item_id + LEFT JOIN inventory_lots il ON h.herb_item_id = il.herb_item_id + AND il.is_depleted = 0 + LEFT JOIN herb_item_tags hit ON h.herb_item_id = hit.herb_item_id + LEFT JOIN herb_efficacy_tags et ON hit.tag_id = et.tag_id WHERE h.is_active = 1 + GROUP BY h.herb_item_id, h.insurance_code, h.herb_name, h.is_active ORDER BY h.herb_name """) herbs = [dict(row) for row in cursor.fetchall()] + + # 태그를 리스트로 변환 + for herb in herbs: + if herb['efficacy_tags']: + herb['efficacy_tags'] = herb['efficacy_tags'].split(',') + else: + herb['efficacy_tags'] = [] + return jsonify({'success': True, 'data': herbs}) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @@ -227,6 +245,53 @@ def get_formula_ingredients(formula_id): except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 +# ==================== 도매상 관리 API ==================== + +@app.route('/api/suppliers', methods=['GET']) +def get_suppliers(): + """도매상 목록 조회""" + try: + with get_db() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT supplier_id, name, business_no, phone, address, is_active + FROM suppliers + WHERE is_active = 1 + ORDER BY name + """) + suppliers = [dict(row) for row in cursor.fetchall()] + return jsonify({'success': True, 'data': suppliers}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/suppliers', methods=['POST']) +def create_supplier(): + """도매상 등록""" + try: + data = request.json + with get_db() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO suppliers (name, business_no, contact_person, phone, address) + VALUES (?, ?, ?, ?, ?) + """, ( + data['name'], + data.get('business_no'), + data.get('contact_person'), + data.get('phone'), + data.get('address') + )) + supplier_id = cursor.lastrowid + return jsonify({ + 'success': True, + 'message': '도매상이 등록되었습니다', + 'supplier_id': supplier_id + }) + except sqlite3.IntegrityError: + return jsonify({'success': False, 'error': '이미 등록된 도매상입니다'}), 400 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + # ==================== 입고 관리 API ==================== @app.route('/api/upload/purchase', methods=['POST']) @@ -243,6 +308,11 @@ def upload_purchase_excel(): if not allowed_file(file.filename): return jsonify({'success': False, 'error': '허용되지 않는 파일 형식입니다'}), 400 + # 도매상 ID 가져오기 (폼 데이터에서) + supplier_id = request.form.get('supplier_id') + if not supplier_id: + return jsonify({'success': False, 'error': '도매상을 선택해주세요'}), 400 + # 파일 저장 filename = secure_filename(file.filename) timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') @@ -281,21 +351,16 @@ def upload_purchase_excel(): processed_rows = 0 processed_items = set() - # 날짜별, 업체별로 그룹화 - grouped = df.groupby(['receipt_date', 'supplier_name']) + # 도매상 정보 확인 + cursor.execute("SELECT name FROM suppliers WHERE supplier_id = ?", (supplier_id,)) + supplier_info = cursor.fetchone() + if not supplier_info: + return jsonify({'success': False, 'error': '유효하지 않은 도매상입니다'}), 400 - for (receipt_date, supplier_name), group in grouped: - # 공급업체 확인/생성 - cursor.execute("SELECT supplier_id FROM suppliers WHERE name = ?", (supplier_name,)) - supplier = cursor.fetchone() + # 날짜별로 그룹화 (도매상은 이미 선택됨) + grouped = df.groupby(['receipt_date']) - if not supplier: - cursor.execute(""" - INSERT INTO suppliers (name) VALUES (?) - """, (supplier_name,)) - supplier_id = cursor.lastrowid - else: - supplier_id = supplier[0] + for receipt_date, group in grouped: # 입고장 헤더 생성 total_amount = group['total_amount'].sum() @@ -399,13 +464,13 @@ def get_purchase_receipts(): pr.receipt_id, pr.receipt_date, pr.receipt_no, - pr.total_amount, pr.source_file, pr.created_at, s.name as supplier_name, s.supplier_id, COUNT(prl.line_id) as line_count, - SUM(prl.quantity_g) as total_quantity + SUM(prl.quantity_g) as total_quantity, + SUM(prl.line_total) as total_amount FROM purchase_receipts pr JOIN suppliers s ON pr.supplier_id = s.supplier_id LEFT JOIN purchase_receipt_lines prl ON pr.receipt_id = prl.receipt_id @@ -625,7 +690,19 @@ def delete_purchase_receipt(receipt_id): 'error': f'{usage["used_count"]}개 품목에서 {usage["used_quantity"]}g이 이미 사용되어 삭제할 수 없습니다' }), 400 - # 재고 로트 삭제 + # 삭제 순서 중요: 참조하는 테이블부터 삭제 + # 1. 재고 원장 기록 삭제 (lot_id를 참조) + cursor.execute(""" + DELETE FROM stock_ledger + WHERE lot_id IN ( + SELECT lot_id FROM inventory_lots + WHERE receipt_line_id IN ( + SELECT line_id FROM purchase_receipt_lines WHERE receipt_id = ? + ) + ) + """, (receipt_id,)) + + # 2. 재고 로트 삭제 (receipt_line_id를 참조) cursor.execute(""" DELETE FROM inventory_lots WHERE receipt_line_id IN ( @@ -633,16 +710,10 @@ def delete_purchase_receipt(receipt_id): ) """, (receipt_id,)) - # 재고 원장 기록 - cursor.execute(""" - DELETE FROM stock_ledger - WHERE reference_table = 'purchase_receipts' AND reference_id = ? - """, (receipt_id,)) - - # 입고장 라인 삭제 + # 3. 입고장 라인 삭제 (receipt_id를 참조) cursor.execute("DELETE FROM purchase_receipt_lines WHERE receipt_id = ?", (receipt_id,)) - # 입고장 헤더 삭제 + # 4. 입고장 헤더 삭제 cursor.execute("DELETE FROM purchase_receipts WHERE receipt_id = ?", (receipt_id,)) return jsonify({'success': True, 'message': '입고장이 삭제되었습니다'}) @@ -686,6 +757,7 @@ def create_compound(): for ingredient in data['ingredients']: herb_item_id = ingredient['herb_item_id'] total_grams = ingredient['total_grams'] + origin_country = ingredient.get('origin_country') # 원산지 선택 정보 # 조제 약재 구성 기록 cursor.execute(""" @@ -695,14 +767,26 @@ def create_compound(): """, (compound_id, herb_item_id, ingredient['grams_per_cheop'], total_grams)) - # 재고 차감 (FIFO 방식) + # 재고 차감 (FIFO 방식 - 원산지 지정 시 해당 원산지만) remaining_qty = total_grams - cursor.execute(""" - SELECT lot_id, quantity_onhand, unit_price_per_g - FROM inventory_lots - WHERE herb_item_id = ? AND is_depleted = 0 AND quantity_onhand > 0 - ORDER BY received_date, lot_id - """, (herb_item_id,)) + + # 원산지가 지정된 경우 해당 원산지만, 아니면 전체에서 FIFO + if origin_country and origin_country != 'auto': + cursor.execute(""" + SELECT lot_id, quantity_onhand, unit_price_per_g + FROM inventory_lots + WHERE herb_item_id = ? AND is_depleted = 0 AND quantity_onhand > 0 + AND origin_country = ? + ORDER BY unit_price_per_g, received_date, lot_id + """, (herb_item_id, origin_country)) + else: + # 자동 선택: 가격이 저렴한 것부터 + cursor.execute(""" + SELECT lot_id, quantity_onhand, unit_price_per_g + FROM inventory_lots + WHERE herb_item_id = ? AND is_depleted = 0 AND quantity_onhand > 0 + ORDER BY unit_price_per_g, received_date, lot_id + """, (herb_item_id,)) lots = cursor.fetchall() @@ -761,11 +845,90 @@ def create_compound(): except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 +# ==================== 조제용 재고 조회 API ==================== + +@app.route('/api/herbs//available-lots', methods=['GET']) +def get_available_lots(herb_item_id): + """조제용 가용 로트 목록 - 원산지별로 그룹화""" + try: + with get_db() as conn: + cursor = conn.cursor() + + # 약재 정보 + cursor.execute(""" + SELECT herb_name, insurance_code + FROM herb_items + WHERE herb_item_id = ? + """, (herb_item_id,)) + herb = cursor.fetchone() + + if not herb: + return jsonify({'success': False, 'error': '약재를 찾을 수 없습니다'}), 404 + + # 가용 로트 목록 (소진되지 않은 재고) + cursor.execute(""" + SELECT + lot_id, + origin_country, + quantity_onhand, + unit_price_per_g, + received_date, + supplier_id + FROM inventory_lots + WHERE herb_item_id = ? + AND is_depleted = 0 + AND quantity_onhand > 0 + ORDER BY origin_country, unit_price_per_g, received_date + """, (herb_item_id,)) + + lots = [] + for row in cursor.fetchall(): + lots.append({ + 'lot_id': row[0], + 'origin_country': row[1] or '미지정', + 'quantity_onhand': row[2], + 'unit_price_per_g': row[3], + 'received_date': row[4], + 'supplier_id': row[5] + }) + + # 원산지별 요약 + origin_summary = {} + for lot in lots: + origin = lot['origin_country'] + if origin not in origin_summary: + origin_summary[origin] = { + 'origin_country': origin, + 'total_quantity': 0, + 'min_price': float('inf'), + 'max_price': 0, + 'lot_count': 0, + 'lots': [] + } + + origin_summary[origin]['total_quantity'] += lot['quantity_onhand'] + origin_summary[origin]['min_price'] = min(origin_summary[origin]['min_price'], lot['unit_price_per_g']) + origin_summary[origin]['max_price'] = max(origin_summary[origin]['max_price'], lot['unit_price_per_g']) + origin_summary[origin]['lot_count'] += 1 + origin_summary[origin]['lots'].append(lot) + + return jsonify({ + 'success': True, + 'data': { + 'herb_name': herb[0], + 'insurance_code': herb[1], + 'origins': list(origin_summary.values()), + 'total_quantity': sum(lot['quantity_onhand'] for lot in lots) + } + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + # ==================== 재고 현황 API ==================== @app.route('/api/inventory/summary', methods=['GET']) def get_inventory_summary(): - """재고 현황 요약""" + """재고 현황 요약 - 원산지별 구분 표시""" try: with get_db() as conn: cursor = conn.cursor() @@ -776,16 +939,29 @@ def get_inventory_summary(): h.herb_name, 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, AVG(il.unit_price_per_g) as avg_price, - COALESCE(SUM(il.quantity_onhand * il.unit_price_per_g), 0) as total_value + MIN(il.unit_price_per_g) as min_price, + MAX(il.unit_price_per_g) as max_price, + COALESCE(SUM(il.quantity_onhand * il.unit_price_per_g), 0) as total_value, + GROUP_CONCAT(DISTINCT et.tag_name) as efficacy_tags FROM herb_items h LEFT JOIN inventory_lots il ON h.herb_item_id = il.herb_item_id AND il.is_depleted = 0 + LEFT JOIN herb_item_tags hit ON h.herb_item_id = hit.herb_item_id + LEFT JOIN herb_efficacy_tags et ON hit.tag_id = et.tag_id GROUP BY h.herb_item_id, h.insurance_code, h.herb_name HAVING total_quantity > 0 ORDER BY h.herb_name """) inventory = [dict(row) for row in cursor.fetchall()] + # 태그를 리스트로 변환 + for item in inventory: + if item['efficacy_tags']: + item['efficacy_tags'] = item['efficacy_tags'].split(',') + else: + item['efficacy_tags'] = [] + # 전체 요약 total_value = sum(item['total_value'] for item in inventory) total_items = len(inventory) @@ -801,6 +977,78 @@ def get_inventory_summary(): except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 +@app.route('/api/inventory/detail/', methods=['GET']) +def get_inventory_detail(herb_item_id): + """약재별 재고 상세 - 원산지별로 구분""" + try: + with get_db() as conn: + cursor = conn.cursor() + + # 약재 기본 정보 + cursor.execute(""" + SELECT herb_item_id, insurance_code, herb_name + FROM herb_items + WHERE herb_item_id = ? + """, (herb_item_id,)) + herb = cursor.fetchone() + + if not herb: + return jsonify({'success': False, 'error': '약재를 찾을 수 없습니다'}), 404 + + herb_data = dict(herb) + + # 원산지별 재고 정보 + cursor.execute(""" + SELECT + il.lot_id, + il.origin_country, + il.quantity_onhand, + il.unit_price_per_g, + il.received_date, + il.supplier_id, + s.name as supplier_name, + il.quantity_onhand * il.unit_price_per_g as lot_value + FROM inventory_lots il + LEFT JOIN suppliers s ON il.supplier_id = s.supplier_id + WHERE il.herb_item_id = ? AND il.is_depleted = 0 + ORDER BY il.origin_country, il.unit_price_per_g, il.received_date + """, (herb_item_id,)) + + lots = [dict(row) for row in cursor.fetchall()] + + # 원산지별 그룹화 + by_origin = {} + for lot in lots: + origin = lot['origin_country'] or '미지정' + if origin not in by_origin: + by_origin[origin] = { + 'origin_country': origin, + 'lots': [], + 'total_quantity': 0, + 'total_value': 0, + 'min_price': float('inf'), + 'max_price': 0, + 'avg_price': 0 + } + + by_origin[origin]['lots'].append(lot) + by_origin[origin]['total_quantity'] += lot['quantity_onhand'] + by_origin[origin]['total_value'] += lot['lot_value'] + by_origin[origin]['min_price'] = min(by_origin[origin]['min_price'], lot['unit_price_per_g']) + by_origin[origin]['max_price'] = max(by_origin[origin]['max_price'], lot['unit_price_per_g']) + + # 평균 단가 계산 + for origin_data in by_origin.values(): + if origin_data['total_quantity'] > 0: + origin_data['avg_price'] = origin_data['total_value'] / origin_data['total_quantity'] + + herb_data['origins'] = list(by_origin.values()) + herb_data['total_origins'] = len(by_origin) + + return jsonify({'success': True, 'data': herb_data}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + # 서버 실행 if __name__ == '__main__': # 데이터베이스 초기화 diff --git a/static/app.js b/static/app.js index a163ee3..a535813 100644 --- a/static/app.js +++ b/static/app.js @@ -32,6 +32,7 @@ $(document).ready(function() { break; case 'purchase': loadPurchaseReceipts(); + loadSuppliersForSelect(); break; case 'formulas': loadFormulas(); @@ -287,6 +288,15 @@ $(document).ready(function() { return; } + // 직접조제인 경우 + if (formulaId === 'custom') { + $('#compoundIngredients').empty(); + // 빈 행 하나 추가 + addEmptyIngredientRow(); + return; + } + + // 등록된 처방인 경우 $.get(`/api/formulas/${formulaId}/ingredients`, function(response) { if (response.success) { $('#compoundIngredients').empty(); @@ -303,6 +313,11 @@ $(document).ready(function() { value="${ing.grams_per_cheop}" min="0.1" step="0.1"> ${totalGrams.toFixed(1)} + + + 확인중... + + + `); + + $('#compoundIngredients').append(newRow); + + // 약재 목록 로드 + loadHerbsForSelect(newRow.find('.herb-select-compound')); + + // 약재 선택 시 원산지 옵션 로드 + newRow.find('.herb-select-compound').on('change', function() { + const herbId = $(this).val(); + if (herbId) { + const row = $(this).closest('tr'); + row.attr('data-herb-id', herbId); + const cheopTotal = parseFloat($('#cheopTotal').val()) || 0; + const gramsPerCheop = parseFloat(row.find('.grams-per-cheop').val()) || 0; + const totalGrams = gramsPerCheop * cheopTotal; + loadOriginOptions(herbId, totalGrams); + } + }); + + // 이벤트 바인딩 + newRow.find('.grams-per-cheop').on('input', function() { + updateIngredientTotals(); + // 원산지 옵션 다시 로드 + const herbId = $(this).closest('tr').attr('data-herb-id'); + if (herbId) { + const cheopTotal = parseFloat($('#cheopTotal').val()) || 0; + const gramsPerCheop = parseFloat($(this).val()) || 0; + const totalGrams = gramsPerCheop * cheopTotal; + loadOriginOptions(herbId, totalGrams); + } + }); + + newRow.find('.remove-compound-ingredient').on('click', function() { + $(this).closest('tr').remove(); + updateIngredientTotals(); + }); + } + + $('#addIngredientBtn').on('click', function() { + addEmptyIngredientRow(); + }); + + // 기존 약재 추가 버튼 (기존 코드 삭제) + /* $('#addIngredientBtn').on('click', function() { const newRow = $(` @@ -391,6 +480,7 @@ $(document).ready(function() { updateIngredientTotals(); }); }); + */ // 조제 실행 $('#compoundEntryForm').on('submit', function(e) { @@ -401,12 +491,14 @@ $(document).ready(function() { const herbId = $(this).data('herb-id'); const gramsPerCheop = parseFloat($(this).find('.grams-per-cheop').val()); const totalGrams = parseFloat($(this).find('.total-grams').text()); + const originCountry = $(this).find('.origin-select').val(); if (herbId && gramsPerCheop) { ingredients.push({ herb_item_id: parseInt(herbId), grams_per_cheop: gramsPerCheop, - total_grams: totalGrams + total_grams: totalGrams, + origin_country: originCountry || null // 원산지 선택 정보 추가 }); } }); @@ -452,17 +544,140 @@ $(document).ready(function() { tbody.empty(); response.data.forEach(item => { + // 원산지가 여러 개인 경우 표시 + const originBadge = item.origin_count > 1 + ? `${item.origin_count}개 원산지` + : ''; + + // 효능 태그 표시 + let efficacyTags = ''; + if (item.efficacy_tags && item.efficacy_tags.length > 0) { + efficacyTags = item.efficacy_tags.map(tag => + `${tag}` + ).join(''); + } + + // 가격 범위 표시 (원산지가 여러 개이고 가격차가 있는 경우) + let priceDisplay = item.avg_price ? formatCurrency(item.avg_price) : '-'; + if (item.origin_count > 1 && item.min_price && item.max_price && item.min_price !== item.max_price) { + priceDisplay = `${formatCurrency(item.min_price)} ~ ${formatCurrency(item.max_price)}`; + } + tbody.append(` - + ${item.insurance_code || '-'} - ${item.herb_name} + ${item.herb_name}${originBadge}${efficacyTags} ${item.total_quantity.toFixed(1)} ${item.lot_count} - ${item.avg_price ? formatCurrency(item.avg_price) : '-'} + ${priceDisplay} ${formatCurrency(item.total_value)} `); }); + + // 클릭 이벤트 바인딩 + $('.inventory-row').on('click', function() { + const herbId = $(this).data('herb-id'); + showInventoryDetail(herbId); + }); + } + }); + } + + // 재고 상세 모달 표시 + function showInventoryDetail(herbId) { + $.get(`/api/inventory/detail/${herbId}`, function(response) { + if (response.success) { + const data = response.data; + + // 원산지별 재고 정보 HTML 생성 + let originsHtml = ''; + data.origins.forEach(origin => { + originsHtml += ` +
+
+
+ ${origin.origin_country} + ${origin.total_quantity.toFixed(1)}g +
+
+
+
+
+ 평균 단가:
+ ${formatCurrency(origin.avg_price)}/g +
+
+ 재고 가치:
+ ${formatCurrency(origin.total_value)} +
+
+ + + + + + + + + + + `; + + origin.lots.forEach(lot => { + originsHtml += ` + + + + + + + `; + }); + + originsHtml += ` + +
로트ID수량단가입고일도매상
#${lot.lot_id}${lot.quantity_onhand.toFixed(1)}g${formatCurrency(lot.unit_price_per_g)}${lot.received_date}${lot.supplier_name || '-'}
+
+
`; + }); + + // 모달 생성 및 표시 + const modalHtml = ` + `; + + // 기존 모달 제거 + $('#inventoryDetailModal').remove(); + $('body').append(modalHtml); + + // 모달 표시 + const modal = new bootstrap.Modal(document.getElementById('inventoryDetailModal')); + modal.show(); } }); } @@ -520,8 +735,8 @@ $(document).ready(function() { ${receipt.receipt_date} ${receipt.supplier_name} ${receipt.line_count}개 - ${receipt.total_quantity ? receipt.total_quantity.toLocaleString() + 'g' : '-'} - ${receipt.total_amount ? formatCurrency(receipt.total_amount) : '-'} + ${receipt.total_amount ? formatCurrency(receipt.total_amount) : '-'} + ${receipt.total_quantity ? receipt.total_quantity.toLocaleString() + 'g' : '-'} ${receipt.source_file || '-'}
-
- - -
- 지원 형식: 한의사랑, 한의정보 (자동 감지) +
+
+ + +
+
+ +
- @@ -370,6 +382,7 @@ 약재명 1첩당 용량(g) 총 용량(g) + 원산지 선택 재고 작업 @@ -599,6 +612,46 @@
+ + +