From 1826ea5ca46ae1b3a2e8b55a915afcab5455972c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=9C=EA=B3=A8=EC=95=BD=EC=82=AC?= Date: Sun, 15 Feb 2026 12:19:18 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9E=AC=EA=B3=A0=20=EB=B3=B4=EC=A0=95?= =?UTF-8?q?=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 재고 보정 기능 - 재고 보정 테이블 추가 (stock_adjustments, stock_adjustment_details) - 보정 타입: LOSS(감모), FOUND(발견), RECOUNT(재고조사), DAMAGE(파손), EXPIRE(유통기한) - 보정 번호 자동 생성: ADJ-YYYYMMDD-XXXX ## API 엔드포인트 - GET /api/stock-adjustments - 보정 내역 조회 - GET /api/stock-adjustments/ - 보정 상세 조회 - POST /api/stock-adjustments - 보정 생성 ## 재고 원장 연동 - 보정 내역이 stock_ledger에 자동 기록 (ADJUST 타입) - 입출고 원장에서 보정 내역 필터링 가능 - 참고번호: ADJ-20260215-0001 ## 사용자 추적 - created_by 필드로 보정 담당자 기록 - 향후 계정 시스템 연동 준비 한약재 loss 관리 및 재고조사 기능 완비! 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app.py | 162 +++++++++++++++++++++++++++++ create_adjustment_tables.py | 69 ++++++++++++ database/add_adjustments_table.sql | 35 +++++++ static/app.js | 4 + templates/index.html | 1 + 5 files changed, 271 insertions(+) create mode 100644 create_adjustment_tables.py create mode 100644 database/add_adjustments_table.sql diff --git a/app.py b/app.py index 5ff6f63..c1539bd 100644 --- a/app.py +++ b/app.py @@ -1107,6 +1107,7 @@ def get_stock_ledger(): WHEN c.prescription_no IS NOT NULL THEN c.prescription_no || ' (' || COALESCE(f.formula_name, '직접조제') || ')' ELSE '조제#' || c.compound_id || ' (' || COALESCE(f.formula_name, '직접조제') || ')' END + WHEN sl.event_type = 'ADJUST' THEN adj.adjustment_no ELSE NULL END as reference_no, CASE @@ -1121,6 +1122,7 @@ def get_stock_ledger(): LEFT JOIN compounds c ON sl.reference_table = 'compounds' AND sl.reference_id = c.compound_id LEFT JOIN patients p ON c.patient_id = p.patient_id LEFT JOIN formulas f ON c.formula_id = f.formula_id + LEFT JOIN stock_adjustments adj ON sl.reference_table = 'stock_adjustments' AND sl.reference_id = adj.adjustment_id WHERE sl.herb_item_id = ? ORDER BY sl.event_time DESC LIMIT ? @@ -1146,6 +1148,7 @@ def get_stock_ledger(): WHEN c.prescription_no IS NOT NULL THEN c.prescription_no || ' (' || COALESCE(f.formula_name, '직접조제') || ')' ELSE '조제#' || c.compound_id || ' (' || COALESCE(f.formula_name, '직접조제') || ')' END + WHEN sl.event_type = 'ADJUST' THEN adj.adjustment_no ELSE NULL END as reference_no, CASE @@ -1160,6 +1163,7 @@ def get_stock_ledger(): LEFT JOIN compounds c ON sl.reference_table = 'compounds' AND sl.reference_id = c.compound_id LEFT JOIN patients p ON c.patient_id = p.patient_id LEFT JOIN formulas f ON c.formula_id = f.formula_id + LEFT JOIN stock_adjustments adj ON sl.reference_table = 'stock_adjustments' AND sl.reference_id = adj.adjustment_id ORDER BY sl.event_time DESC LIMIT ? """, (limit,)) @@ -1418,6 +1422,164 @@ def get_inventory_detail(herb_item_id): return jsonify({'success': False, 'error': str(e)}), 500 # 서버 실행 +# ==================== 재고 보정 API ==================== + +@app.route('/api/stock-adjustments', methods=['GET']) +def get_stock_adjustments(): + """재고 보정 내역 조회""" + try: + limit = request.args.get('limit', 100, type=int) + + with get_db() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT + sa.adjustment_id, + sa.adjustment_date, + sa.adjustment_no, + sa.adjustment_type, + sa.notes, + sa.created_by, + sa.created_at, + COUNT(sad.detail_id) as detail_count, + SUM(ABS(sad.quantity_delta)) as total_adjusted + FROM stock_adjustments sa + LEFT JOIN stock_adjustment_details sad ON sa.adjustment_id = sad.adjustment_id + GROUP BY sa.adjustment_id + ORDER BY sa.adjustment_date DESC, sa.created_at DESC + LIMIT ? + """, (limit,)) + + adjustments = [dict(row) for row in cursor.fetchall()] + return jsonify({'success': True, 'data': adjustments}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/stock-adjustments/', methods=['GET']) +def get_stock_adjustment_detail(adjustment_id): + """재고 보정 상세 조회""" + try: + with get_db() as conn: + cursor = conn.cursor() + + # 보정 헤더 + cursor.execute(""" + SELECT * FROM stock_adjustments + WHERE adjustment_id = ? + """, (adjustment_id,)) + adjustment = dict(cursor.fetchone()) + + # 보정 상세 + cursor.execute(""" + SELECT + sad.*, + h.herb_name, + h.insurance_code, + il.origin_country, + s.name as supplier_name + FROM stock_adjustment_details sad + JOIN herb_items h ON sad.herb_item_id = h.herb_item_id + JOIN inventory_lots il ON sad.lot_id = il.lot_id + LEFT JOIN suppliers s ON il.supplier_id = s.supplier_id + WHERE sad.adjustment_id = ? + """, (adjustment_id,)) + + details = [dict(row) for row in cursor.fetchall()] + adjustment['details'] = details + + return jsonify({'success': True, 'data': adjustment}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/stock-adjustments', methods=['POST']) +def create_stock_adjustment(): + """재고 보정 생성""" + try: + data = request.json + + with get_db() as conn: + cursor = conn.cursor() + + # 보정 번호 생성 (ADJ-YYYYMMDD-XXXX) + adjustment_date = data.get('adjustment_date', datetime.now().strftime('%Y-%m-%d')) + date_str = adjustment_date.replace('-', '') + + cursor.execute(""" + SELECT MAX(CAST(SUBSTR(adjustment_no, -4) AS INTEGER)) + FROM stock_adjustments + WHERE adjustment_no LIKE ? + """, (f'ADJ-{date_str}-%',)) + + max_num = cursor.fetchone()[0] + next_num = (max_num or 0) + 1 + adjustment_no = f"ADJ-{date_str}-{next_num:04d}" + + # 보정 헤더 생성 + cursor.execute(""" + INSERT INTO stock_adjustments (adjustment_date, adjustment_no, adjustment_type, notes, created_by) + VALUES (?, ?, ?, ?, ?) + """, ( + adjustment_date, + adjustment_no, + data['adjustment_type'], + data.get('notes'), + data.get('created_by', 'system') + )) + adjustment_id = cursor.lastrowid + + # 보정 상세 처리 + for detail in data['details']: + herb_item_id = detail['herb_item_id'] + lot_id = detail['lot_id'] + quantity_before = detail['quantity_before'] + quantity_after = detail['quantity_after'] + quantity_delta = quantity_after - quantity_before + + # 보정 상세 기록 + cursor.execute(""" + INSERT INTO stock_adjustment_details (adjustment_id, herb_item_id, lot_id, + quantity_before, quantity_after, quantity_delta, reason) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, ( + adjustment_id, herb_item_id, lot_id, + quantity_before, quantity_after, quantity_delta, + detail.get('reason') + )) + + # 재고 로트 업데이트 + cursor.execute(""" + UPDATE inventory_lots + SET quantity_onhand = ?, + is_depleted = ?, + updated_at = CURRENT_TIMESTAMP + WHERE lot_id = ? + """, (quantity_after, 1 if quantity_after == 0 else 0, lot_id)) + + # 재고 원장 기록 + cursor.execute(""" + SELECT unit_price_per_g FROM inventory_lots WHERE lot_id = ? + """, (lot_id,)) + unit_price = cursor.fetchone()[0] + + cursor.execute(""" + INSERT INTO stock_ledger (event_type, herb_item_id, lot_id, + quantity_delta, unit_cost_per_g, + reference_table, reference_id, notes, created_by) + VALUES ('ADJUST', ?, ?, ?, ?, 'stock_adjustments', ?, ?, ?) + """, ( + herb_item_id, lot_id, quantity_delta, unit_price, + adjustment_id, detail.get('reason'), data.get('created_by', 'system') + )) + + return jsonify({ + 'success': True, + 'message': '재고 보정이 완료되었습니다', + 'adjustment_id': adjustment_id, + 'adjustment_no': adjustment_no + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + if __name__ == '__main__': # 데이터베이스 초기화 if not os.path.exists(app.config['DATABASE']): diff --git a/create_adjustment_tables.py b/create_adjustment_tables.py new file mode 100644 index 0000000..1067a36 --- /dev/null +++ b/create_adjustment_tables.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +재고 보정 테이블 생성 스크립트 +""" + +import sqlite3 + +def create_tables(): + conn = sqlite3.connect('database/kdrug.db') + cursor = conn.cursor() + + try: + # 재고 보정 테이블 생성 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS stock_adjustments ( + adjustment_id INTEGER PRIMARY KEY AUTOINCREMENT, + adjustment_date DATE NOT NULL, + adjustment_no TEXT, + adjustment_type TEXT NOT NULL CHECK(adjustment_type IN ('LOSS', 'FOUND', 'RECOUNT', 'DAMAGE', 'EXPIRE')), + notes TEXT, + created_by TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """) + + # 재고 보정 상세 테이블 생성 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS stock_adjustment_details ( + detail_id INTEGER PRIMARY KEY AUTOINCREMENT, + adjustment_id INTEGER NOT NULL, + herb_item_id INTEGER NOT NULL, + lot_id INTEGER NOT NULL, + quantity_before REAL NOT NULL, + quantity_after REAL NOT NULL, + quantity_delta REAL NOT NULL, + reason TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (adjustment_id) REFERENCES stock_adjustments(adjustment_id), + FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id), + FOREIGN KEY (lot_id) REFERENCES inventory_lots(lot_id) + ) + """) + + # 인덱스 생성 + cursor.execute("CREATE INDEX IF NOT EXISTS idx_stock_adjustments_date ON stock_adjustments(adjustment_date)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_adjustment_details_herb ON stock_adjustment_details(herb_item_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_adjustment_details_lot ON stock_adjustment_details(lot_id)") + + conn.commit() + print("✅ 재고 보정 테이블 생성 완료!") + + # 테이블 확인 + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%adjustment%'") + tables = cursor.fetchall() + print("\n생성된 테이블:") + for table in tables: + print(f" - {table[0]}") + + except Exception as e: + print(f"❌ 오류 발생: {e}") + conn.rollback() + finally: + conn.close() + +if __name__ == "__main__": + create_tables() diff --git a/database/add_adjustments_table.sql b/database/add_adjustments_table.sql new file mode 100644 index 0000000..dbf0c09 --- /dev/null +++ b/database/add_adjustments_table.sql @@ -0,0 +1,35 @@ +-- 재고 보정 테이블 추가 +-- 재고 조정/보정 내역을 기록 + +CREATE TABLE IF NOT EXISTS stock_adjustments ( + adjustment_id INTEGER PRIMARY KEY AUTOINCREMENT, + adjustment_date DATE NOT NULL, + adjustment_no TEXT, -- 보정 번호 (ADJ-YYYYMMDD-XXXX) + adjustment_type TEXT NOT NULL CHECK(adjustment_type IN ('LOSS', 'FOUND', 'RECOUNT', 'DAMAGE', 'EXPIRE')), + -- LOSS: 감모(손실), FOUND: 발견, RECOUNT: 재고조사, DAMAGE: 파손, EXPIRE: 유통기한 + notes TEXT, + created_by TEXT, -- 보정 담당자 (나중에 계정 연동) + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 재고 보정 상세 (로트별) +CREATE TABLE IF NOT EXISTS stock_adjustment_details ( + detail_id INTEGER PRIMARY KEY AUTOINCREMENT, + adjustment_id INTEGER NOT NULL, + herb_item_id INTEGER NOT NULL, + lot_id INTEGER NOT NULL, + quantity_before REAL NOT NULL, -- 보정 전 재고 + quantity_after REAL NOT NULL, -- 보정 후 재고 + quantity_delta REAL NOT NULL, -- 증감량 + reason TEXT, -- 보정 사유 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (adjustment_id) REFERENCES stock_adjustments(adjustment_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_stock_adjustments_date ON stock_adjustments(adjustment_date); +CREATE INDEX IF NOT EXISTS idx_adjustment_details_herb ON stock_adjustment_details(herb_item_id); +CREATE INDEX IF NOT EXISTS idx_adjustment_details_lot ON stock_adjustment_details(lot_id); diff --git a/static/app.js b/static/app.js index b341acf..fdd3e9e 100644 --- a/static/app.js +++ b/static/app.js @@ -1357,6 +1357,10 @@ $(document).ready(function() { typeLabel = '출고'; typeBadge = 'badge bg-danger'; break; + case 'ADJUST': + typeLabel = '보정'; + typeBadge = 'badge bg-warning'; + break; default: typeLabel = entry.event_type; typeBadge = 'badge bg-secondary'; diff --git a/templates/index.html b/templates/index.html index 94dd2f2..4af61de 100644 --- a/templates/index.html +++ b/templates/index.html @@ -663,6 +663,7 @@ +