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:
시골약사 2026-02-15 12:19:18 +00:00
parent 76a2b5c1a6
commit 1826ea5ca4
5 changed files with 271 additions and 0 deletions

162
app.py
View File

@ -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']):

View 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()

View 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);

View File

@ -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';

View File

@ -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>