- medicine_master.db (305,522행) CSV→SQLite 변환 완료 - get_db()에서 ATTACH DATABASE로 자동 연결 - GET /api/medicine-master/search: 상품명/업체명/표준코드 검색 - GET /api/medicine-master/categories: 전문일반구분별 통계 - config.py에 MEDICINE_MASTER_PATH 추가 - 취소된 제품 자동 필터링, 카테고리 필터 지원 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
3848 lines
161 KiB
Python
3848 lines
161 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
|
|
"""
|
|
한약 재고관리 시스템 - Flask Backend
|
|
"""
|
|
|
|
import os
|
|
import sqlite3
|
|
from datetime import datetime
|
|
from flask import Flask, request, jsonify, render_template, send_from_directory
|
|
from flask_cors import CORS
|
|
import pandas as pd
|
|
from werkzeug.utils import secure_filename
|
|
import json
|
|
from contextlib import contextmanager
|
|
from excel_processor import ExcelProcessor
|
|
from config import DATABASE_PATH, MEDICINE_MASTER_PATH, STATIC_PATH, TEMPLATES_PATH
|
|
|
|
# Flask 앱 초기화
|
|
app = Flask(__name__, static_folder='static', template_folder='templates')
|
|
app.config['SECRET_KEY'] = 'your-secret-key-change-in-production'
|
|
app.config['DATABASE'] = str(DATABASE_PATH) # config.py의 경로 사용
|
|
app.config['UPLOAD_FOLDER'] = 'uploads'
|
|
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
|
|
|
|
CORS(app)
|
|
|
|
# 업로드 폴더 생성
|
|
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
|
os.makedirs('database', exist_ok=True)
|
|
|
|
# 허용된 파일 확장자
|
|
ALLOWED_EXTENSIONS = {'xlsx', 'xls'}
|
|
|
|
def allowed_file(filename):
|
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
|
|
|
# 데이터베이스 연결 컨텍스트 매니저
|
|
@contextmanager
|
|
def get_db():
|
|
conn = sqlite3.connect(app.config['DATABASE'])
|
|
conn.row_factory = sqlite3.Row # 딕셔너리 형태로 반환
|
|
conn.execute('PRAGMA foreign_keys = ON') # 외래키 제약 활성화
|
|
# 의약품 마스터 DB 연결 (읽기 전용 참조)
|
|
medicine_db = str(MEDICINE_MASTER_PATH)
|
|
if os.path.exists(medicine_db):
|
|
conn.execute(f"ATTACH DATABASE '{medicine_db}' AS med_master")
|
|
try:
|
|
yield conn
|
|
conn.commit()
|
|
except Exception as e:
|
|
conn.rollback()
|
|
raise e
|
|
finally:
|
|
conn.close()
|
|
|
|
# 데이터베이스 초기화
|
|
def init_db():
|
|
with open('database/schema.sql', 'r', encoding='utf-8') as f:
|
|
schema = f.read()
|
|
|
|
with get_db() as conn:
|
|
conn.executescript(schema)
|
|
|
|
cursor = conn.cursor()
|
|
|
|
# formulas 테이블 마이그레이션: official_formula_id FK 컬럼 추가
|
|
cursor.execute("PRAGMA table_info(formulas)")
|
|
existing_cols = {row[1] for row in cursor.fetchall()}
|
|
if 'official_formula_id' not in existing_cols:
|
|
cursor.execute("ALTER TABLE formulas ADD COLUMN official_formula_id INTEGER REFERENCES official_formulas(official_formula_id)")
|
|
|
|
# official_formulas 테이블 마이그레이션: reference_notes 컬럼 추가
|
|
cursor.execute("PRAGMA table_info(official_formulas)")
|
|
of_cols = {row[1] for row in cursor.fetchall()}
|
|
if 'reference_notes' not in of_cols:
|
|
cursor.execute("ALTER TABLE official_formulas ADD COLUMN reference_notes TEXT")
|
|
|
|
# 100처방 원방 마스터 시드 데이터 로드
|
|
cursor.execute("SELECT COUNT(*) FROM official_formulas")
|
|
if cursor.fetchone()[0] == 0:
|
|
seed_official_formulas(conn)
|
|
|
|
conn.commit()
|
|
print("Database initialized successfully")
|
|
|
|
|
|
def seed_official_formulas(conn):
|
|
"""100처방 원방 마스터 데이터 시드"""
|
|
official_100 = [
|
|
(1, '가미온담탕', '의종금감'), (2, '가미패독산', '경악전서'), (3, '갈근탕', '상한론'),
|
|
(4, '강활유풍탕', '의학발명'), (5, '계지가용골모려탕', '금궤요략'), (6, '계지작약지모탕', '금궤요략'),
|
|
(7, '곽향정기산', '화제국방'), (8, '구미강활탕', '차사난지'), (9, '궁귀교애탕', '금궤요략'),
|
|
(10, '귀비탕', '제생방'), (11, '귀출파징탕', '동의보감'), (12, '금수육군전', '경악전서'),
|
|
(13, '녹용대보탕', '갑병원류서촉'), (14, '당귀사역가오수유생강탕', '상한론'), (15, '당귀수산', '의학입문'),
|
|
(16, '당귀육황탕', '난실비장'), (17, '당귀작약산', '금궤요략'), (18, '대강활탕', '위생보감'),
|
|
(19, '대건중탕', '금궤요략'), (20, '대금음자', '화제국방'), (21, '대방풍탕', '화제국방'),
|
|
(22, '대청룡탕', '상한론'), (23, '대황목단피탕', '금궤요략'), (24, '독활기생탕', '천금방'),
|
|
(25, '마행의감탕', '금궤요략'), (26, '마황부자세신탕', '상한론'), (27, '반하백출천마탕', '의학심오'),
|
|
(28, '반하사심탕', '상한론'), (29, '반하후박탕', '금궤요략'), (30, '방기황기탕', '금궤요략'),
|
|
(31, '방풍통성산', '선명논방'), (32, '배농산급탕', '춘림헌방함'), (33, '백출산', '외대비요'),
|
|
(34, '보생탕', '부인양방'), (35, '보중익기탕', '비위론'), (36, '복령음', '외대비요'),
|
|
(37, '분심기음', '직지방'), (38, '사군자탕', '화제국방'), (39, '사물탕', '화제국방'),
|
|
(40, '삼령백출산', '화제국방'), (41, '삼소음', '화제국방'), (42, '삼출건비탕', '동의보감'),
|
|
(43, '삼환사심탕', '금궤요략'), (44, '생혈윤부탕', '의학정전'), (45, '세간명목탕', '중보만병회춘'),
|
|
(46, '소건중탕', '상한론'), (47, '소시호탕', '상한론'), (48, '소요산', '화제국방'),
|
|
(49, '소자강기탕', '화제국방'), (50, '소적정원산', '의학입문'), (51, '소청룡탕', '상한론'),
|
|
(52, '소풍산', '외과정종'), (53, '소풍활혈탕', '심씨존생서'), (54, '속명탕', '금궤요략'),
|
|
(55, '승마갈근탕', '염씨소아방론'), (56, '시함탕', '중정통속상한론'), (57, '시호계강탕', '상한론'),
|
|
(58, '시호억간탕', '의학입문'), (59, '시호청간탕', '구치유요'), (60, '십전대보탕', '화제국방'),
|
|
(61, '쌍화탕', '화제국방'), (62, '안중산', '화제국방'), (63, '양격산', '화제국방'),
|
|
(64, '연령고본단', '만병회춘'), (65, '영감강미신하인탕', '금궤요략'), (66, '영계출감탕', '상한론'),
|
|
(67, '오약순기산', '화제국방'), (68, '오적산', '화제국방'), (69, '온경탕', '금궤요략'),
|
|
(70, '온백원', '화제금궤'), (71, '용담사간탕', '의종금감'), (72, '월비탕', '금궤요략'),
|
|
(73, '위령탕', '만병회춘'), (74, '육군자탕', '부인양방'), (75, '육미지황환', '소아약증직결'),
|
|
(76, '육울탕', '단계심법'), (77, '이기거풍산', '고금의감'), (78, '이중환', '상한론'),
|
|
(79, '이진탕', '화제국방'), (80, '인삼양영탕', '화제국방'), (81, '인삼양위탕', '화제국방'),
|
|
(82, '인삼패독산', '소아약증질결'), (83, '인진오령산', '금궤요략'), (84, '자감초탕', '상한론'),
|
|
(85, '자음강화탕', '만병회춘'), (86, '자음건비탕', '만병회푼'), (87, '저령탕', '상한론'),
|
|
(88, '조경종옥탕', '고금의감'), (89, '지황음자', '선명논방'), (90, '진무탕', '상한론'),
|
|
(91, '청간해올탕', '증치준승'), (92, '청금강화탕', '고금의감'), (93, '청상방풍탕', '만병회춘'),
|
|
(94, '청서익기탕', '비위론'), (95, '청심연자음', '화제국방'), (96, '평위산', '화제국방'),
|
|
(97, '형계연교탕', '일관당'), (98, '형방패독산', '섭생중묘방'), (99, '황련아교탕', '상한론'),
|
|
(100, '황련해독탕', '외대비요'),
|
|
]
|
|
|
|
cursor = conn.cursor()
|
|
for num, name, source in official_100:
|
|
cursor.execute("""
|
|
INSERT INTO official_formulas (formula_number, formula_name, source_text)
|
|
VALUES (?, ?, ?)
|
|
""", (num, name, source))
|
|
|
|
# 라우트: 메인 페이지
|
|
@app.route('/')
|
|
def index():
|
|
return render_template('index.html')
|
|
|
|
@app.route('/survey/<survey_token>')
|
|
def survey_page(survey_token):
|
|
"""문진표 페이지 (모바일)"""
|
|
return render_template('survey.html')
|
|
|
|
# ==================== 환자 관리 API ====================
|
|
|
|
@app.route('/api/patients', methods=['GET'])
|
|
def get_patients():
|
|
"""환자 목록 조회"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
SELECT patient_id, name, phone, gender, birth_date, notes,
|
|
mileage_balance, total_mileage_earned, total_mileage_used
|
|
FROM patients
|
|
WHERE is_active = 1
|
|
ORDER BY created_at DESC
|
|
""")
|
|
patients = [dict(row) for row in cursor.fetchall()]
|
|
return jsonify({'success': True, 'data': patients})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/patients/<int:patient_id>', methods=['GET'])
|
|
def get_patient(patient_id):
|
|
"""환자 개별 조회"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
SELECT patient_id, name, phone, jumin_no, gender, birth_date, address, notes,
|
|
mileage_balance, total_mileage_earned, total_mileage_used
|
|
FROM patients
|
|
WHERE patient_id = ? AND is_active = 1
|
|
""", (patient_id,))
|
|
patient_row = cursor.fetchone()
|
|
if patient_row:
|
|
return jsonify({'success': True, 'data': dict(patient_row)})
|
|
else:
|
|
return jsonify({'success': False, 'error': '환자를 찾을 수 없습니다'}), 404
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/patients/search', methods=['GET'])
|
|
def search_patients():
|
|
"""환자 검색 (이름으로)"""
|
|
try:
|
|
name = request.args.get('name', '')
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
SELECT patient_id, name, phone, gender, birth_date, notes,
|
|
mileage_balance, total_mileage_earned, total_mileage_used
|
|
FROM patients
|
|
WHERE name LIKE ? AND is_active = 1
|
|
ORDER BY name
|
|
""", (f'%{name}%',))
|
|
patients = [dict(row) for row in cursor.fetchall()]
|
|
return jsonify({'success': True, 'data': patients})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/patients', methods=['POST'])
|
|
def create_patient():
|
|
"""새 환자 등록"""
|
|
try:
|
|
data = request.json
|
|
required_fields = ['name', 'phone']
|
|
|
|
# 필수 필드 검증
|
|
for field in required_fields:
|
|
if field not in data or not data[field]:
|
|
return jsonify({'success': False, 'error': f'{field}는 필수입니다'}), 400
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
INSERT INTO patients (name, phone, jumin_no, gender, birth_date, address, notes)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
""", (
|
|
data['name'],
|
|
data['phone'],
|
|
data.get('jumin_no'),
|
|
data.get('gender'),
|
|
data.get('birth_date'),
|
|
data.get('address'),
|
|
data.get('notes')
|
|
))
|
|
patient_id = cursor.lastrowid
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': '환자가 등록되었습니다',
|
|
'patient_id': patient_id
|
|
})
|
|
except sqlite3.IntegrityError:
|
|
return jsonify({'success': False, 'error': '이미 등록된 환자입니다'}), 400
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/patients/<int:patient_id>', methods=['PUT'])
|
|
def update_patient(patient_id):
|
|
"""환자 정보 수정"""
|
|
try:
|
|
data = request.json
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT patient_id FROM patients WHERE patient_id = ?", (patient_id,))
|
|
if not cursor.fetchone():
|
|
return jsonify({'success': False, 'error': '환자를 찾을 수 없습니다'}), 404
|
|
|
|
cursor.execute("""
|
|
UPDATE patients
|
|
SET name = ?, phone = ?, jumin_no = ?, gender = ?,
|
|
birth_date = ?, address = ?, notes = ?
|
|
WHERE patient_id = ?
|
|
""", (
|
|
data.get('name'),
|
|
data.get('phone'),
|
|
data.get('jumin_no'),
|
|
data.get('gender'),
|
|
data.get('birth_date'),
|
|
data.get('address'),
|
|
data.get('notes'),
|
|
patient_id
|
|
))
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': '환자 정보가 수정되었습니다'
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
# ==================== 약재 관리 API ====================
|
|
|
|
@app.route('/api/herbs', methods=['GET'])
|
|
def get_herbs():
|
|
"""약재 목록 조회"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
# 효능 태그는 나중에 안전하게 추가 예정
|
|
# 일단 기본 기능만 유지
|
|
cursor.execute("""
|
|
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 het.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
|
|
-- 간단한 JOIN: ingredient_code로 직접 연결
|
|
LEFT JOIN herb_products hp ON h.insurance_code = hp.product_code
|
|
LEFT JOIN herb_item_tags hit ON COALESCE(h.ingredient_code, hp.ingredient_code) = hit.ingredient_code
|
|
LEFT JOIN herb_efficacy_tags het ON hit.tag_id = het.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 = []
|
|
for row in cursor.fetchall():
|
|
herb = dict(row)
|
|
# 효능 태그를 리스트로 변환
|
|
if herb['efficacy_tags']:
|
|
herb['efficacy_tags'] = herb['efficacy_tags'].split(',')
|
|
else:
|
|
herb['efficacy_tags'] = []
|
|
herbs.append(herb)
|
|
|
|
return jsonify({'success': True, 'data': herbs})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/herbs/masters', methods=['GET'])
|
|
def get_herb_masters():
|
|
"""주성분코드 기준 전체 약재 목록 조회 (454개)"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
SELECT
|
|
m.ingredient_code,
|
|
m.herb_name,
|
|
m.herb_name_hanja,
|
|
m.herb_name_latin,
|
|
-- 확장 정보
|
|
hme.herb_id,
|
|
hme.property,
|
|
hme.taste,
|
|
hme.meridian_tropism,
|
|
hme.main_effects,
|
|
-- 재고 정보
|
|
COALESCE(inv.total_quantity, 0) as stock_quantity,
|
|
COALESCE(inv.lot_count, 0) as lot_count,
|
|
COALESCE(inv.avg_price, 0) as avg_price,
|
|
CASE WHEN inv.total_quantity > 0 THEN 1 ELSE 0 END as has_stock,
|
|
-- 제품 정보
|
|
COUNT(DISTINCT p.company_name) as company_count,
|
|
COUNT(DISTINCT p.product_id) as product_count,
|
|
-- 효능 태그
|
|
GROUP_CONCAT(DISTINCT et.tag_name) as efficacy_tags
|
|
FROM herb_masters m
|
|
LEFT JOIN herb_master_extended hme ON m.ingredient_code = hme.ingredient_code
|
|
LEFT JOIN (
|
|
-- 재고 정보 서브쿼리
|
|
SELECT
|
|
h.ingredient_code,
|
|
SUM(il.quantity_onhand) as total_quantity,
|
|
COUNT(DISTINCT il.lot_id) as lot_count,
|
|
AVG(il.unit_price_per_g) as avg_price
|
|
FROM herb_items h
|
|
INNER JOIN inventory_lots il ON h.herb_item_id = il.herb_item_id
|
|
WHERE il.is_depleted = 0 AND il.quantity_onhand > 0
|
|
GROUP BY h.ingredient_code
|
|
) inv ON m.ingredient_code = inv.ingredient_code
|
|
LEFT JOIN herb_products p ON m.ingredient_code = p.ingredient_code
|
|
LEFT JOIN herb_items hi ON m.ingredient_code = hi.ingredient_code
|
|
-- 간단한 JOIN: ingredient_code로 직접 연결
|
|
LEFT JOIN herb_item_tags hit ON m.ingredient_code = hit.ingredient_code
|
|
LEFT JOIN herb_efficacy_tags et ON hit.tag_id = et.tag_id
|
|
WHERE m.is_active = 1
|
|
GROUP BY m.ingredient_code, m.herb_name, hme.herb_id, hme.property, hme.taste, hme.meridian_tropism, hme.main_effects, inv.total_quantity, inv.lot_count, inv.avg_price
|
|
ORDER BY has_stock DESC, m.herb_name
|
|
""")
|
|
|
|
herbs = []
|
|
for row in cursor.fetchall():
|
|
herb = dict(row)
|
|
# 효능 태그를 리스트로 변환
|
|
if herb['efficacy_tags']:
|
|
herb['efficacy_tags'] = herb['efficacy_tags'].split(',')
|
|
else:
|
|
herb['efficacy_tags'] = []
|
|
herbs.append(herb)
|
|
|
|
# 통계 정보
|
|
total_herbs = len(herbs)
|
|
herbs_with_stock = sum(1 for h in herbs if h['has_stock'])
|
|
coverage_rate = round(herbs_with_stock * 100 / total_herbs, 1) if total_herbs > 0 else 0
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'data': herbs,
|
|
'summary': {
|
|
'total_herbs': total_herbs,
|
|
'herbs_with_stock': herbs_with_stock,
|
|
'herbs_without_stock': total_herbs - herbs_with_stock,
|
|
'coverage_rate': coverage_rate
|
|
}
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/herbs/by-ingredient/<ingredient_code>', methods=['GET'])
|
|
def get_herbs_by_ingredient(ingredient_code):
|
|
"""특정 ingredient_code에 해당하는 제품 목록 조회"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 먼저 마스터 약재명 조회
|
|
cursor.execute("""
|
|
SELECT herb_name
|
|
FROM herb_masters
|
|
WHERE ingredient_code = ?
|
|
""", (ingredient_code,))
|
|
master_row = cursor.fetchone()
|
|
master_herb_name = master_row[0] if master_row else None
|
|
|
|
cursor.execute("""
|
|
SELECT
|
|
h.herb_item_id,
|
|
h.insurance_code,
|
|
h.herb_name as product_name,
|
|
h.specification,
|
|
CASE
|
|
WHEN h.specification LIKE '%신흥%' THEN '신흥'
|
|
WHEN h.specification LIKE '%세화%' THEN '세화'
|
|
WHEN h.specification LIKE '%한동%' THEN '한동'
|
|
WHEN h.specification IS NULL OR h.specification = '' THEN '일반'
|
|
ELSE h.specification
|
|
END as company_name,
|
|
COALESCE(SUM(il.quantity_onhand), 0) as stock_quantity,
|
|
COUNT(DISTINCT il.lot_id) as lot_count,
|
|
AVG(il.unit_price_per_g) as avg_price
|
|
FROM herb_items h
|
|
LEFT JOIN inventory_lots il ON h.herb_item_id = il.herb_item_id
|
|
AND il.is_depleted = 0
|
|
WHERE h.ingredient_code = ?
|
|
AND h.is_active = 1
|
|
GROUP BY h.herb_item_id
|
|
ORDER BY stock_quantity DESC, h.herb_name
|
|
""", (ingredient_code,))
|
|
|
|
products = []
|
|
for row in cursor.fetchall():
|
|
product = dict(row)
|
|
# 마스터 약재명 추가
|
|
product['herb_name'] = master_herb_name or product['product_name']
|
|
products.append(product)
|
|
|
|
return jsonify({'success': True, 'data': products})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
# ==================== 처방 관리 API ====================
|
|
|
|
@app.route('/api/official-formulas', methods=['GET'])
|
|
def get_official_formulas():
|
|
"""100처방 원방 마스터 목록 조회"""
|
|
try:
|
|
search = request.args.get('search', '').strip()
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
base_query = """
|
|
SELECT of2.official_formula_id, of2.formula_number, of2.formula_name,
|
|
of2.formula_name_hanja, of2.source_text, of2.description, of2.reference_notes,
|
|
COUNT(ofi.ingredient_id) as ingredient_count
|
|
FROM official_formulas of2
|
|
LEFT JOIN official_formula_ingredients ofi ON of2.official_formula_id = ofi.official_formula_id
|
|
"""
|
|
if search:
|
|
cursor.execute(base_query + """
|
|
WHERE of2.formula_name LIKE ? OR of2.formula_name_hanja LIKE ?
|
|
OR of2.source_text LIKE ? OR of2.reference_notes LIKE ?
|
|
GROUP BY of2.official_formula_id
|
|
ORDER BY of2.formula_number
|
|
""", (f'%{search}%', f'%{search}%', f'%{search}%', f'%{search}%'))
|
|
else:
|
|
cursor.execute(base_query + """
|
|
GROUP BY of2.official_formula_id
|
|
ORDER BY of2.formula_number
|
|
""")
|
|
formulas = [dict(row) for row in cursor.fetchall()]
|
|
|
|
# 등록 여부 판정: official_formula_id FK 매칭 (1차) + 이름 매칭 (fallback)
|
|
# 1차: formulas.official_formula_id로 직접 연결된 처방
|
|
cursor.execute("""
|
|
SELECT official_formula_id, formula_name
|
|
FROM formulas
|
|
WHERE is_active = 1 AND official_formula_id IS NOT NULL
|
|
""")
|
|
registered_by_id = {}
|
|
for row in cursor.fetchall():
|
|
oid = row['official_formula_id']
|
|
if oid not in registered_by_id:
|
|
registered_by_id[oid] = []
|
|
registered_by_id[oid].append(row['formula_name'])
|
|
|
|
# 2차 fallback: 이름 기반 매칭용
|
|
cursor.execute("SELECT formula_name FROM formulas WHERE is_active = 1")
|
|
my_formula_names = [row['formula_name'] for row in cursor.fetchall()]
|
|
|
|
for formula in formulas:
|
|
oid = formula['official_formula_id']
|
|
oname = formula['formula_name']
|
|
|
|
# 1차: FK 매칭
|
|
if oid in registered_by_id:
|
|
formula['is_registered'] = True
|
|
formula['registered_names'] = registered_by_id[oid]
|
|
# 2차: 이름 매칭 (정확 매칭 또는 내 처방명에 원방명이 포함)
|
|
elif any(name == oname or oname in name for name in my_formula_names):
|
|
matched = [name for name in my_formula_names if name == oname or oname in name]
|
|
formula['is_registered'] = True
|
|
formula['registered_names'] = matched
|
|
else:
|
|
formula['is_registered'] = False
|
|
formula['registered_names'] = []
|
|
|
|
return jsonify({'success': True, 'data': formulas})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/official-formulas/<int:official_formula_id>', methods=['PUT'])
|
|
def update_official_formula(official_formula_id):
|
|
"""100처방 원방 마스터 정보 수정"""
|
|
try:
|
|
data = request.json
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
update_fields = []
|
|
update_values = []
|
|
for field in ['formula_name_hanja', 'description', 'reference_notes']:
|
|
if field in data:
|
|
update_fields.append(f'{field} = ?')
|
|
update_values.append(data[field])
|
|
if not update_fields:
|
|
return jsonify({'error': '수정할 항목이 없습니다'}), 400
|
|
update_fields.append('updated_at = CURRENT_TIMESTAMP')
|
|
update_values.append(official_formula_id)
|
|
cursor.execute(f"""
|
|
UPDATE official_formulas SET {', '.join(update_fields)}
|
|
WHERE official_formula_id = ?
|
|
""", update_values)
|
|
conn.commit()
|
|
return jsonify({'success': True, 'message': '수정되었습니다'})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/official-formulas/<int:official_formula_id>/ingredients', methods=['GET'])
|
|
def get_official_formula_ingredients(official_formula_id):
|
|
"""100처방 원방 구성 약재 조회"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
SELECT ofi.ingredient_code, ofi.grams_per_cheop, ofi.notes, ofi.sort_order,
|
|
hm.herb_name, hm.herb_name_hanja
|
|
FROM official_formula_ingredients ofi
|
|
JOIN herb_masters hm ON ofi.ingredient_code = hm.ingredient_code
|
|
WHERE ofi.official_formula_id = ?
|
|
ORDER BY ofi.sort_order
|
|
""", (official_formula_id,))
|
|
ingredients = [dict(row) for row in cursor.fetchall()]
|
|
return jsonify({'success': True, 'data': ingredients})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/formulas', methods=['GET'])
|
|
def get_formulas():
|
|
"""처방 목록 조회 (100처방 대비 가감 정보 포함)"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
SELECT f.formula_id, f.formula_code, f.formula_name, f.formula_type,
|
|
f.base_cheop, f.base_pouches, f.description, f.efficacy,
|
|
f.official_formula_id,
|
|
of2.formula_name as official_name
|
|
FROM formulas f
|
|
LEFT JOIN official_formulas of2 ON f.official_formula_id = of2.official_formula_id
|
|
WHERE f.is_active = 1
|
|
ORDER BY f.formula_name
|
|
""")
|
|
formulas = [dict(row) for row in cursor.fetchall()]
|
|
|
|
# 100처방 기반 처방에 대해 가감 정보 계산
|
|
for formula in formulas:
|
|
if formula.get('official_formula_id'):
|
|
official_id = formula['official_formula_id']
|
|
formula_id = formula['formula_id']
|
|
|
|
# 원방 구성 조회
|
|
cursor.execute("""
|
|
SELECT ofi.ingredient_code, hm.herb_name, ofi.grams_per_cheop
|
|
FROM official_formula_ingredients ofi
|
|
JOIN herb_masters hm ON ofi.ingredient_code = hm.ingredient_code
|
|
WHERE ofi.official_formula_id = ?
|
|
""", (official_id,))
|
|
original = {row['ingredient_code']: {'name': row['herb_name'], 'grams': row['grams_per_cheop']} for row in cursor.fetchall()}
|
|
|
|
# 내 처방 구성 조회
|
|
cursor.execute("""
|
|
SELECT fi.ingredient_code, hm.herb_name, fi.grams_per_cheop
|
|
FROM formula_ingredients fi
|
|
JOIN herb_masters hm ON fi.ingredient_code = hm.ingredient_code
|
|
WHERE fi.formula_id = ?
|
|
""", (formula_id,))
|
|
current = {row['ingredient_code']: {'name': row['herb_name'], 'grams': row['grams_per_cheop']} for row in cursor.fetchall()}
|
|
|
|
added = []
|
|
removed = []
|
|
modified = []
|
|
|
|
# 추가된 약재
|
|
for code, info in current.items():
|
|
if code not in original:
|
|
added.append(f"{info['name']} {info['grams']}g")
|
|
|
|
# 제거된 약재
|
|
for code, info in original.items():
|
|
if code not in current:
|
|
removed.append(info['name'])
|
|
|
|
# 용량 변경
|
|
for code in current:
|
|
if code in original:
|
|
orig_g = original[code]['grams']
|
|
curr_g = current[code]['grams']
|
|
if abs(orig_g - curr_g) > 0.01:
|
|
modified.append(f"{original[code]['name']} {orig_g}g→{curr_g}g")
|
|
|
|
formula['custom_added'] = added
|
|
formula['custom_removed'] = removed
|
|
formula['custom_modified'] = modified
|
|
formula['is_custom'] = bool(added or removed or modified)
|
|
|
|
return jsonify({'success': True, 'data': formulas})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/formulas', methods=['POST'])
|
|
def create_formula():
|
|
"""새 처방 등록"""
|
|
try:
|
|
data = request.json
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 처방 마스터 생성
|
|
cursor.execute("""
|
|
INSERT INTO formulas (formula_code, formula_name, formula_type,
|
|
base_cheop, base_pouches, description, efficacy, created_by, official_formula_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (
|
|
data.get('formula_code'),
|
|
data['formula_name'],
|
|
data.get('formula_type', 'CUSTOM'),
|
|
data.get('base_cheop', 20),
|
|
data.get('base_pouches', 30),
|
|
data.get('description'),
|
|
data.get('efficacy'),
|
|
data.get('created_by', 'system'),
|
|
data.get('official_formula_id')
|
|
))
|
|
formula_id = cursor.lastrowid
|
|
|
|
# 구성 약재 추가 (ingredient_code 기반)
|
|
if 'ingredients' in data:
|
|
for idx, ingredient in enumerate(data['ingredients']):
|
|
cursor.execute("""
|
|
INSERT INTO formula_ingredients (formula_id, ingredient_code,
|
|
grams_per_cheop, notes, sort_order)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
""", (
|
|
formula_id,
|
|
ingredient.get('ingredient_code', ingredient.get('herb_item_id')),
|
|
ingredient['grams_per_cheop'],
|
|
ingredient.get('notes'),
|
|
idx
|
|
))
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': '처방이 등록되었습니다',
|
|
'formula_id': formula_id
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/formulas/<int:formula_id>/ingredients', methods=['GET'])
|
|
def get_formula_ingredients(formula_id):
|
|
"""처방 구성 약재 조회 (ingredient_code 기반, 사용 가능한 모든 제품 포함)"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 처방 구성 약재 조회 (ingredient_code 기반으로 재고 포함)
|
|
cursor.execute("""
|
|
SELECT
|
|
fi.ingredient_id,
|
|
fi.formula_id,
|
|
fi.ingredient_code,
|
|
fi.grams_per_cheop,
|
|
fi.notes,
|
|
fi.sort_order,
|
|
hm.herb_name,
|
|
hm.herb_name_hanja,
|
|
-- 해당 성분코드를 가진 제품들의 재고 합계
|
|
COALESCE((
|
|
SELECT SUM(il.quantity_onhand)
|
|
FROM herb_items hi
|
|
LEFT JOIN inventory_lots il ON hi.herb_item_id = il.herb_item_id
|
|
WHERE hi.ingredient_code = fi.ingredient_code
|
|
AND il.is_depleted = 0
|
|
), 0) as stock_quantity
|
|
FROM formula_ingredients fi
|
|
LEFT JOIN herb_masters hm ON fi.ingredient_code = hm.ingredient_code
|
|
WHERE fi.formula_id = ?
|
|
ORDER BY fi.sort_order
|
|
""", (formula_id,))
|
|
|
|
ingredients = []
|
|
for row in cursor.fetchall():
|
|
ingredient = dict(row)
|
|
ingredient_code = ingredient['ingredient_code']
|
|
|
|
# 해당 주성분을 가진 사용 가능한 모든 제품 찾기
|
|
cursor.execute("""
|
|
SELECT
|
|
h.herb_item_id,
|
|
h.herb_name,
|
|
h.insurance_code,
|
|
h.specification,
|
|
COALESCE(SUM(il.quantity_onhand), 0) as stock,
|
|
COALESCE(AVG(il.unit_price_per_g), 0) as avg_price
|
|
FROM herb_items h
|
|
LEFT JOIN inventory_lots il ON h.herb_item_id = il.herb_item_id AND il.is_depleted = 0
|
|
WHERE h.ingredient_code = ?
|
|
GROUP BY h.herb_item_id
|
|
ORDER BY stock DESC
|
|
""", (ingredient_code,))
|
|
|
|
available_products = [dict(row) for row in cursor.fetchall()]
|
|
ingredient['available_products'] = available_products
|
|
ingredient['total_available_stock'] = sum(p['stock'] for p in available_products)
|
|
ingredient['product_count'] = len(available_products)
|
|
|
|
ingredients.append(ingredient)
|
|
|
|
return jsonify({'success': True, 'data': ingredients})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/formulas/<int:formula_id>', methods=['GET'])
|
|
def get_formula_detail(formula_id):
|
|
"""처방 상세 정보 조회"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
SELECT formula_id, formula_code, formula_name, formula_type,
|
|
base_cheop, base_pouches, description, efficacy, created_at, updated_at
|
|
FROM formulas
|
|
WHERE formula_id = ? AND is_active = 1
|
|
""", (formula_id,))
|
|
|
|
formula = cursor.fetchone()
|
|
if formula:
|
|
return jsonify({'success': True, 'data': dict(formula)})
|
|
else:
|
|
return jsonify({'success': False, 'error': '처방을 찾을 수 없습니다'}), 404
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/formulas/<int:formula_id>', methods=['PUT'])
|
|
def update_formula(formula_id):
|
|
"""처방 수정"""
|
|
try:
|
|
data = request.json
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 처방 기본 정보 업데이트
|
|
cursor.execute("""
|
|
UPDATE formulas
|
|
SET formula_code = ?, formula_name = ?, formula_type = ?,
|
|
base_cheop = ?, base_pouches = ?, description = ?, efficacy = ?,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE formula_id = ?
|
|
""", (
|
|
data.get('formula_code'),
|
|
data['formula_name'],
|
|
data.get('formula_type', 'CUSTOM'),
|
|
data.get('base_cheop', 20),
|
|
data.get('base_pouches', 30),
|
|
data.get('description'),
|
|
data.get('efficacy'),
|
|
formula_id
|
|
))
|
|
|
|
# 기존 구성 약재 삭제
|
|
cursor.execute("DELETE FROM formula_ingredients WHERE formula_id = ?", (formula_id,))
|
|
|
|
# 새로운 구성 약재 추가
|
|
if 'ingredients' in data:
|
|
for idx, ingredient in enumerate(data['ingredients']):
|
|
# ingredient_code 기반으로 저장
|
|
cursor.execute("""
|
|
INSERT INTO formula_ingredients (formula_id, ingredient_code,
|
|
grams_per_cheop, notes, sort_order)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
""", (
|
|
formula_id,
|
|
ingredient.get('ingredient_code', ingredient.get('herb_item_id')),
|
|
ingredient['grams_per_cheop'],
|
|
ingredient.get('notes'),
|
|
idx
|
|
))
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': '처방이 수정되었습니다',
|
|
'formula_id': formula_id
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/formulas/<int:formula_id>', methods=['DELETE'])
|
|
def delete_formula(formula_id):
|
|
"""처방 삭제 (소프트 삭제)"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 조제에서 사용 중인지 확인
|
|
cursor.execute("""
|
|
SELECT COUNT(*) as count
|
|
FROM compounds
|
|
WHERE formula_id = ?
|
|
""", (formula_id,))
|
|
|
|
count = cursor.fetchone()['count']
|
|
if count > 0:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': '이 처방은 조제 내역에서 사용 중이므로 삭제할 수 없습니다'
|
|
}), 400
|
|
|
|
# 소프트 삭제 (is_active를 0으로 설정)
|
|
cursor.execute("""
|
|
UPDATE formulas
|
|
SET is_active = 0, updated_at = CURRENT_TIMESTAMP
|
|
WHERE formula_id = ?
|
|
""", (formula_id,))
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': '처방이 삭제되었습니다'
|
|
})
|
|
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'])
|
|
def upload_purchase_excel():
|
|
"""Excel 파일 업로드 및 입고 처리 (한의사랑/한의정보/한퓨어 형식 자동 감지)"""
|
|
try:
|
|
if 'file' not in request.files:
|
|
return jsonify({'success': False, 'error': '파일이 없습니다'}), 400
|
|
|
|
file = request.files['file']
|
|
if file.filename == '':
|
|
return jsonify({'success': False, 'error': '파일이 선택되지 않았습니다'}), 400
|
|
|
|
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')
|
|
filename = f"{timestamp}_{filename}"
|
|
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
|
file.save(filepath)
|
|
|
|
# Excel 프로세서로 파일 처리
|
|
processor = ExcelProcessor()
|
|
if not processor.read_excel(filepath):
|
|
return jsonify({'success': False, 'error': 'Excel 파일을 읽을 수 없습니다'}), 400
|
|
|
|
# 형식 감지 및 처리
|
|
try:
|
|
df = processor.process()
|
|
except ValueError as e:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'지원하지 않는 Excel 형식입니다: {str(e)}'
|
|
}), 400
|
|
|
|
# 데이터 검증
|
|
valid, msg = processor.validate_data()
|
|
if not valid:
|
|
return jsonify({'success': False, 'error': f'데이터 검증 실패: {msg}'}), 400
|
|
|
|
# 표준 형식으로 변환
|
|
df = processor.export_to_standard()
|
|
|
|
# 처리 요약 정보
|
|
summary = processor.get_summary()
|
|
|
|
# 데이터 처리
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
processed_rows = 0
|
|
processed_items = set()
|
|
|
|
# 도매상 정보 확인
|
|
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
|
|
|
|
# 날짜별로 그룹화 (도매상은 이미 선택됨)
|
|
grouped = df.groupby('receipt_date') # 리스트 대신 문자열로 변경
|
|
|
|
for receipt_date, group in grouped:
|
|
# receipt_date가 튜플인 경우 처리
|
|
if isinstance(receipt_date, tuple):
|
|
receipt_date = receipt_date[0]
|
|
|
|
# receipt_date를 문자열로 확실히 변환
|
|
receipt_date_str = str(receipt_date)
|
|
|
|
# YYYY-MM-DD 포맷으로 정규화
|
|
clean_date = receipt_date_str.replace('-', '')
|
|
if len(clean_date) == 8 and clean_date.isdigit():
|
|
receipt_date_str = f"{clean_date[:4]}-{clean_date[4:6]}-{clean_date[6:8]}"
|
|
|
|
# 입고장 번호 생성 (PR-YYYYMMDD-XXXX)
|
|
date_str = receipt_date_str.replace('-', '')
|
|
|
|
# 해당 날짜의 최대 번호 찾기
|
|
cursor.execute("""
|
|
SELECT MAX(CAST(SUBSTR(receipt_no, -4) AS INTEGER))
|
|
FROM purchase_receipts
|
|
WHERE receipt_no LIKE ?
|
|
""", (f'PR-{date_str}-%',))
|
|
|
|
max_num = cursor.fetchone()[0]
|
|
next_num = (max_num or 0) + 1
|
|
receipt_no = f"PR-{date_str}-{next_num:04d}"
|
|
|
|
# 입고장 헤더 생성
|
|
total_amount = float(group['total_amount'].sum()) # float로 변환하여 numpy 타입 문제 해결
|
|
cursor.execute("""
|
|
INSERT INTO purchase_receipts (supplier_id, receipt_date, receipt_no, total_amount, source_file)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
""", (supplier_id, receipt_date_str, receipt_no, total_amount, filename))
|
|
receipt_id = cursor.lastrowid
|
|
|
|
# 입고장 라인 생성
|
|
for _, row in group.iterrows():
|
|
insurance_code = row.get('insurance_code')
|
|
|
|
# 보험코드가 있는 경우 herb_products에서 정보 가져오기
|
|
if insurance_code:
|
|
cursor.execute("""
|
|
SELECT DISTINCT
|
|
hp.ingredient_code,
|
|
hp.product_name,
|
|
hp.company_name
|
|
FROM herb_products hp
|
|
WHERE hp.product_code = ?
|
|
""", (insurance_code,))
|
|
product_info = cursor.fetchone()
|
|
|
|
if product_info:
|
|
ingredient_code = product_info[0]
|
|
product_name = product_info[1]
|
|
company_name = product_info[2]
|
|
|
|
# herb_items에서 해당 보험코드 제품 확인
|
|
cursor.execute("""
|
|
SELECT herb_item_id FROM herb_items
|
|
WHERE insurance_code = ?
|
|
""", (insurance_code,))
|
|
herb = cursor.fetchone()
|
|
|
|
if not herb:
|
|
# 새 제품 생성 (ingredient_code, company_name 포함)
|
|
cursor.execute("""
|
|
INSERT INTO herb_items (
|
|
ingredient_code,
|
|
insurance_code,
|
|
herb_name,
|
|
specification
|
|
) VALUES (?, ?, ?, ?)
|
|
""", (ingredient_code, insurance_code, product_name, company_name))
|
|
herb_item_id = cursor.lastrowid
|
|
else:
|
|
herb_item_id = herb[0]
|
|
# 기존 제품의 ingredient_code가 없으면 업데이트
|
|
cursor.execute("""
|
|
UPDATE herb_items
|
|
SET ingredient_code = COALESCE(ingredient_code, ?),
|
|
specification = COALESCE(specification, ?)
|
|
WHERE herb_item_id = ?
|
|
""", (ingredient_code, company_name, herb_item_id))
|
|
else:
|
|
# herb_products에 없는 경우 — Excel에서 제공한 ingredient_code 활용
|
|
row_ingredient_code = row.get('ingredient_code') if pd.notna(row.get('ingredient_code')) else None
|
|
|
|
cursor.execute("""
|
|
SELECT herb_item_id FROM herb_items
|
|
WHERE insurance_code = ? OR herb_name = ?
|
|
""", (insurance_code, row['herb_name']))
|
|
herb = cursor.fetchone()
|
|
|
|
if not herb:
|
|
cursor.execute("""
|
|
INSERT INTO herb_items (ingredient_code, insurance_code, herb_name)
|
|
VALUES (?, ?, ?)
|
|
""", (row_ingredient_code, insurance_code, row['herb_name']))
|
|
herb_item_id = cursor.lastrowid
|
|
else:
|
|
herb_item_id = herb[0]
|
|
if row_ingredient_code:
|
|
cursor.execute("""
|
|
UPDATE herb_items
|
|
SET ingredient_code = COALESCE(ingredient_code, ?)
|
|
WHERE herb_item_id = ?
|
|
""", (row_ingredient_code, herb_item_id))
|
|
else:
|
|
# 보험코드가 없는 경우 약재명으로만 처리
|
|
cursor.execute("""
|
|
SELECT herb_item_id FROM herb_items
|
|
WHERE herb_name = ?
|
|
""", (row['herb_name'],))
|
|
herb = cursor.fetchone()
|
|
|
|
if not herb:
|
|
cursor.execute("""
|
|
INSERT INTO herb_items (herb_name)
|
|
VALUES (?)
|
|
""", (row['herb_name'],))
|
|
herb_item_id = cursor.lastrowid
|
|
else:
|
|
herb_item_id = herb[0]
|
|
|
|
# 단가 계산 (총액 / 수량)
|
|
quantity = float(row['quantity'])
|
|
total = float(row['total_amount'])
|
|
unit_price = total / quantity if quantity > 0 else 0
|
|
|
|
# 입고장 라인 생성
|
|
cursor.execute("""
|
|
INSERT INTO purchase_receipt_lines
|
|
(receipt_id, herb_item_id, origin_country, quantity_g, unit_price_per_g, line_total)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
""", (receipt_id, herb_item_id, row.get('origin_country'),
|
|
quantity, unit_price, total))
|
|
line_id = cursor.lastrowid
|
|
|
|
# 재고 로트 생성
|
|
cursor.execute("""
|
|
INSERT INTO inventory_lots
|
|
(herb_item_id, supplier_id, receipt_line_id, received_date, origin_country,
|
|
unit_price_per_g, quantity_received, quantity_onhand)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (herb_item_id, supplier_id, line_id, receipt_date_str,
|
|
row.get('origin_country'), unit_price, quantity, quantity))
|
|
lot_id = cursor.lastrowid
|
|
|
|
# 재고 원장 기록
|
|
cursor.execute("""
|
|
INSERT INTO stock_ledger
|
|
(event_type, herb_item_id, lot_id, quantity_delta, unit_cost_per_g,
|
|
reference_table, reference_id)
|
|
VALUES ('RECEIPT', ?, ?, ?, ?, 'purchase_receipts', ?)
|
|
""", (herb_item_id, lot_id, quantity, unit_price, receipt_id))
|
|
|
|
processed_rows += 1
|
|
processed_items.add(row['herb_name'])
|
|
|
|
# 응답 메시지 생성
|
|
format_name = {
|
|
'hanisarang': '한의사랑',
|
|
'haninfo': '한의정보',
|
|
'hanpure': '한퓨어'
|
|
}.get(summary['format_type'], '알 수 없음')
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'{format_name} 형식 입고 데이터가 성공적으로 처리되었습니다',
|
|
'filename': filename,
|
|
'summary': {
|
|
'format': format_name,
|
|
'processed_rows': processed_rows,
|
|
'total_items': len(processed_items),
|
|
'total_quantity': f"{summary['total_quantity']:,.0f}g",
|
|
'total_amount': f"{summary['total_amount']:,.0f}원"
|
|
}
|
|
})
|
|
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
# ==================== 수동 입고 API ====================
|
|
|
|
@app.route('/api/purchase-receipts/manual', methods=['POST'])
|
|
def create_manual_receipt():
|
|
"""수동 입고 처리"""
|
|
try:
|
|
data = request.get_json()
|
|
|
|
# 필수값 검증
|
|
supplier_id = data.get('supplier_id')
|
|
receipt_date = data.get('receipt_date')
|
|
notes = data.get('notes', '')
|
|
lines = data.get('lines', [])
|
|
|
|
if not supplier_id:
|
|
return jsonify({'success': False, 'error': '도매상을 선택해주세요.'}), 400
|
|
if not receipt_date:
|
|
return jsonify({'success': False, 'error': '입고일을 입력해주세요.'}), 400
|
|
if not lines or len(lines) == 0:
|
|
return jsonify({'success': False, 'error': '입고 품목을 1개 이상 추가해주세요.'}), 400
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 도매상 존재 확인
|
|
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
|
|
|
|
# 입고장 번호 생성 (PR-YYYYMMDD-XXXX)
|
|
date_str = receipt_date.replace('-', '')
|
|
cursor.execute("""
|
|
SELECT MAX(CAST(SUBSTR(receipt_no, -4) AS INTEGER))
|
|
FROM purchase_receipts
|
|
WHERE receipt_no LIKE ?
|
|
""", (f'PR-{date_str}-%',))
|
|
max_num = cursor.fetchone()[0]
|
|
next_num = (max_num or 0) + 1
|
|
receipt_no = f"PR-{date_str}-{next_num:04d}"
|
|
|
|
# 총 금액 계산
|
|
total_amount = sum(
|
|
float(line.get('quantity_g', 0)) * float(line.get('unit_price_per_g', 0))
|
|
for line in lines
|
|
)
|
|
|
|
# 입고장 헤더 생성
|
|
cursor.execute("""
|
|
INSERT INTO purchase_receipts (supplier_id, receipt_date, receipt_no, total_amount, source_file, notes)
|
|
VALUES (?, ?, ?, ?, 'MANUAL', ?)
|
|
""", (supplier_id, receipt_date, receipt_no, total_amount, notes))
|
|
receipt_id = cursor.lastrowid
|
|
|
|
processed_count = 0
|
|
for line in lines:
|
|
ingredient_code = line.get('ingredient_code')
|
|
quantity_g = float(line.get('quantity_g', 0))
|
|
unit_price = float(line.get('unit_price_per_g', 0))
|
|
origin_country = line.get('origin_country', '')
|
|
lot_number = line.get('lot_number', '')
|
|
expiry_date = line.get('expiry_date', '')
|
|
line_total = quantity_g * unit_price
|
|
|
|
if not ingredient_code or quantity_g <= 0:
|
|
continue
|
|
|
|
# herb_items에서 해당 ingredient_code 조회
|
|
cursor.execute("""
|
|
SELECT herb_item_id FROM herb_items
|
|
WHERE ingredient_code = ?
|
|
""", (ingredient_code,))
|
|
herb = cursor.fetchone()
|
|
|
|
if not herb:
|
|
# herb_masters에서 약재명 가져와서 herb_items 생성
|
|
cursor.execute("""
|
|
SELECT herb_name FROM herb_masters
|
|
WHERE ingredient_code = ?
|
|
""", (ingredient_code,))
|
|
master = cursor.fetchone()
|
|
herb_name = master[0] if master else ingredient_code
|
|
|
|
cursor.execute("""
|
|
INSERT INTO herb_items (ingredient_code, herb_name)
|
|
VALUES (?, ?)
|
|
""", (ingredient_code, herb_name))
|
|
herb_item_id = cursor.lastrowid
|
|
else:
|
|
herb_item_id = herb[0]
|
|
|
|
# 입고장 라인 생성
|
|
cursor.execute("""
|
|
INSERT INTO purchase_receipt_lines
|
|
(receipt_id, herb_item_id, origin_country, quantity_g, unit_price_per_g, line_total)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
""", (receipt_id, herb_item_id, origin_country, quantity_g, unit_price, line_total))
|
|
line_id = cursor.lastrowid
|
|
|
|
# 재고 로트 생성
|
|
cursor.execute("""
|
|
INSERT INTO inventory_lots
|
|
(herb_item_id, supplier_id, receipt_line_id, received_date, origin_country,
|
|
unit_price_per_g, quantity_received, quantity_onhand, lot_number, expiry_date)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (herb_item_id, supplier_id, line_id, receipt_date,
|
|
origin_country, unit_price, quantity_g, quantity_g,
|
|
lot_number or None, expiry_date or None))
|
|
lot_id = cursor.lastrowid
|
|
|
|
# 재고 원장 기록
|
|
cursor.execute("""
|
|
INSERT INTO stock_ledger
|
|
(event_type, herb_item_id, lot_id, quantity_delta, unit_cost_per_g,
|
|
reference_table, reference_id)
|
|
VALUES ('RECEIPT', ?, ?, ?, ?, 'purchase_receipts', ?)
|
|
""", (herb_item_id, lot_id, quantity_g, unit_price, receipt_id))
|
|
|
|
processed_count += 1
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': '수동 입고가 완료되었습니다.',
|
|
'receipt_no': receipt_no,
|
|
'summary': {
|
|
'item_count': processed_count,
|
|
'total_amount': f"{total_amount:,.0f}원"
|
|
}
|
|
})
|
|
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
# ==================== 입고장 조회/관리 API ====================
|
|
|
|
@app.route('/api/purchase-receipts', methods=['GET'])
|
|
def get_purchase_receipts():
|
|
"""입고장 목록 조회"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 날짜 범위 파라미터
|
|
start_date = request.args.get('start_date')
|
|
end_date = request.args.get('end_date')
|
|
supplier_id = request.args.get('supplier_id')
|
|
|
|
query = """
|
|
SELECT
|
|
pr.receipt_id,
|
|
pr.receipt_date,
|
|
pr.receipt_no,
|
|
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.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
|
|
WHERE 1=1
|
|
"""
|
|
params = []
|
|
|
|
if start_date:
|
|
query += " AND pr.receipt_date >= ?"
|
|
params.append(start_date)
|
|
if end_date:
|
|
query += " AND pr.receipt_date <= ?"
|
|
params.append(end_date)
|
|
if supplier_id:
|
|
query += " AND pr.supplier_id = ?"
|
|
params.append(supplier_id)
|
|
|
|
query += " GROUP BY pr.receipt_id ORDER BY pr.receipt_date DESC, pr.created_at DESC"
|
|
|
|
cursor.execute(query, params)
|
|
receipts = []
|
|
for row in cursor.fetchall():
|
|
receipt = dict(row)
|
|
# 타입 변환 (bytes 문제 해결)
|
|
for key, value in receipt.items():
|
|
if isinstance(value, bytes):
|
|
# bytes를 float로 변환 시도
|
|
try:
|
|
import struct
|
|
receipt[key] = struct.unpack('d', value)[0]
|
|
except:
|
|
receipt[key] = float(0)
|
|
elif key in ['receipt_date', 'created_at'] and value is not None:
|
|
receipt[key] = str(value)
|
|
|
|
# total_amount와 total_quantity 반올림
|
|
if 'total_amount' in receipt and receipt['total_amount'] is not None:
|
|
receipt['total_amount'] = round(float(receipt['total_amount']), 2)
|
|
if 'total_quantity' in receipt and receipt['total_quantity'] is not None:
|
|
receipt['total_quantity'] = round(float(receipt['total_quantity']), 2)
|
|
|
|
receipts.append(receipt)
|
|
|
|
return jsonify({'success': True, 'data': receipts})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/purchase-receipts/<int:receipt_id>', methods=['GET'])
|
|
def get_purchase_receipt_detail(receipt_id):
|
|
"""입고장 상세 조회"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 입고장 헤더 조회
|
|
cursor.execute("""
|
|
SELECT
|
|
pr.*,
|
|
s.name as supplier_name,
|
|
s.business_no as supplier_business_no,
|
|
s.phone as supplier_phone
|
|
FROM purchase_receipts pr
|
|
JOIN suppliers s ON pr.supplier_id = s.supplier_id
|
|
WHERE pr.receipt_id = ?
|
|
""", (receipt_id,))
|
|
|
|
receipt = cursor.fetchone()
|
|
if not receipt:
|
|
return jsonify({'success': False, 'error': '입고장을 찾을 수 없습니다'}), 404
|
|
|
|
receipt_data = dict(receipt)
|
|
|
|
# 입고장 상세 라인 조회 (display_name 포함)
|
|
# prl.*를 쓰면 prl.lot_number/expiry_date와 il.lot_number/expiry_date가 충돌하므로 명시적으로 나열
|
|
cursor.execute("""
|
|
SELECT
|
|
prl.line_id,
|
|
prl.receipt_id,
|
|
prl.herb_item_id,
|
|
prl.origin_country,
|
|
prl.quantity_g,
|
|
prl.unit_price_per_g,
|
|
prl.line_total,
|
|
prl.created_at,
|
|
COALESCE(il.lot_number, prl.lot_number) as lot_number,
|
|
COALESCE(il.expiry_date, prl.expiry_date) as expiry_date,
|
|
h.herb_name,
|
|
h.insurance_code,
|
|
h.ingredient_code,
|
|
il.lot_id,
|
|
il.quantity_onhand as current_stock,
|
|
il.display_name,
|
|
lv.form,
|
|
lv.processing,
|
|
lv.selection_state,
|
|
lv.grade
|
|
FROM purchase_receipt_lines prl
|
|
JOIN herb_items h ON prl.herb_item_id = h.herb_item_id
|
|
LEFT JOIN inventory_lots il ON prl.line_id = il.receipt_line_id
|
|
LEFT JOIN lot_variants lv ON il.lot_id = lv.lot_id
|
|
WHERE prl.receipt_id = ?
|
|
ORDER BY prl.line_id
|
|
""", (receipt_id,))
|
|
|
|
lines = [dict(row) for row in cursor.fetchall()]
|
|
receipt_data['lines'] = lines
|
|
|
|
return jsonify({'success': True, 'data': receipt_data})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/purchase-receipts/<int:receipt_id>/lines/<int:line_id>', methods=['PUT'])
|
|
def update_purchase_receipt_line(receipt_id, line_id):
|
|
"""입고장 라인 수정"""
|
|
try:
|
|
data = request.json
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 기존 라인 정보 조회
|
|
cursor.execute("""
|
|
SELECT prl.*, il.lot_id, il.quantity_onhand, il.quantity_received
|
|
FROM purchase_receipt_lines prl
|
|
LEFT JOIN inventory_lots il ON prl.line_id = il.receipt_line_id
|
|
WHERE prl.line_id = ? AND prl.receipt_id = ?
|
|
""", (line_id, receipt_id))
|
|
|
|
old_line = cursor.fetchone()
|
|
if not old_line:
|
|
return jsonify({'success': False, 'error': '입고 라인을 찾을 수 없습니다'}), 404
|
|
|
|
# 재고 사용 여부 확인
|
|
if old_line['quantity_onhand'] != old_line['quantity_received']:
|
|
used_qty = old_line['quantity_received'] - old_line['quantity_onhand']
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'이미 {used_qty}g이 사용되어 수정할 수 없습니다'
|
|
}), 400
|
|
|
|
# 수정 가능한 필드만 업데이트
|
|
update_fields = []
|
|
params = []
|
|
|
|
if 'quantity_g' in data:
|
|
update_fields.append('quantity_g = ?')
|
|
params.append(data['quantity_g'])
|
|
|
|
if 'unit_price_per_g' in data:
|
|
update_fields.append('unit_price_per_g = ?')
|
|
params.append(data['unit_price_per_g'])
|
|
|
|
if 'line_total' in data:
|
|
update_fields.append('line_total = ?')
|
|
params.append(data['line_total'])
|
|
elif 'quantity_g' in data and 'unit_price_per_g' in data:
|
|
# 자동 계산
|
|
line_total = float(data['quantity_g']) * float(data['unit_price_per_g'])
|
|
update_fields.append('line_total = ?')
|
|
params.append(line_total)
|
|
|
|
if 'origin_country' in data:
|
|
update_fields.append('origin_country = ?')
|
|
params.append(data['origin_country'])
|
|
|
|
if not update_fields:
|
|
return jsonify({'success': False, 'error': '수정할 내용이 없습니다'}), 400
|
|
|
|
# 입고장 라인 업데이트
|
|
params.append(line_id)
|
|
cursor.execute(f"""
|
|
UPDATE purchase_receipt_lines
|
|
SET {', '.join(update_fields)}
|
|
WHERE line_id = ?
|
|
""", params)
|
|
|
|
# 재고 로트 업데이트 (수량 변경시)
|
|
if 'quantity_g' in data and old_line['lot_id']:
|
|
cursor.execute("""
|
|
UPDATE inventory_lots
|
|
SET quantity_received = ?, quantity_onhand = ?
|
|
WHERE lot_id = ?
|
|
""", (data['quantity_g'], data['quantity_g'], old_line['lot_id']))
|
|
|
|
# 재고 원장에 조정 기록
|
|
cursor.execute("""
|
|
INSERT INTO stock_ledger
|
|
(event_type, herb_item_id, lot_id, quantity_delta, notes, reference_table, reference_id)
|
|
VALUES ('ADJUST',
|
|
(SELECT herb_item_id FROM purchase_receipt_lines WHERE line_id = ?),
|
|
?, ?, '입고장 수정', 'purchase_receipt_lines', ?)
|
|
""", (line_id, old_line['lot_id'],
|
|
float(data['quantity_g']) - float(old_line['quantity_g']), line_id))
|
|
|
|
# 입고장 헤더의 총액 업데이트
|
|
cursor.execute("""
|
|
UPDATE purchase_receipts
|
|
SET total_amount = (
|
|
SELECT SUM(line_total)
|
|
FROM purchase_receipt_lines
|
|
WHERE receipt_id = ?
|
|
),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE receipt_id = ?
|
|
""", (receipt_id, receipt_id))
|
|
|
|
return jsonify({'success': True, 'message': '입고 라인이 수정되었습니다'})
|
|
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/purchase-receipts/<int:receipt_id>/bulk', methods=['PUT'])
|
|
def bulk_update_purchase_receipt(receipt_id):
|
|
"""입고장 헤더 + 전체 라인 일괄 수정"""
|
|
try:
|
|
data = request.get_json()
|
|
notes = data.get('notes')
|
|
lines = data.get('lines', [])
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 입고장 존재 확인
|
|
cursor.execute("SELECT receipt_id FROM purchase_receipts WHERE receipt_id = ?", (receipt_id,))
|
|
if not cursor.fetchone():
|
|
return jsonify({'success': False, 'error': '입고장을 찾을 수 없습니다'}), 404
|
|
|
|
# 각 라인 업데이트
|
|
for line_data in lines:
|
|
line_id = line_data.get('line_id')
|
|
if not line_id:
|
|
continue
|
|
|
|
# 기존 라인 + 로트 정보 조회
|
|
cursor.execute("""
|
|
SELECT prl.*, il.lot_id, il.quantity_onhand, il.quantity_received
|
|
FROM purchase_receipt_lines prl
|
|
LEFT JOIN inventory_lots il ON prl.line_id = il.receipt_line_id
|
|
WHERE prl.line_id = ? AND prl.receipt_id = ?
|
|
""", (line_id, receipt_id))
|
|
|
|
old_line = cursor.fetchone()
|
|
if not old_line:
|
|
continue
|
|
|
|
# 수량 변경 시 재고 사용 여부 확인
|
|
new_qty = line_data.get('quantity_g')
|
|
if new_qty is not None and float(new_qty) != float(old_line['quantity_g']):
|
|
if old_line['quantity_onhand'] != old_line['quantity_received']:
|
|
used_qty = old_line['quantity_received'] - old_line['quantity_onhand']
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'이미 {used_qty}g이 사용되어 수량을 수정할 수 없습니다'
|
|
}), 400
|
|
|
|
# purchase_receipt_lines 업데이트
|
|
prl_fields = []
|
|
prl_params = []
|
|
|
|
if 'quantity_g' in line_data:
|
|
prl_fields.append('quantity_g = ?')
|
|
prl_params.append(line_data['quantity_g'])
|
|
|
|
if 'unit_price_per_g' in line_data:
|
|
prl_fields.append('unit_price_per_g = ?')
|
|
prl_params.append(line_data['unit_price_per_g'])
|
|
|
|
if 'origin_country' in line_data:
|
|
prl_fields.append('origin_country = ?')
|
|
prl_params.append(line_data['origin_country'])
|
|
|
|
if 'lot_number' in line_data:
|
|
prl_fields.append('lot_number = ?')
|
|
prl_params.append(line_data['lot_number'] or None)
|
|
|
|
if 'expiry_date' in line_data:
|
|
prl_fields.append('expiry_date = ?')
|
|
prl_params.append(line_data['expiry_date'] or None)
|
|
|
|
# line_total 자동 계산
|
|
qty = float(line_data.get('quantity_g', old_line['quantity_g']))
|
|
price = float(line_data.get('unit_price_per_g', old_line['unit_price_per_g']))
|
|
line_total = qty * price
|
|
prl_fields.append('line_total = ?')
|
|
prl_params.append(line_total)
|
|
|
|
if prl_fields:
|
|
prl_params.append(line_id)
|
|
cursor.execute(f"""
|
|
UPDATE purchase_receipt_lines
|
|
SET {', '.join(prl_fields)}
|
|
WHERE line_id = ?
|
|
""", prl_params)
|
|
|
|
# inventory_lots 업데이트
|
|
if old_line['lot_id']:
|
|
lot_fields = []
|
|
lot_params = []
|
|
|
|
if 'lot_number' in line_data:
|
|
lot_fields.append('lot_number = ?')
|
|
lot_params.append(line_data['lot_number'] or None)
|
|
|
|
if 'expiry_date' in line_data:
|
|
lot_fields.append('expiry_date = ?')
|
|
lot_params.append(line_data['expiry_date'] or None)
|
|
|
|
if 'origin_country' in line_data:
|
|
lot_fields.append('origin_country = ?')
|
|
lot_params.append(line_data['origin_country'])
|
|
|
|
if 'unit_price_per_g' in line_data:
|
|
lot_fields.append('unit_price_per_g = ?')
|
|
lot_params.append(line_data['unit_price_per_g'])
|
|
|
|
# 수량 변경 시 재고 로트도 업데이트
|
|
if new_qty is not None and float(new_qty) != float(old_line['quantity_g']):
|
|
lot_fields.append('quantity_received = ?')
|
|
lot_params.append(new_qty)
|
|
lot_fields.append('quantity_onhand = ?')
|
|
lot_params.append(new_qty)
|
|
|
|
# 재고 원장에 조정 기록
|
|
cursor.execute("""
|
|
INSERT INTO stock_ledger
|
|
(event_type, herb_item_id, lot_id, quantity_delta, notes, reference_table, reference_id)
|
|
VALUES ('ADJUST',
|
|
(SELECT herb_item_id FROM purchase_receipt_lines WHERE line_id = ?),
|
|
?, ?, '입고장 수정', 'purchase_receipt_lines', ?)
|
|
""", (line_id, old_line['lot_id'],
|
|
float(new_qty) - float(old_line['quantity_g']), line_id))
|
|
|
|
if lot_fields:
|
|
lot_params.append(old_line['lot_id'])
|
|
cursor.execute(f"""
|
|
UPDATE inventory_lots
|
|
SET {', '.join(lot_fields)}
|
|
WHERE lot_id = ?
|
|
""", lot_params)
|
|
|
|
# 입고장 헤더 업데이트
|
|
header_fields = []
|
|
header_params = []
|
|
|
|
if notes is not None:
|
|
header_fields.append('notes = ?')
|
|
header_params.append(notes)
|
|
|
|
# 총액 재계산
|
|
header_fields.append("""total_amount = (
|
|
SELECT COALESCE(SUM(line_total), 0)
|
|
FROM purchase_receipt_lines
|
|
WHERE receipt_id = ?
|
|
)""")
|
|
header_params.append(receipt_id)
|
|
|
|
header_fields.append('updated_at = CURRENT_TIMESTAMP')
|
|
header_params.append(receipt_id)
|
|
|
|
cursor.execute(f"""
|
|
UPDATE purchase_receipts
|
|
SET {', '.join(header_fields)}
|
|
WHERE receipt_id = ?
|
|
""", header_params)
|
|
|
|
return jsonify({'success': True, 'message': '입고장이 수정되었습니다'})
|
|
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/purchase-receipts/<int:receipt_id>', methods=['DELETE'])
|
|
def delete_purchase_receipt(receipt_id):
|
|
"""입고장 삭제 (재고 사용 확인 후)"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 재고 사용 여부 확인
|
|
cursor.execute("""
|
|
SELECT
|
|
COUNT(*) as used_count,
|
|
SUM(il.quantity_received - il.quantity_onhand) as used_quantity
|
|
FROM purchase_receipt_lines prl
|
|
JOIN inventory_lots il ON prl.line_id = il.receipt_line_id
|
|
WHERE prl.receipt_id = ?
|
|
AND il.quantity_onhand < il.quantity_received
|
|
""", (receipt_id,))
|
|
|
|
usage = cursor.fetchone()
|
|
if usage['used_count'] > 0:
|
|
return jsonify({
|
|
'success': False,
|
|
'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. 취소된 조제의 소비 내역 삭제 (lot_id를 참조)
|
|
cursor.execute("""
|
|
DELETE FROM compound_consumptions
|
|
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 = ?
|
|
)
|
|
)
|
|
AND compound_id IN (
|
|
SELECT compound_id FROM compounds WHERE status = 'CANCELLED'
|
|
)
|
|
""", (receipt_id,))
|
|
|
|
# 3. 재고 로트 삭제 (receipt_line_id를 참조)
|
|
cursor.execute("""
|
|
DELETE FROM inventory_lots
|
|
WHERE receipt_line_id IN (
|
|
SELECT line_id FROM purchase_receipt_lines WHERE receipt_id = ?
|
|
)
|
|
""", (receipt_id,))
|
|
|
|
# 4. 입고장 라인 삭제 (receipt_id를 참조)
|
|
cursor.execute("DELETE FROM purchase_receipt_lines WHERE receipt_id = ?", (receipt_id,))
|
|
|
|
# 5. 입고장 헤더 삭제
|
|
cursor.execute("DELETE FROM purchase_receipts WHERE receipt_id = ?", (receipt_id,))
|
|
|
|
return jsonify({'success': True, 'message': '입고장이 삭제되었습니다'})
|
|
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
# ==================== 조제 관리 API ====================
|
|
|
|
@app.route('/api/compounds', methods=['GET'])
|
|
def get_compounds():
|
|
"""조제 목록 조회"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
SELECT
|
|
c.compound_id,
|
|
c.patient_id,
|
|
p.name as patient_name,
|
|
p.phone as patient_phone,
|
|
c.formula_id,
|
|
f.formula_name,
|
|
f.formula_code,
|
|
c.compound_date,
|
|
c.je_count,
|
|
c.cheop_total,
|
|
c.pouch_total,
|
|
c.cost_total,
|
|
c.sell_price_total,
|
|
c.prescription_no,
|
|
c.status,
|
|
c.notes,
|
|
c.created_at,
|
|
c.created_by,
|
|
c.usage_type
|
|
FROM compounds c
|
|
LEFT JOIN patients p ON c.patient_id = p.patient_id
|
|
LEFT JOIN formulas f ON c.formula_id = f.formula_id
|
|
ORDER BY c.created_at DESC
|
|
LIMIT 100
|
|
""")
|
|
compounds = [dict(row) for row in cursor.fetchall()]
|
|
return jsonify({'success': True, 'data': compounds})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/compounds/<int:compound_id>', methods=['GET'])
|
|
def get_compound_detail(compound_id):
|
|
"""조제 상세 정보 조회"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 조제 마스터 정보
|
|
cursor.execute("""
|
|
SELECT
|
|
c.*,
|
|
p.name as patient_name,
|
|
p.phone as patient_phone,
|
|
f.formula_name,
|
|
f.formula_code
|
|
FROM compounds c
|
|
LEFT JOIN patients p ON c.patient_id = p.patient_id
|
|
LEFT JOIN formulas f ON c.formula_id = f.formula_id
|
|
WHERE c.compound_id = ?
|
|
""", (compound_id,))
|
|
compound = dict(cursor.fetchone())
|
|
|
|
# 조제 약재 구성
|
|
cursor.execute("""
|
|
SELECT
|
|
ci.*,
|
|
h.herb_name,
|
|
h.insurance_code
|
|
FROM compound_ingredients ci
|
|
JOIN herb_items h ON ci.herb_item_id = h.herb_item_id
|
|
WHERE ci.compound_id = ?
|
|
ORDER BY ci.compound_ingredient_id
|
|
""", (compound_id,))
|
|
ingredients = [dict(row) for row in cursor.fetchall()]
|
|
|
|
# 소비 내역
|
|
cursor.execute("""
|
|
SELECT
|
|
cc.*,
|
|
h.herb_name,
|
|
il.origin_country,
|
|
il.supplier_id,
|
|
s.name as supplier_name
|
|
FROM compound_consumptions cc
|
|
JOIN herb_items h ON cc.herb_item_id = h.herb_item_id
|
|
JOIN inventory_lots il ON cc.lot_id = il.lot_id
|
|
LEFT JOIN suppliers s ON il.supplier_id = s.supplier_id
|
|
WHERE cc.compound_id = ?
|
|
ORDER BY cc.consumption_id
|
|
""", (compound_id,))
|
|
consumptions = [dict(row) for row in cursor.fetchall()]
|
|
|
|
compound['ingredients'] = ingredients
|
|
compound['consumptions'] = consumptions
|
|
|
|
return jsonify({'success': True, 'data': compound})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/patients/<int:patient_id>/compounds', methods=['GET'])
|
|
def get_patient_compounds(patient_id):
|
|
"""환자별 처방 기록 조회"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
SELECT
|
|
c.compound_id,
|
|
c.formula_id,
|
|
f.formula_name,
|
|
f.formula_code,
|
|
c.compound_date,
|
|
c.je_count,
|
|
c.cheop_total,
|
|
c.pouch_total,
|
|
c.cost_total,
|
|
c.sell_price_total,
|
|
c.prescription_no,
|
|
c.status,
|
|
c.notes,
|
|
c.created_at,
|
|
c.created_by,
|
|
c.is_custom,
|
|
c.custom_summary,
|
|
c.custom_type
|
|
FROM compounds c
|
|
LEFT JOIN formulas f ON c.formula_id = f.formula_id
|
|
WHERE c.patient_id = ?
|
|
ORDER BY c.compound_date DESC, c.created_at DESC
|
|
""", (patient_id,))
|
|
compounds = [dict(row) for row in cursor.fetchall()]
|
|
|
|
# 환자 정보도 함께 반환
|
|
cursor.execute("""
|
|
SELECT patient_id, name, phone, gender, birth_date, notes
|
|
FROM patients
|
|
WHERE patient_id = ?
|
|
""", (patient_id,))
|
|
patient_row = cursor.fetchone()
|
|
patient = dict(patient_row) if patient_row else None
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'patient': patient,
|
|
'compounds': compounds
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/compounds', methods=['POST'])
|
|
def create_compound():
|
|
"""조제 실행 - 커스텀 처방 감지 포함"""
|
|
try:
|
|
data = request.json
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
formula_id = data.get('formula_id')
|
|
|
|
# 커스텀 처방 감지를 위한 준비
|
|
is_custom = False
|
|
custom_summary = ""
|
|
custom_details = {
|
|
'added': [],
|
|
'removed': [],
|
|
'modified': []
|
|
}
|
|
|
|
# formula_id가 있는 경우 원 처방과 비교
|
|
if formula_id:
|
|
# 원 처방 구성 조회 (ingredient_code 기반)
|
|
cursor.execute("""
|
|
SELECT fi.ingredient_code, hm.herb_name, fi.grams_per_cheop
|
|
FROM formula_ingredients fi
|
|
JOIN herb_masters hm ON fi.ingredient_code = hm.ingredient_code
|
|
WHERE fi.formula_id = ?
|
|
""", (formula_id,))
|
|
|
|
# 원 처방 구성을 ingredient_code 기준으로 저장
|
|
original_by_code = {}
|
|
original_ingredients = {} # herb_item_id 기준
|
|
|
|
for row in cursor.fetchall():
|
|
ingredient_code = row[0]
|
|
herb_name = row[1]
|
|
grams = row[2]
|
|
|
|
# ingredient_code 기준으로 저장
|
|
original_by_code[ingredient_code] = {
|
|
'herb_name': herb_name,
|
|
'grams': grams
|
|
}
|
|
|
|
# 해당 ingredient_code를 가진 herb_item_id들 조회
|
|
cursor.execute("SELECT herb_item_id FROM herb_items WHERE ingredient_code = ?",
|
|
(ingredient_code,))
|
|
herb_ids = [r[0] for r in cursor.fetchall()]
|
|
|
|
for herb_id in herb_ids:
|
|
original_ingredients[herb_id] = {
|
|
'herb_name': herb_name,
|
|
'grams': grams,
|
|
'ingredient_code': ingredient_code
|
|
}
|
|
|
|
# 실제 조제 구성과 비교
|
|
actual_ingredients = {ing['herb_item_id']: ing['grams_per_cheop']
|
|
for ing in data['ingredients']}
|
|
|
|
# 실제 조제의 ingredient_code 수집
|
|
actual_by_code = {}
|
|
for ing in data['ingredients']:
|
|
cursor.execute("SELECT ingredient_code FROM herb_items WHERE herb_item_id = ?",
|
|
(ing['herb_item_id'],))
|
|
result = cursor.fetchone()
|
|
if result:
|
|
ingredient_code = result[0]
|
|
if ingredient_code not in actual_by_code:
|
|
actual_by_code[ingredient_code] = ing['grams_per_cheop']
|
|
|
|
# 추가된 약재 확인
|
|
for ing in data['ingredients']:
|
|
herb_id = ing['herb_item_id']
|
|
if herb_id not in original_ingredients:
|
|
# 약재명 조회
|
|
cursor.execute("SELECT herb_name FROM herb_items WHERE herb_item_id = ?", (herb_id,))
|
|
herb_name = cursor.fetchone()[0]
|
|
custom_details['added'].append(f"{herb_name} {ing['grams_per_cheop']}g")
|
|
is_custom = True
|
|
|
|
# 제거된 약재 확인 (ingredient_code 기준)
|
|
for code, info in original_by_code.items():
|
|
if code not in actual_by_code:
|
|
custom_details['removed'].append(info['herb_name'])
|
|
is_custom = True
|
|
|
|
# 용량 변경된 약재 확인
|
|
for herb_id, original_info in original_ingredients.items():
|
|
if herb_id in actual_ingredients:
|
|
original_grams = original_info['grams']
|
|
actual_grams = actual_ingredients[herb_id]
|
|
if abs(original_grams - actual_grams) > 0.01:
|
|
custom_details['modified'].append(
|
|
f"{original_info['herb_name']} {original_grams}g→{actual_grams}g"
|
|
)
|
|
is_custom = True
|
|
|
|
# 커스텀 요약 생성
|
|
summary_parts = []
|
|
if custom_details['added']:
|
|
summary_parts.append(f"추가: {', '.join(custom_details['added'])}")
|
|
if custom_details['removed']:
|
|
summary_parts.append(f"제거: {', '.join(custom_details['removed'])}")
|
|
if custom_details['modified']:
|
|
summary_parts.append(f"변경: {', '.join(custom_details['modified'])}")
|
|
|
|
custom_summary = " | ".join(summary_parts) if summary_parts else ""
|
|
|
|
# 용도 구분 (SALE: 판매, SELF_USE: 자가소비, SAMPLE: 샘플, DISPOSAL: 폐기)
|
|
usage_type = data.get('usage_type', 'SALE')
|
|
|
|
# 조제 마스터 생성 (커스텀 정보 포함)
|
|
cursor.execute("""
|
|
INSERT INTO compounds (patient_id, formula_id, compound_date,
|
|
je_count, cheop_total, pouch_total,
|
|
prescription_no, notes, created_by,
|
|
is_custom, custom_summary, custom_type,
|
|
usage_type, sell_price_total)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (
|
|
data.get('patient_id'),
|
|
formula_id,
|
|
data.get('compound_date', datetime.now().strftime('%Y-%m-%d')),
|
|
data['je_count'],
|
|
data['cheop_total'],
|
|
data['pouch_total'],
|
|
data.get('prescription_no'),
|
|
data.get('notes'),
|
|
data.get('created_by', 'system'),
|
|
1 if is_custom else 0,
|
|
custom_summary if is_custom else None,
|
|
'custom' if is_custom else 'standard',
|
|
usage_type,
|
|
0 if usage_type != 'SALE' else data.get('sell_price_total')
|
|
))
|
|
compound_id = cursor.lastrowid
|
|
|
|
total_cost = 0
|
|
|
|
# 조제 약재 처리
|
|
for ingredient in data['ingredients']:
|
|
herb_item_id = ingredient['herb_item_id']
|
|
total_grams = ingredient['total_grams']
|
|
origin_country = ingredient.get('origin_country') # 원산지 선택 정보
|
|
|
|
# modification_type 결정
|
|
modification_type = 'original'
|
|
original_grams = None
|
|
|
|
if formula_id and herb_item_id in original_ingredients:
|
|
orig_g = original_ingredients[herb_item_id]['grams']
|
|
if abs(orig_g - ingredient['grams_per_cheop']) > 0.01:
|
|
modification_type = 'modified'
|
|
original_grams = orig_g
|
|
elif formula_id and herb_item_id not in original_ingredients:
|
|
modification_type = 'added'
|
|
|
|
# 조제 약재 구성 기록 (커스텀 정보 포함)
|
|
cursor.execute("""
|
|
INSERT INTO compound_ingredients (compound_id, herb_item_id,
|
|
grams_per_cheop, total_grams,
|
|
modification_type, original_grams)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
""", (compound_id, herb_item_id,
|
|
ingredient['grams_per_cheop'], total_grams,
|
|
modification_type, original_grams))
|
|
|
|
# 재고 차감 처리
|
|
remaining_qty = total_grams
|
|
|
|
# 수동 로트 배분이 있는 경우 (lot_assignments 배열 사용)
|
|
if 'lot_assignments' in ingredient and ingredient['lot_assignments']:
|
|
# 수동 로트 배분 검증
|
|
assigned_total = sum(la['quantity'] for la in ingredient['lot_assignments'])
|
|
if abs(assigned_total - total_grams) > 0.01:
|
|
raise ValueError(f"로트 배분 합계({assigned_total}g)와 필요량({total_grams}g)이 일치하지 않습니다")
|
|
|
|
# 각 로트별로 처리
|
|
for assignment in ingredient['lot_assignments']:
|
|
lot_id = assignment['lot_id']
|
|
requested_qty = assignment['quantity']
|
|
|
|
# 로트 정보 조회
|
|
cursor.execute("""
|
|
SELECT quantity_onhand, unit_price_per_g
|
|
FROM inventory_lots
|
|
WHERE lot_id = ? AND herb_item_id = ? AND is_depleted = 0
|
|
""", (lot_id, herb_item_id))
|
|
|
|
lot_info = cursor.fetchone()
|
|
if not lot_info:
|
|
raise ValueError(f"로트 #{lot_id}를 찾을 수 없거나 사용 불가능합니다")
|
|
|
|
available = lot_info[0]
|
|
unit_price = lot_info[1]
|
|
|
|
if requested_qty > available:
|
|
raise ValueError(f"로트 #{lot_id}의 재고({available}g)가 부족합니다 (요청: {requested_qty}g)")
|
|
|
|
cost = requested_qty * unit_price
|
|
total_cost += cost
|
|
|
|
# 소비 내역 기록
|
|
cursor.execute("""
|
|
INSERT INTO compound_consumptions (compound_id, herb_item_id, lot_id,
|
|
quantity_used, unit_cost_per_g, cost_amount)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
""", (compound_id, herb_item_id, lot_id, requested_qty, unit_price, cost))
|
|
|
|
# 로트 재고 감소
|
|
new_qty = available - requested_qty
|
|
cursor.execute("""
|
|
UPDATE inventory_lots
|
|
SET quantity_onhand = ?, is_depleted = ?
|
|
WHERE lot_id = ?
|
|
""", (new_qty, 1 if new_qty == 0 else 0, lot_id))
|
|
|
|
# 재고 원장 기록
|
|
cursor.execute("""
|
|
INSERT INTO stock_ledger (event_type, herb_item_id, lot_id,
|
|
quantity_delta, unit_cost_per_g,
|
|
reference_table, reference_id)
|
|
VALUES ('CONSUME', ?, ?, ?, ?, 'compounds', ?)
|
|
""", (herb_item_id, lot_id, -requested_qty, unit_price, compound_id))
|
|
|
|
# remaining_qty 감소 (중요!)
|
|
remaining_qty -= requested_qty
|
|
|
|
# 자동 로트 선택 (기존 로직)
|
|
else:
|
|
# 원산지가 지정된 경우 해당 원산지만, 아니면 전체에서 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()
|
|
|
|
for lot in lots:
|
|
if remaining_qty <= 0:
|
|
break
|
|
|
|
lot_id = lot[0]
|
|
available = lot[1]
|
|
unit_price = lot[2]
|
|
|
|
used = min(remaining_qty, available)
|
|
cost = used * unit_price
|
|
total_cost += cost
|
|
|
|
# 소비 내역 기록
|
|
cursor.execute("""
|
|
INSERT INTO compound_consumptions (compound_id, herb_item_id, lot_id,
|
|
quantity_used, unit_cost_per_g, cost_amount)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
""", (compound_id, herb_item_id, lot_id, used, unit_price, cost))
|
|
|
|
# 로트 재고 감소
|
|
new_qty = available - used
|
|
cursor.execute("""
|
|
UPDATE inventory_lots
|
|
SET quantity_onhand = ?, is_depleted = ?
|
|
WHERE lot_id = ?
|
|
""", (new_qty, 1 if new_qty == 0 else 0, lot_id))
|
|
|
|
# 재고 원장 기록
|
|
cursor.execute("""
|
|
INSERT INTO stock_ledger (event_type, herb_item_id, lot_id,
|
|
quantity_delta, unit_cost_per_g,
|
|
reference_table, reference_id)
|
|
VALUES ('CONSUME', ?, ?, ?, ?, 'compounds', ?)
|
|
""", (herb_item_id, lot_id, -used, unit_price, compound_id))
|
|
|
|
remaining_qty -= used
|
|
|
|
# 재고 부족 체크 (수동/자동 모두 적용)
|
|
if remaining_qty > 0:
|
|
raise Exception(f"재고 부족: {ingredient.get('herb_name', herb_item_id)}")
|
|
|
|
# 총 원가 업데이트
|
|
cursor.execute("""
|
|
UPDATE compounds SET cost_total = ? WHERE compound_id = ?
|
|
""", (total_cost, compound_id))
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': '조제가 완료되었습니다',
|
|
'compound_id': compound_id,
|
|
'total_cost': total_cost
|
|
})
|
|
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
# ==================== 재고 원장 API ====================
|
|
|
|
@app.route('/api/stock-ledger', methods=['GET'])
|
|
def get_stock_ledger():
|
|
"""재고 원장 (입출고 내역) 조회"""
|
|
try:
|
|
herb_id = request.args.get('herb_id')
|
|
limit = request.args.get('limit', 100, type=int)
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
if herb_id:
|
|
cursor.execute("""
|
|
SELECT
|
|
sl.ledger_id,
|
|
sl.event_type,
|
|
sl.event_time,
|
|
h.herb_name,
|
|
h.insurance_code,
|
|
sl.quantity_delta,
|
|
sl.unit_cost_per_g,
|
|
sl.reference_table,
|
|
sl.reference_id,
|
|
il.origin_country,
|
|
s.name as supplier_name,
|
|
CASE
|
|
WHEN sl.event_type = 'PURCHASE' OR sl.event_type = 'RECEIPT' THEN pr.receipt_no
|
|
WHEN sl.event_type IN ('CONSUME', 'RETURN') THEN
|
|
CASE
|
|
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
|
|
WHEN sl.event_type IN ('CONSUME', 'RETURN') THEN p.name
|
|
ELSE NULL
|
|
END as patient_name
|
|
FROM stock_ledger sl
|
|
JOIN herb_items h ON sl.herb_item_id = h.herb_item_id
|
|
LEFT JOIN inventory_lots il ON sl.lot_id = il.lot_id
|
|
LEFT JOIN suppliers s ON il.supplier_id = s.supplier_id
|
|
LEFT JOIN purchase_receipts pr ON sl.reference_table = 'purchase_receipts' AND sl.reference_id = pr.receipt_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 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 ?
|
|
""", (herb_id, limit))
|
|
else:
|
|
cursor.execute("""
|
|
SELECT
|
|
sl.ledger_id,
|
|
sl.event_type,
|
|
sl.event_time,
|
|
h.herb_name,
|
|
h.insurance_code,
|
|
sl.quantity_delta,
|
|
sl.unit_cost_per_g,
|
|
sl.reference_table,
|
|
sl.reference_id,
|
|
il.origin_country,
|
|
s.name as supplier_name,
|
|
CASE
|
|
WHEN sl.event_type = 'PURCHASE' OR sl.event_type = 'RECEIPT' THEN pr.receipt_no
|
|
WHEN sl.event_type IN ('CONSUME', 'RETURN') THEN
|
|
CASE
|
|
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
|
|
WHEN sl.event_type IN ('CONSUME', 'RETURN') THEN p.name
|
|
ELSE NULL
|
|
END as patient_name
|
|
FROM stock_ledger sl
|
|
JOIN herb_items h ON sl.herb_item_id = h.herb_item_id
|
|
LEFT JOIN inventory_lots il ON sl.lot_id = il.lot_id
|
|
LEFT JOIN suppliers s ON il.supplier_id = s.supplier_id
|
|
LEFT JOIN purchase_receipts pr ON sl.reference_table = 'purchase_receipts' AND sl.reference_id = pr.receipt_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 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,))
|
|
|
|
ledger_entries = [dict(row) for row in cursor.fetchall()]
|
|
|
|
# 약재별 현재 재고 요약
|
|
cursor.execute("""
|
|
SELECT
|
|
h.herb_item_id,
|
|
h.herb_name,
|
|
h.insurance_code,
|
|
COALESCE(SUM(il.quantity_onhand), 0) as total_stock,
|
|
COUNT(DISTINCT il.lot_id) as active_lots,
|
|
AVG(il.unit_price_per_g) as avg_price
|
|
FROM herb_items h
|
|
LEFT JOIN inventory_lots il ON h.herb_item_id = il.herb_item_id
|
|
AND il.is_depleted = 0
|
|
WHERE h.is_active = 1
|
|
GROUP BY h.herb_item_id
|
|
HAVING total_stock > 0
|
|
ORDER BY h.herb_name
|
|
""")
|
|
|
|
stock_summary = [dict(row) for row in cursor.fetchall()]
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'ledger': ledger_entries,
|
|
'summary': stock_summary
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
# ==================== 조제용 재고 조회 API ====================
|
|
|
|
@app.route('/api/herbs/<int:herb_item_id>/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
|
|
|
|
# 가용 로트 목록 (소진되지 않은 재고) - display_name 포함
|
|
cursor.execute("""
|
|
SELECT
|
|
il.lot_id,
|
|
il.origin_country,
|
|
il.quantity_onhand,
|
|
il.unit_price_per_g,
|
|
il.received_date,
|
|
il.supplier_id,
|
|
il.display_name,
|
|
lv.form,
|
|
lv.processing,
|
|
lv.selection_state,
|
|
lv.grade
|
|
FROM inventory_lots il
|
|
LEFT JOIN lot_variants lv ON il.lot_id = lv.lot_id
|
|
WHERE il.herb_item_id = ?
|
|
AND il.is_depleted = 0
|
|
AND il.quantity_onhand > 0
|
|
ORDER BY il.origin_country, il.unit_price_per_g, il.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],
|
|
'display_name': row[6],
|
|
'form': row[7],
|
|
'processing': row[8],
|
|
'selection_state': row[9],
|
|
'grade': row[10]
|
|
})
|
|
|
|
# 원산지별 요약
|
|
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():
|
|
"""재고 현황 요약 - 원산지별 구분 표시
|
|
|
|
Query Parameters:
|
|
mode (str): 계산 모드
|
|
- 'all' (기본): 모든 LOT 포함
|
|
- 'receipt_only': 입고장과 연결된 LOT만
|
|
- 'verified': 검증된 LOT만 (is_verified=1)
|
|
"""
|
|
try:
|
|
# 계산 모드 파라미터 가져오기
|
|
mode = request.args.get('mode', 'all')
|
|
|
|
# 모드별 WHERE 조건 설정
|
|
where_conditions = ["il.is_depleted = 0"]
|
|
|
|
if mode == 'receipt_only':
|
|
# receipt_line_id > 0인 것만 (0은 입고장 없음 표시)
|
|
where_conditions.append("il.receipt_line_id > 0")
|
|
elif mode == 'verified':
|
|
# is_verified 컬럼이 없을 경우를 대비해 조건 추가
|
|
# 현재는 receipt_line_id > 0인 것을 검증된 것으로 간주
|
|
where_conditions.append("il.receipt_line_id > 0")
|
|
# 'all' 모드는 추가 조건 없음
|
|
|
|
where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
# 먼저 재고 정보를 정확하게 계산 (효능 태그 JOIN 없이)
|
|
cursor.execute(f"""
|
|
SELECT
|
|
h.herb_item_id,
|
|
h.insurance_code,
|
|
h.herb_name,
|
|
h.ingredient_code,
|
|
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,
|
|
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
|
|
FROM herb_items h
|
|
LEFT JOIN inventory_lots il ON h.herb_item_id = il.herb_item_id AND {where_clause}
|
|
GROUP BY h.herb_item_id, h.insurance_code, h.herb_name, h.ingredient_code
|
|
HAVING total_quantity > 0
|
|
ORDER BY h.herb_name
|
|
""")
|
|
inventory = []
|
|
for row in cursor.fetchall():
|
|
item = dict(row)
|
|
|
|
# 효능 태그를 별도 쿼리로 가져오기
|
|
if item['ingredient_code']:
|
|
cursor.execute("""
|
|
SELECT GROUP_CONCAT(DISTINCT et.tag_name) as tags
|
|
FROM herb_item_tags hit
|
|
JOIN herb_efficacy_tags et ON hit.tag_id = et.tag_id
|
|
WHERE hit.ingredient_code = ?
|
|
""", (item['ingredient_code'],))
|
|
tags_row = cursor.fetchone()
|
|
if tags_row and tags_row[0]:
|
|
item['efficacy_tags'] = tags_row[0].split(',')
|
|
else:
|
|
item['efficacy_tags'] = []
|
|
else:
|
|
# ingredient_code가 없는 경우 product_code로 시도
|
|
cursor.execute("""
|
|
SELECT hp.ingredient_code
|
|
FROM herb_products hp
|
|
WHERE hp.product_code = ?
|
|
""", (item['insurance_code'],))
|
|
prod_row = cursor.fetchone()
|
|
if prod_row and prod_row[0]:
|
|
cursor.execute("""
|
|
SELECT GROUP_CONCAT(DISTINCT et.tag_name) as tags
|
|
FROM herb_item_tags hit
|
|
JOIN herb_efficacy_tags et ON hit.tag_id = et.tag_id
|
|
WHERE hit.ingredient_code = ?
|
|
""", (prod_row[0],))
|
|
tags_row = cursor.fetchone()
|
|
if tags_row and tags_row[0]:
|
|
item['efficacy_tags'] = tags_row[0].split(',')
|
|
else:
|
|
item['efficacy_tags'] = []
|
|
else:
|
|
item['efficacy_tags'] = []
|
|
|
|
# ingredient_code 제거 (API 응답에 불필요)
|
|
if 'ingredient_code' in item:
|
|
del item['ingredient_code']
|
|
|
|
inventory.append(item)
|
|
|
|
# 전체 요약
|
|
total_value = sum(item['total_value'] for item in inventory)
|
|
total_items = len(inventory)
|
|
|
|
# 주성분코드 기준 보유 현황 추가
|
|
cursor.execute("""
|
|
SELECT COUNT(DISTINCT ingredient_code)
|
|
FROM herb_masters
|
|
""")
|
|
total_ingredient_codes = cursor.fetchone()[0]
|
|
|
|
cursor.execute("""
|
|
SELECT COUNT(DISTINCT h.ingredient_code)
|
|
FROM herb_items h
|
|
INNER JOIN inventory_lots il ON h.herb_item_id = il.herb_item_id
|
|
WHERE il.quantity_onhand > 0 AND il.is_depleted = 0
|
|
AND h.ingredient_code IS NOT NULL
|
|
""")
|
|
owned_ingredient_codes = cursor.fetchone()[0]
|
|
|
|
# 계산 모드별 추가 정보 조회
|
|
mode_info = {
|
|
'mode': mode,
|
|
'mode_label': {
|
|
'all': '전체 재고',
|
|
'receipt_only': '입고장 기준',
|
|
'verified': '검증된 재고'
|
|
}.get(mode, mode)
|
|
}
|
|
|
|
# 입고장 없는 LOT 수 확인 (mode='all'일 때만)
|
|
# receipt_line_id = 0을 입고장 없음으로 처리
|
|
if mode == 'all':
|
|
cursor.execute("""
|
|
SELECT COUNT(*) as count,
|
|
COALESCE(SUM(quantity_onhand * unit_price_per_g), 0) as value
|
|
FROM inventory_lots
|
|
WHERE receipt_line_id = 0
|
|
AND is_depleted = 0
|
|
AND quantity_onhand > 0
|
|
""")
|
|
no_receipt = cursor.fetchone()
|
|
mode_info['no_receipt_lots'] = no_receipt[0]
|
|
mode_info['no_receipt_value'] = no_receipt[1]
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'data': inventory,
|
|
'summary': {
|
|
'total_items': total_items,
|
|
'total_value': total_value,
|
|
'total_ingredient_codes': total_ingredient_codes, # 전체 급여 약재 수
|
|
'owned_ingredient_codes': owned_ingredient_codes, # 보유 약재 수
|
|
'coverage_rate': round(owned_ingredient_codes * 100 / total_ingredient_codes, 1) if total_ingredient_codes > 0 else 0, # 보유율
|
|
'calculation_mode': mode_info # 계산 모드 정보 추가
|
|
}
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/inventory/detail/<int:herb_item_id>', 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)
|
|
|
|
# 원산지별 재고 정보 (display_name 포함)
|
|
cursor.execute("""
|
|
SELECT
|
|
il.lot_id,
|
|
il.origin_country,
|
|
il.quantity_onhand,
|
|
il.unit_price_per_g,
|
|
il.received_date,
|
|
il.expiry_date,
|
|
il.supplier_id,
|
|
s.name as supplier_name,
|
|
il.quantity_onhand * il.unit_price_per_g as lot_value,
|
|
il.display_name,
|
|
lv.form,
|
|
lv.processing,
|
|
lv.selection_state,
|
|
lv.grade
|
|
FROM inventory_lots il
|
|
LEFT JOIN suppliers s ON il.supplier_id = s.supplier_id
|
|
LEFT JOIN lot_variants lv ON il.lot_id = lv.lot_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
|
|
|
|
# 서버 실행
|
|
# ==================== 재고 보정 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
|
|
|
|
# ==================== 문진표 API ====================
|
|
|
|
@app.route('/api/surveys/templates', methods=['GET'])
|
|
def get_survey_templates():
|
|
"""문진표 템플릿 조회"""
|
|
try:
|
|
category = request.args.get('category')
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
if category:
|
|
cursor.execute("""
|
|
SELECT * FROM survey_templates
|
|
WHERE category = ? AND is_active = 1
|
|
ORDER BY sort_order
|
|
""", (category,))
|
|
else:
|
|
cursor.execute("""
|
|
SELECT * FROM survey_templates
|
|
WHERE is_active = 1
|
|
ORDER BY sort_order
|
|
""")
|
|
|
|
templates = [dict(row) for row in cursor.fetchall()]
|
|
|
|
# JSON 파싱
|
|
for template in templates:
|
|
if template['options']:
|
|
template['options'] = json.loads(template['options'])
|
|
|
|
return jsonify({'success': True, 'data': templates})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/surveys/categories', methods=['GET'])
|
|
def get_survey_categories():
|
|
"""문진표 카테고리 목록"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
SELECT DISTINCT category, category_name,
|
|
MIN(sort_order) as min_order,
|
|
COUNT(*) as question_count
|
|
FROM survey_templates
|
|
WHERE is_active = 1
|
|
GROUP BY category
|
|
ORDER BY min_order
|
|
""")
|
|
|
|
categories = [dict(row) for row in cursor.fetchall()]
|
|
return jsonify({'success': True, 'data': categories})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/surveys', methods=['POST'])
|
|
def create_survey():
|
|
"""새 문진표 생성"""
|
|
try:
|
|
data = request.json
|
|
patient_id = data.get('patient_id')
|
|
|
|
# 고유 토큰 생성
|
|
import secrets
|
|
survey_token = secrets.token_urlsafe(16)
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
INSERT INTO patient_surveys (patient_id, survey_token, status)
|
|
VALUES (?, ?, 'PENDING')
|
|
""", (patient_id, survey_token))
|
|
|
|
survey_id = cursor.lastrowid
|
|
conn.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'survey_id': survey_id,
|
|
'survey_token': survey_token,
|
|
'survey_url': f'/survey/{survey_token}'
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/surveys/<survey_token>', methods=['GET'])
|
|
def get_survey(survey_token):
|
|
"""문진표 조회 (토큰으로)"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 문진표 기본 정보
|
|
cursor.execute("""
|
|
SELECT s.*, p.name as patient_name, p.phone as patient_phone
|
|
FROM patient_surveys s
|
|
LEFT JOIN patients p ON s.patient_id = p.patient_id
|
|
WHERE s.survey_token = ?
|
|
""", (survey_token,))
|
|
|
|
survey_row = cursor.fetchone()
|
|
if not survey_row:
|
|
return jsonify({'success': False, 'error': '문진표를 찾을 수 없습니다'}), 404
|
|
|
|
survey = dict(survey_row)
|
|
|
|
# 진행 상태 조회
|
|
cursor.execute("""
|
|
SELECT * FROM survey_progress
|
|
WHERE survey_id = ?
|
|
""", (survey['survey_id'],))
|
|
|
|
progress = [dict(row) for row in cursor.fetchall()]
|
|
survey['progress'] = progress
|
|
|
|
# 기존 응답 조회
|
|
cursor.execute("""
|
|
SELECT * FROM survey_responses
|
|
WHERE survey_id = ?
|
|
""", (survey['survey_id'],))
|
|
|
|
responses = [dict(row) for row in cursor.fetchall()]
|
|
survey['responses'] = responses
|
|
|
|
return jsonify({'success': True, 'data': survey})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/surveys/<survey_token>/responses', methods=['POST'])
|
|
def save_survey_responses(survey_token):
|
|
"""문진 응답 저장"""
|
|
try:
|
|
data = request.json
|
|
responses = data.get('responses', [])
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 문진표 확인
|
|
cursor.execute("""
|
|
SELECT survey_id FROM patient_surveys
|
|
WHERE survey_token = ?
|
|
""", (survey_token,))
|
|
|
|
survey_row = cursor.fetchone()
|
|
if not survey_row:
|
|
return jsonify({'success': False, 'error': '문진표를 찾을 수 없습니다'}), 404
|
|
|
|
survey_id = survey_row['survey_id']
|
|
|
|
# 기존 응답 삭제 후 새로 저장 (upsert 방식)
|
|
for response in responses:
|
|
# 기존 응답 삭제
|
|
cursor.execute("""
|
|
DELETE FROM survey_responses
|
|
WHERE survey_id = ? AND question_code = ?
|
|
""", (survey_id, response['question_code']))
|
|
|
|
# 새 응답 저장
|
|
cursor.execute("""
|
|
INSERT INTO survey_responses
|
|
(survey_id, category, question_code, question_text, answer_value, answer_type)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
""", (
|
|
survey_id,
|
|
response['category'],
|
|
response['question_code'],
|
|
response.get('question_text'),
|
|
json.dumps(response['answer_value'], ensure_ascii=False) if isinstance(response['answer_value'], (list, dict)) else response['answer_value'],
|
|
response.get('answer_type', 'SINGLE')
|
|
))
|
|
|
|
# 상태 업데이트
|
|
cursor.execute("""
|
|
UPDATE patient_surveys
|
|
SET status = 'IN_PROGRESS',
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE survey_id = ?
|
|
""", (survey_id,))
|
|
|
|
conn.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'{len(responses)}개 응답 저장 완료'
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/surveys/<survey_token>/complete', methods=['POST'])
|
|
def complete_survey(survey_token):
|
|
"""문진표 제출 완료"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute("""
|
|
UPDATE patient_surveys
|
|
SET status = 'COMPLETED',
|
|
completed_at = CURRENT_TIMESTAMP
|
|
WHERE survey_token = ?
|
|
""", (survey_token,))
|
|
|
|
conn.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': '문진표가 제출되었습니다'
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
# ================ 한약재 확장 정보 API ================
|
|
|
|
@app.route('/api/herbs/<int:herb_id>/extended', methods=['GET'])
|
|
def get_herb_extended_info(herb_id):
|
|
"""약재 확장 정보 조회"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 기본 정보 + 확장 정보 조회
|
|
cursor.execute("""
|
|
SELECT
|
|
hme.*,
|
|
hm.herb_name,
|
|
hm.herb_name_hanja
|
|
FROM herb_master_extended hme
|
|
LEFT JOIN herb_masters hm ON hme.ingredient_code = hm.ingredient_code
|
|
WHERE hme.herb_id = ?
|
|
""", (herb_id,))
|
|
|
|
herb_info = cursor.fetchone()
|
|
|
|
if not herb_info:
|
|
return jsonify({'error': '약재 정보를 찾을 수 없습니다'}), 404
|
|
|
|
# 효능 태그 조회 - ingredient_code 기반
|
|
cursor.execute("""
|
|
SELECT
|
|
het.tag_name,
|
|
het.tag_category,
|
|
het.description,
|
|
hit.strength
|
|
FROM herb_item_tags hit
|
|
JOIN herb_efficacy_tags het ON hit.tag_id = het.tag_id
|
|
JOIN herb_master_extended hme2 ON hit.ingredient_code = hme2.ingredient_code
|
|
WHERE hme2.herb_id = ?
|
|
ORDER BY hit.strength DESC, het.tag_category
|
|
""", (herb_id,))
|
|
|
|
tags = []
|
|
for row in cursor.fetchall():
|
|
tags.append({
|
|
'name': row['tag_name'],
|
|
'category': row['tag_category'],
|
|
'description': row['description'],
|
|
'strength': row['strength']
|
|
})
|
|
|
|
# 안전성 정보 조회
|
|
cursor.execute("""
|
|
SELECT * FROM herb_safety_info
|
|
WHERE herb_id = ?
|
|
""", (herb_id,))
|
|
|
|
safety_info = cursor.fetchone()
|
|
|
|
result = dict(herb_info)
|
|
result['efficacy_tags'] = tags
|
|
result['safety_info'] = dict(safety_info) if safety_info else None
|
|
|
|
return jsonify(result)
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/herbs/<int:herb_id>/extended', methods=['PUT'])
|
|
def update_herb_extended_info(herb_id):
|
|
"""약재 확장 정보 수정"""
|
|
try:
|
|
data = request.json
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 업데이트할 필드 동적 생성
|
|
update_fields = []
|
|
update_values = []
|
|
|
|
allowed_fields = [
|
|
'property', 'taste', 'meridian_tropism',
|
|
'main_effects', 'indications', 'contraindications',
|
|
'precautions', 'dosage_range', 'dosage_max',
|
|
'preparation_method', 'active_compounds',
|
|
'pharmacological_effects', 'clinical_applications'
|
|
]
|
|
|
|
for field in allowed_fields:
|
|
if field in data:
|
|
update_fields.append(f"{field} = ?")
|
|
update_values.append(data[field])
|
|
|
|
if update_fields:
|
|
update_values.append(herb_id)
|
|
cursor.execute(f"""
|
|
UPDATE herb_master_extended
|
|
SET {', '.join(update_fields)},
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE herb_id = ?
|
|
""", update_values)
|
|
|
|
# 변경 로그 기록
|
|
cursor.execute("""
|
|
INSERT INTO data_update_logs
|
|
(update_type, source, target_table, target_id, after_data)
|
|
VALUES ('MANUAL', 'API', 'herb_master_extended', ?, ?)
|
|
""", (herb_id, json.dumps(data, ensure_ascii=False)))
|
|
|
|
conn.commit()
|
|
|
|
return jsonify({'success': True, 'message': '정보가 업데이트되었습니다'})
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/herbs/<int:herb_id>/tags', methods=['GET'])
|
|
def get_herb_tags(herb_id):
|
|
"""약재 효능 태그 조회"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute("""
|
|
SELECT
|
|
het.tag_id,
|
|
het.tag_name,
|
|
het.tag_category,
|
|
het.description,
|
|
hit.strength
|
|
FROM herb_item_tags hit
|
|
JOIN herb_efficacy_tags het ON hit.tag_id = het.tag_id
|
|
WHERE hit.herb_id = ?
|
|
ORDER BY hit.strength DESC
|
|
""", (herb_id,))
|
|
|
|
tags = []
|
|
for row in cursor.fetchall():
|
|
tags.append({
|
|
'tag_id': row['tag_id'],
|
|
'name': row['tag_name'],
|
|
'category': row['tag_category'],
|
|
'description': row['description'],
|
|
'strength': row['strength']
|
|
})
|
|
|
|
return jsonify(tags)
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/herbs/<int:herb_id>/tags', methods=['POST'])
|
|
def add_herb_tag(herb_id):
|
|
"""약재에 효능 태그 추가"""
|
|
try:
|
|
data = request.json
|
|
tag_id = data.get('tag_id')
|
|
strength = data.get('strength', 3)
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute("""
|
|
INSERT OR REPLACE INTO herb_item_tags
|
|
(herb_id, tag_id, strength)
|
|
VALUES (?, ?, ?)
|
|
""", (herb_id, tag_id, strength))
|
|
|
|
conn.commit()
|
|
|
|
return jsonify({'success': True, 'message': '태그가 추가되었습니다'})
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/herbs/search-by-efficacy', methods=['GET'])
|
|
def search_herbs_by_efficacy():
|
|
"""효능별 약재 검색"""
|
|
try:
|
|
# tag_ids 또는 tags 파라미터 받기
|
|
tag_ids = request.args.get('tag_ids', '')
|
|
tag_names = request.args.getlist('tags')
|
|
|
|
if not tag_ids and not tag_names:
|
|
return jsonify({'error': '검색할 태그를 지정해주세요'}), 400
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
if tag_ids:
|
|
# tag_ids로 검색
|
|
tag_id_list = [int(tid) for tid in tag_ids.split(',') if tid]
|
|
placeholders = ','.join('?' * len(tag_id_list))
|
|
cursor.execute(f"""
|
|
SELECT DISTINCT
|
|
hme.herb_id,
|
|
hme.ingredient_code,
|
|
COALESCE(hm.herb_name, hme.name_korean) as herb_name,
|
|
hme.name_hanja,
|
|
hme.main_effects,
|
|
hme.property,
|
|
hme.taste,
|
|
GROUP_CONCAT(het.tag_name) as tags
|
|
FROM herb_master_extended hme
|
|
LEFT JOIN herb_masters hm ON hme.ingredient_code = hm.ingredient_code
|
|
JOIN herb_item_tags hit ON hme.ingredient_code = hit.ingredient_code
|
|
JOIN herb_efficacy_tags het ON hit.tag_id = het.tag_id
|
|
WHERE hit.tag_id IN ({placeholders})
|
|
GROUP BY hme.herb_id
|
|
ORDER BY hm.herb_name
|
|
""", tag_id_list)
|
|
else:
|
|
# tag_names로 검색
|
|
placeholders = ','.join('?' * len(tag_names))
|
|
cursor.execute(f"""
|
|
SELECT DISTINCT
|
|
hme.herb_id,
|
|
hme.ingredient_code,
|
|
COALESCE(hm.herb_name, hme.name_korean) as herb_name,
|
|
hme.name_hanja,
|
|
hme.main_effects,
|
|
hme.property,
|
|
hme.taste,
|
|
GROUP_CONCAT(het.tag_name) as tags
|
|
FROM herb_master_extended hme
|
|
LEFT JOIN herb_masters hm ON hme.ingredient_code = hm.ingredient_code
|
|
JOIN herb_item_tags hit ON hme.ingredient_code = hit.ingredient_code
|
|
JOIN herb_efficacy_tags het ON hit.tag_id = het.tag_id
|
|
WHERE het.tag_name IN ({placeholders})
|
|
GROUP BY hme.herb_id
|
|
ORDER BY hm.herb_name
|
|
""", tag_names)
|
|
|
|
results = []
|
|
for row in cursor.fetchall():
|
|
results.append({
|
|
'herb_id': row['herb_id'],
|
|
'ingredient_code': row['ingredient_code'],
|
|
'herb_name': row['herb_name'],
|
|
'name_hanja': row['name_hanja'],
|
|
'main_effects': row['main_effects'],
|
|
'property': row['property'],
|
|
'taste': row['taste'],
|
|
'tags': row['tags'].split(',') if row['tags'] else []
|
|
})
|
|
|
|
return jsonify(results)
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/prescription-check', methods=['POST'])
|
|
def check_prescription_safety():
|
|
"""처방 안전성 검증"""
|
|
try:
|
|
data = request.json
|
|
herb_ids = data.get('herb_ids', [])
|
|
|
|
if len(herb_ids) < 2:
|
|
return jsonify({'safe': True, 'warnings': []})
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
warnings = []
|
|
|
|
# 약재 조합 규칙 확인
|
|
for i in range(len(herb_ids)):
|
|
for j in range(i + 1, len(herb_ids)):
|
|
cursor.execute("""
|
|
SELECT
|
|
relationship_type,
|
|
description,
|
|
severity_level,
|
|
is_absolute
|
|
FROM prescription_rules
|
|
WHERE (herb1_id = ? AND herb2_id = ?)
|
|
OR (herb1_id = ? AND herb2_id = ?)
|
|
""", (herb_ids[i], herb_ids[j], herb_ids[j], herb_ids[i]))
|
|
|
|
rule = cursor.fetchone()
|
|
if rule:
|
|
# 상반(相反), 상살(相殺) 등 위험한 관계 체크
|
|
if rule['relationship_type'] in ['상반', '상살']:
|
|
warnings.append({
|
|
'type': 'danger',
|
|
'herbs': [herb_ids[i], herb_ids[j]],
|
|
'relationship': rule['relationship_type'],
|
|
'description': rule['description'],
|
|
'is_absolute': rule['is_absolute']
|
|
})
|
|
elif rule['relationship_type'] == '상외':
|
|
warnings.append({
|
|
'type': 'warning',
|
|
'herbs': [herb_ids[i], herb_ids[j]],
|
|
'relationship': rule['relationship_type'],
|
|
'description': rule['description']
|
|
})
|
|
|
|
# 절대 금기 사항이 있으면 안전하지 않음
|
|
is_safe = not any(w.get('is_absolute') for w in warnings)
|
|
|
|
return jsonify({
|
|
'safe': is_safe,
|
|
'warnings': warnings
|
|
})
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/efficacy-tags', methods=['GET'])
|
|
def get_all_efficacy_tags():
|
|
"""모든 효능 태그 조회"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute("""
|
|
SELECT * FROM herb_efficacy_tags
|
|
ORDER BY tag_category, tag_name
|
|
""")
|
|
|
|
tags = []
|
|
for row in cursor.fetchall():
|
|
tags.append({
|
|
'tag_id': row['tag_id'],
|
|
'name': row['tag_name'],
|
|
'category': row['tag_category'],
|
|
'description': row['description']
|
|
})
|
|
|
|
return jsonify(tags)
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
# ==================== 판매 관리 API ====================
|
|
|
|
@app.route('/api/compounds/<int:compound_id>/status', methods=['POST'])
|
|
def update_compound_status(compound_id):
|
|
"""조제 상태 업데이트"""
|
|
try:
|
|
data = request.json
|
|
new_status = data.get('status')
|
|
|
|
valid_statuses = ['PREPARED', 'PENDING_PAYMENT', 'PAID', 'PENDING_DELIVERY',
|
|
'DELIVERED', 'COMPLETED', 'OTC_CONVERTED', 'CANCELLED', 'REFUNDED']
|
|
|
|
if new_status not in valid_statuses:
|
|
return jsonify({'error': '유효하지 않은 상태입니다'}), 400
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 현재 상태 조회
|
|
cursor.execute("SELECT status FROM compounds WHERE compound_id = ?", (compound_id,))
|
|
current = cursor.fetchone()
|
|
if not current:
|
|
return jsonify({'error': '조제 정보를 찾을 수 없습니다'}), 404
|
|
|
|
old_status = current['status']
|
|
|
|
# 취소 요청 시 사전 검증
|
|
if new_status == 'CANCELLED':
|
|
if old_status == 'CANCELLED':
|
|
return jsonify({'error': '이미 취소된 조제입니다'}), 400
|
|
if old_status != 'PREPARED':
|
|
return jsonify({'error': '조제완료(PREPARED) 상태에서만 취소할 수 있습니다'}), 400
|
|
|
|
# 상태 업데이트
|
|
update_fields = ['status = ?']
|
|
update_values = [new_status]
|
|
|
|
# 결제 관련 정보
|
|
if new_status == 'PAID':
|
|
if 'payment_method' in data:
|
|
update_fields.append('payment_method = ?')
|
|
update_values.append(data['payment_method'])
|
|
if 'payment_date' in data:
|
|
update_fields.append('payment_date = ?')
|
|
update_values.append(data['payment_date'])
|
|
if 'actual_payment_amount' in data:
|
|
update_fields.append('actual_payment_amount = ?')
|
|
update_values.append(data['actual_payment_amount'])
|
|
|
|
# 배송 관련 정보
|
|
elif new_status in ['PENDING_DELIVERY', 'DELIVERED']:
|
|
if 'delivery_method' in data:
|
|
update_fields.append('delivery_method = ?')
|
|
update_values.append(data['delivery_method'])
|
|
if 'delivery_date' in data:
|
|
update_fields.append('delivery_date = ?')
|
|
update_values.append(data['delivery_date'])
|
|
if 'invoice_number' in data:
|
|
update_fields.append('invoice_number = ?')
|
|
update_values.append(data['invoice_number'])
|
|
|
|
update_values.append(compound_id)
|
|
|
|
cursor.execute(f"""
|
|
UPDATE compounds
|
|
SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP
|
|
WHERE compound_id = ?
|
|
""", update_values)
|
|
|
|
# 상태 변경 이력 저장
|
|
cursor.execute("""
|
|
INSERT INTO sales_status_history (compound_id, old_status, new_status, changed_by, change_reason)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
""", (compound_id, old_status, new_status,
|
|
data.get('changed_by', 'system'), data.get('reason', '')))
|
|
|
|
# 조제 취소 시 재고 복원
|
|
if new_status == 'CANCELLED':
|
|
# compound_consumptions에서 사용 내역 조회
|
|
cursor.execute("""
|
|
SELECT consumption_id, herb_item_id, lot_id, quantity_used, unit_cost_per_g
|
|
FROM compound_consumptions
|
|
WHERE compound_id = ?
|
|
""", (compound_id,))
|
|
consumptions = cursor.fetchall()
|
|
|
|
for con in consumptions:
|
|
# 재고 복원
|
|
cursor.execute("""
|
|
UPDATE inventory_lots
|
|
SET quantity_onhand = quantity_onhand + ?,
|
|
is_depleted = CASE WHEN quantity_onhand + ? > 0 THEN 0 ELSE is_depleted END
|
|
WHERE lot_id = ?
|
|
""", (con['quantity_used'], con['quantity_used'], con['lot_id']))
|
|
|
|
# stock_ledger에 RETURN 이벤트 기록 (조제 취소 복원)
|
|
cursor.execute("""
|
|
INSERT INTO stock_ledger (event_type, herb_item_id, lot_id,
|
|
quantity_delta, unit_cost_per_g,
|
|
reference_table, reference_id)
|
|
VALUES ('RETURN', ?, ?, ?, ?, 'compounds', ?)
|
|
""", (con['herb_item_id'], con['lot_id'], con['quantity_used'],
|
|
con['unit_cost_per_g'], compound_id))
|
|
|
|
# 판매 거래 기록 (결제 완료시)
|
|
if new_status == 'PAID' and 'actual_payment_amount' in data:
|
|
cursor.execute("""
|
|
INSERT INTO sales_transactions (compound_id, transaction_date, transaction_type,
|
|
amount, payment_method, payment_status, created_by)
|
|
VALUES (?, ?, 'SALE', ?, ?, 'COMPLETED', ?)
|
|
""", (compound_id, data.get('payment_date', datetime.now()),
|
|
data['actual_payment_amount'], data.get('payment_method'),
|
|
data.get('changed_by', 'system')))
|
|
|
|
conn.commit()
|
|
return jsonify({'success': True, 'message': '상태가 업데이트되었습니다'})
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/compounds/<int:compound_id>/usage-type', methods=['PUT'])
|
|
def update_compound_usage_type(compound_id):
|
|
"""조제 용도 변경 (SALE, SELF_USE, SAMPLE, DISPOSAL)"""
|
|
try:
|
|
data = request.json
|
|
new_usage = data.get('usage_type')
|
|
valid_types = ['SALE', 'SELF_USE', 'SAMPLE', 'DISPOSAL']
|
|
|
|
if new_usage not in valid_types:
|
|
return jsonify({'error': f'유효하지 않은 용도입니다: {new_usage}'}), 400
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute("SELECT usage_type, status FROM compounds WHERE compound_id = ?", (compound_id,))
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
return jsonify({'error': '조제를 찾을 수 없습니다'}), 404
|
|
|
|
old_usage = row['usage_type'] or 'SALE'
|
|
|
|
# 용도 변경
|
|
cursor.execute("""
|
|
UPDATE compounds
|
|
SET usage_type = ?,
|
|
sell_price_total = CASE WHEN ? != 'SALE' THEN 0 ELSE sell_price_total END,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE compound_id = ?
|
|
""", (new_usage, new_usage, compound_id))
|
|
|
|
# 이력 기록
|
|
cursor.execute("""
|
|
INSERT INTO sales_status_history (compound_id, old_status, new_status, changed_by, change_reason)
|
|
VALUES (?, ?, ?, 'system', ?)
|
|
""", (compound_id, f'usage:{old_usage}', f'usage:{new_usage}',
|
|
f'용도 변경: {old_usage} → {new_usage}'))
|
|
|
|
conn.commit()
|
|
|
|
return jsonify({'success': True, 'message': f'용도가 변경되었습니다: {new_usage}'})
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/compounds/<int:compound_id>/price', methods=['PUT'])
|
|
def update_compound_price(compound_id):
|
|
"""조제 가격 조정 (복합결제 지원)"""
|
|
try:
|
|
data = request.json
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 현재 정보 조회
|
|
cursor.execute("""
|
|
SELECT sell_price_total, cost_total, patient_id
|
|
FROM compounds WHERE compound_id = ?
|
|
""", (compound_id,))
|
|
current = cursor.fetchone()
|
|
if not current:
|
|
return jsonify({'error': '조제 정보를 찾을 수 없습니다'}), 404
|
|
|
|
update_fields = []
|
|
update_values = []
|
|
|
|
# 판매가격
|
|
if 'sell_price_total' in data:
|
|
update_fields.append('sell_price_total = ?')
|
|
update_values.append(data['sell_price_total'])
|
|
|
|
# 할인 정보
|
|
if 'discount_rate' in data:
|
|
update_fields.append('discount_rate = ?')
|
|
update_values.append(data['discount_rate'])
|
|
|
|
if 'discount_amount' in data:
|
|
update_fields.append('discount_amount = ?')
|
|
update_values.append(data['discount_amount'])
|
|
|
|
if 'discount_reason' in data:
|
|
update_fields.append('discount_reason = ?')
|
|
update_values.append(data['discount_reason'])
|
|
|
|
# 복합 결제 정보
|
|
if 'payment_cash' in data:
|
|
update_fields.append('payment_cash = ?')
|
|
update_values.append(data['payment_cash'])
|
|
|
|
if 'payment_card' in data:
|
|
update_fields.append('payment_card = ?')
|
|
update_values.append(data['payment_card'])
|
|
|
|
if 'payment_transfer' in data:
|
|
update_fields.append('payment_transfer = ?')
|
|
update_values.append(data['payment_transfer'])
|
|
|
|
# 마일리지 사용
|
|
if 'mileage_used' in data and data['mileage_used'] > 0:
|
|
update_fields.append('mileage_used = ?')
|
|
update_values.append(data['mileage_used'])
|
|
|
|
# 환자 마일리지 차감
|
|
if current['patient_id']:
|
|
cursor.execute("""
|
|
UPDATE patients
|
|
SET mileage_balance = mileage_balance - ?,
|
|
total_mileage_used = total_mileage_used + ?
|
|
WHERE patient_id = ?
|
|
""", (data['mileage_used'], data['mileage_used'], current['patient_id']))
|
|
|
|
# 마일리지 거래 이력 추가
|
|
cursor.execute("""
|
|
INSERT INTO mileage_transactions
|
|
(patient_id, compound_id, transaction_type, amount, balance_after, description, created_by)
|
|
SELECT patient_id, ?, 'USED', ?, mileage_balance, '한약 결제 사용', 'system'
|
|
FROM patients WHERE patient_id = ?
|
|
""", (compound_id, data['mileage_used'], current['patient_id']))
|
|
|
|
# 실제 결제 금액 계산
|
|
total_payment = (data.get('mileage_used', 0) +
|
|
data.get('payment_cash', 0) +
|
|
data.get('payment_card', 0) +
|
|
data.get('payment_transfer', 0))
|
|
|
|
if total_payment > 0:
|
|
update_fields.append('actual_payment_amount = ?')
|
|
update_values.append(total_payment)
|
|
|
|
update_values.append(compound_id)
|
|
|
|
if update_fields:
|
|
cursor.execute(f"""
|
|
UPDATE compounds
|
|
SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP
|
|
WHERE compound_id = ?
|
|
""", update_values)
|
|
|
|
conn.commit()
|
|
return jsonify({'success': True, 'message': '가격이 조정되었습니다'})
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/sales/statistics', methods=['GET'])
|
|
def get_sales_statistics():
|
|
"""판매 통계 조회"""
|
|
try:
|
|
start_date = request.args.get('start_date')
|
|
end_date = request.args.get('end_date')
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 기간 내 매출 통계
|
|
query = """
|
|
SELECT
|
|
COUNT(*) as total_count,
|
|
SUM(COALESCE(actual_payment_amount, sell_price_total)) as total_sales,
|
|
AVG(COALESCE(actual_payment_amount, sell_price_total)) as avg_price,
|
|
COUNT(CASE WHEN status = 'COMPLETED' THEN 1 END) as completed_count,
|
|
COUNT(CASE WHEN status = 'CANCELLED' THEN 1 END) as cancelled_count,
|
|
COUNT(CASE WHEN status = 'REFUNDED' THEN 1 END) as refunded_count
|
|
FROM compounds
|
|
WHERE status IN ('PAID', 'PENDING_DELIVERY', 'DELIVERED', 'COMPLETED')
|
|
AND COALESCE(usage_type, 'SALE') = 'SALE'
|
|
"""
|
|
|
|
params = []
|
|
if start_date:
|
|
query += " AND compound_date >= ?"
|
|
params.append(start_date)
|
|
if end_date:
|
|
query += " AND compound_date <= ?"
|
|
params.append(end_date)
|
|
|
|
cursor.execute(query, params)
|
|
stats = cursor.fetchone()
|
|
|
|
# 일별 매출 추이
|
|
daily_query = """
|
|
SELECT
|
|
DATE(compound_date) as sale_date,
|
|
COUNT(*) as count,
|
|
SUM(COALESCE(actual_payment_amount, sell_price_total)) as daily_total
|
|
FROM compounds
|
|
WHERE status IN ('PAID', 'PENDING_DELIVERY', 'DELIVERED', 'COMPLETED')
|
|
AND COALESCE(usage_type, 'SALE') = 'SALE'
|
|
"""
|
|
|
|
if start_date:
|
|
daily_query += " AND compound_date >= ?"
|
|
if end_date:
|
|
daily_query += " AND compound_date <= ?"
|
|
|
|
daily_query += " GROUP BY DATE(compound_date) ORDER BY sale_date"
|
|
|
|
cursor.execute(daily_query, params)
|
|
daily_sales = []
|
|
for row in cursor.fetchall():
|
|
daily_sales.append({
|
|
'date': row['sale_date'],
|
|
'count': row['count'],
|
|
'total': row['daily_total']
|
|
})
|
|
|
|
# 처방별 매출
|
|
formula_query = """
|
|
SELECT
|
|
f.formula_name,
|
|
COUNT(*) as count,
|
|
SUM(COALESCE(c.actual_payment_amount, c.sell_price_total)) as total
|
|
FROM compounds c
|
|
LEFT JOIN formulas f ON c.formula_id = f.formula_id
|
|
WHERE c.status IN ('PAID', 'PENDING_DELIVERY', 'DELIVERED', 'COMPLETED')
|
|
AND COALESCE(c.usage_type, 'SALE') = 'SALE'
|
|
"""
|
|
|
|
if start_date:
|
|
formula_query += " AND c.compound_date >= ?"
|
|
if end_date:
|
|
formula_query += " AND c.compound_date <= ?"
|
|
|
|
formula_query += " GROUP BY f.formula_name ORDER BY total DESC LIMIT 10"
|
|
|
|
cursor.execute(formula_query, params)
|
|
formula_sales = []
|
|
for row in cursor.fetchall():
|
|
formula_sales.append({
|
|
'formula_name': row['formula_name'] or '직접조제',
|
|
'count': row['count'],
|
|
'total': row['total']
|
|
})
|
|
|
|
return jsonify({
|
|
'summary': {
|
|
'total_count': stats['total_count'],
|
|
'total_sales': stats['total_sales'],
|
|
'avg_price': stats['avg_price'],
|
|
'completed_count': stats['completed_count'],
|
|
'cancelled_count': stats['cancelled_count'],
|
|
'refunded_count': stats['refunded_count']
|
|
},
|
|
'daily_sales': daily_sales,
|
|
'formula_sales': formula_sales
|
|
})
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/compounds/<int:compound_id>/convert-to-otc', methods=['POST'])
|
|
def convert_to_otc(compound_id):
|
|
"""OTC 전환"""
|
|
try:
|
|
data = request.json
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# 조제 정보 업데이트
|
|
cursor.execute("""
|
|
UPDATE compounds
|
|
SET status = 'OTC_CONVERTED',
|
|
notes = COALESCE(notes || ' | ', '') || 'OTC 전환: ' || ?,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE compound_id = ?
|
|
""", (data.get('reason', ''), compound_id))
|
|
|
|
# 상태 변경 이력
|
|
cursor.execute("""
|
|
INSERT INTO sales_status_history (compound_id, old_status, new_status,
|
|
changed_by, change_reason)
|
|
SELECT compound_id, status, 'OTC_CONVERTED', ?, ?
|
|
FROM compounds WHERE compound_id = ?
|
|
""", (data.get('changed_by', 'system'), data.get('reason', ''), compound_id))
|
|
|
|
conn.commit()
|
|
return jsonify({'success': True, 'message': 'OTC로 전환되었습니다'})
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
# ==================== 의약품 마스터 검색 API ====================
|
|
|
|
@app.route('/api/medicine-master/search', methods=['GET'])
|
|
def search_medicine_master():
|
|
"""의약품 마스터 검색 (medicine_master.db ATTACH 활용)"""
|
|
try:
|
|
q = request.args.get('q', '').strip()
|
|
category = request.args.get('category', '') # 전문일반구분 필터
|
|
limit = min(int(request.args.get('limit', 50)), 200)
|
|
|
|
if not q or len(q) < 2:
|
|
return jsonify({'success': False, 'error': '검색어는 2자 이상 입력하세요'}), 400
|
|
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
where_clauses = ["(m.product_name LIKE ? OR m.company_name LIKE ? OR m.standard_code LIKE ? OR m.notes LIKE ?)"]
|
|
params = [f'%{q}%', f'%{q}%', f'%{q}%', f'%{q}%']
|
|
|
|
if category:
|
|
where_clauses.append("m.category = ?")
|
|
params.append(category)
|
|
|
|
# 취소된 제품 제외
|
|
where_clauses.append("(m.cancel_date IS NULL OR m.cancel_date = '')")
|
|
|
|
params.append(limit)
|
|
|
|
cursor.execute(f"""
|
|
SELECT m.id, m.product_name, m.company_name, m.spec,
|
|
m.total_quantity, m.form_type, m.package_type,
|
|
m.item_std_code, m.category, m.representative_code,
|
|
m.standard_code, m.ingredient_name_code, m.notes,
|
|
m.atc_code
|
|
FROM med_master.medicine_master m
|
|
WHERE {' AND '.join(where_clauses)}
|
|
ORDER BY m.product_name, m.spec
|
|
LIMIT ?
|
|
""", params)
|
|
|
|
results = [dict(row) for row in cursor.fetchall()]
|
|
|
|
return jsonify({'success': True, 'data': results, 'count': len(results)})
|
|
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/api/medicine-master/categories', methods=['GET'])
|
|
def get_medicine_categories():
|
|
"""의약품 마스터 카테고리 목록 (전문일반구분)"""
|
|
try:
|
|
with get_db() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
SELECT category, COUNT(*) as cnt
|
|
FROM med_master.medicine_master
|
|
WHERE category != '' AND (cancel_date IS NULL OR cancel_date = '')
|
|
GROUP BY category
|
|
ORDER BY cnt DESC
|
|
""")
|
|
categories = [{'name': row['category'], 'count': row['cnt']} for row in cursor.fetchall()]
|
|
|
|
return jsonify({'success': True, 'data': categories})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
|
|
if __name__ == '__main__':
|
|
# 데이터베이스 초기화
|
|
if not os.path.exists(app.config['DATABASE']):
|
|
init_db()
|
|
|
|
# 개발 서버 실행
|
|
app.run(debug=True, host='0.0.0.0', port=5001) |