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, '직접조제') || ')'
|
WHEN c.prescription_no IS NOT NULL THEN c.prescription_no || ' (' || COALESCE(f.formula_name, '직접조제') || ')'
|
||||||
ELSE '조제#' || c.compound_id || ' (' || COALESCE(f.formula_name, '직접조제') || ')'
|
ELSE '조제#' || c.compound_id || ' (' || COALESCE(f.formula_name, '직접조제') || ')'
|
||||||
END
|
END
|
||||||
|
WHEN sl.event_type = 'ADJUST' THEN adj.adjustment_no
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END as reference_no,
|
END as reference_no,
|
||||||
CASE
|
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 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 patients p ON c.patient_id = p.patient_id
|
||||||
LEFT JOIN formulas f ON c.formula_id = f.formula_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 = ?
|
WHERE sl.herb_item_id = ?
|
||||||
ORDER BY sl.event_time DESC
|
ORDER BY sl.event_time DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
@ -1146,6 +1148,7 @@ def get_stock_ledger():
|
|||||||
WHEN c.prescription_no IS NOT NULL THEN c.prescription_no || ' (' || COALESCE(f.formula_name, '직접조제') || ')'
|
WHEN c.prescription_no IS NOT NULL THEN c.prescription_no || ' (' || COALESCE(f.formula_name, '직접조제') || ')'
|
||||||
ELSE '조제#' || c.compound_id || ' (' || COALESCE(f.formula_name, '직접조제') || ')'
|
ELSE '조제#' || c.compound_id || ' (' || COALESCE(f.formula_name, '직접조제') || ')'
|
||||||
END
|
END
|
||||||
|
WHEN sl.event_type = 'ADJUST' THEN adj.adjustment_no
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END as reference_no,
|
END as reference_no,
|
||||||
CASE
|
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 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 patients p ON c.patient_id = p.patient_id
|
||||||
LEFT JOIN formulas f ON c.formula_id = f.formula_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
|
ORDER BY sl.event_time DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""", (limit,))
|
""", (limit,))
|
||||||
@ -1418,6 +1422,164 @@ def get_inventory_detail(herb_item_id):
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
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 __name__ == '__main__':
|
||||||
# 데이터베이스 초기화
|
# 데이터베이스 초기화
|
||||||
if not os.path.exists(app.config['DATABASE']):
|
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 = '출고';
|
typeLabel = '출고';
|
||||||
typeBadge = 'badge bg-danger';
|
typeBadge = 'badge bg-danger';
|
||||||
break;
|
break;
|
||||||
|
case 'ADJUST':
|
||||||
|
typeLabel = '보정';
|
||||||
|
typeBadge = 'badge bg-warning';
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
typeLabel = entry.event_type;
|
typeLabel = entry.event_type;
|
||||||
typeBadge = 'badge bg-secondary';
|
typeBadge = 'badge bg-secondary';
|
||||||
|
|||||||
@ -663,6 +663,7 @@
|
|||||||
<option value="">전체 내역</option>
|
<option value="">전체 내역</option>
|
||||||
<option value="RECEIPT">입고만</option>
|
<option value="RECEIPT">입고만</option>
|
||||||
<option value="CONSUME">출고만</option>
|
<option value="CONSUME">출고만</option>
|
||||||
|
<option value="ADJUST">보정만</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user