feat: 재고 보정 시스템 구현
## 재고 보정 기능 - 재고 보정 테이블 추가 (stock_adjustments, stock_adjustment_details) - 보정 타입: LOSS(감모), FOUND(발견), RECOUNT(재고조사), DAMAGE(파손), EXPIRE(유통기한) - 보정 번호 자동 생성: ADJ-YYYYMMDD-XXXX ## API 엔드포인트 - GET /api/stock-adjustments - 보정 내역 조회 - GET /api/stock-adjustments/<id> - 보정 상세 조회 - 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 <noreply@anthropic.com>
This commit is contained in:
parent
76a2b5c1a6
commit
1826ea5ca4
162
app.py
162
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/<int:adjustment_id>', 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']):
|
||||
|
||||
69
create_adjustment_tables.py
Normal file
69
create_adjustment_tables.py
Normal file
@ -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()
|
||||
35
database/add_adjustments_table.sql
Normal file
35
database/add_adjustments_table.sql
Normal file
@ -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);
|
||||
@ -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';
|
||||
|
||||
@ -663,6 +663,7 @@
|
||||
<option value="">전체 내역</option>
|
||||
<option value="RECEIPT">입고만</option>
|
||||
<option value="CONSUME">출고만</option>
|
||||
<option value="ADJUST">보정만</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user