From 2fddc89bcab00ec3a5b71cdc1bcf51e21b4bbeee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=9C=EA=B3=A8=EC=95=BD=EC=82=AC?= Date: Sun, 15 Feb 2026 07:55:52 +0000 Subject: [PATCH] =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=EC=BB=A4=EB=B0=8B:=20?= =?UTF-8?q?=ED=95=9C=EC=95=BD=20=EC=9E=AC=EA=B3=A0=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ 주요 기능 - 환자 관리: 환자 등록 및 조회 (이름, 전화번호, 주민번호, 성별) - 입고 관리: Excel 파일 업로드로 대량 입고 처리 - 처방 관리: 약속 처방 템플릿 등록 및 관리 - 조제 관리: 처방 기반 조제 및 약재 가감 기능 - 재고 관리: 실시간 재고 현황 및 로트별 관리 🛠️ 기술 스택 - Backend: Flask (Python 웹 프레임워크) - Database: SQLite (경량 관계형 데이터베이스) - Frontend: Bootstrap + jQuery - Excel 처리: pandas + openpyxl 🔧 핵심 개념 - 1제 = 20첩 = 30파우치 (기본값) - FIFO 방식 재고 차감 - 로트별 원산지/단가 관리 - 정확한 조제 원가 계산 📁 프로젝트 구조 - app.py: Flask 백엔드 서버 - database/: 데이터베이스 스키마 및 파일 - templates/: HTML 템플릿 - static/: JavaScript 및 CSS - sample/: 샘플 Excel 파일 🤖 Generated with Claude Code Co-Authored-By: Claude --- .gitignore | 68 ++ README.md | 205 ++++++ app.py | 513 +++++++++++++++ database/schema.sql | 230 +++++++ gitea.md | 309 +++++++++ sample/order_view_20260215154829.xlsx | Bin 0 -> 7915 bytes static/app.js | 596 ++++++++++++++++++ templates/index.html | 556 ++++++++++++++++ ...0215_074050_order_view_20260215154829.xlsx | Bin 0 -> 7915 bytes 기획문서.md | 294 +++++++++ 10 files changed, 2771 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 database/schema.sql create mode 100644 gitea.md create mode 100644 sample/order_view_20260215154829.xlsx create mode 100644 static/app.js create mode 100644 templates/index.html create mode 100644 uploads/20260215_074050_order_view_20260215154829.xlsx create mode 100644 기획문서.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fa4f7a --- /dev/null +++ b/.gitignore @@ -0,0 +1,68 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +ENV/ +env/ +.venv + +# Flask +instance/ +.webassets-cache + +# Database +*.db +*.sqlite +*.sqlite3 + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Logs +*.log + +# Environment variables +.env +.env.local + +# Uploads (if contains sensitive data) +# uploads/* +# !uploads/.gitkeep + +# OS +.DS_Store +Thumbs.db + +# Temporary files +*.tmp +*.bak +*.swp + +# Excel temporary files +~$*.xlsx +~$*.xls diff --git a/README.md b/README.md new file mode 100644 index 0000000..08cf156 --- /dev/null +++ b/README.md @@ -0,0 +1,205 @@ +# 한약 재고관리 시스템 + +한의원/한약방을 위한 웹 기반 재고관리 및 조제관리 시스템입니다. + +## 주요 기능 + +### 1. 환자 관리 +- 환자 등록 (이름, 전화번호, 주민번호, 성별, 생년월일) +- 환자 검색 및 조회 +- 환자별 조제 이력 관리 + +### 2. 입고 관리 +- Excel 파일을 통한 대량 입고 처리 +- 도매상별 입고 관리 +- 로트별 재고 추적 (원산지, 입고일, 단가 등) + +### 3. 처방 관리 (약속 처방) +- 자주 사용하는 처방 템플릿 등록 +- 처방별 구성 약재 및 용량 설정 +- 처방 재사용 및 가감 기능 + +### 4. 조제 관리 +- 처방 선택 후 즉시 조제 +- 약재 가감 기능 +- FIFO 방식 재고 차감 +- 조제 원가 자동 계산 + +### 5. 재고 현황 +- 실시간 재고 조회 +- 약재별 재고 수량 및 금액 +- 로트별 상세 재고 현황 + +## 시스템 아키텍처 + +``` +┌─────────────────────────────────────────┐ +│ 웹 브라우저 (클라이언트) │ +│ HTML + Bootstrap + jQuery │ +└──────────────────┬──────────────────────┘ + │ HTTP/AJAX + │ +┌──────────────────▼──────────────────────┐ +│ Flask 웹 서버 (Backend) │ +│ REST API + 템플릿 렌더링 │ +└──────────────────┬──────────────────────┘ + │ +┌──────────────────▼──────────────────────┐ +│ SQLite Database │ +│ (재고, 환자, 처방, 조제 데이터) │ +└─────────────────────────────────────────┘ +``` + +## 데이터베이스 구조 + +### 핵심 테이블 +- `patients` - 환자 정보 +- `herb_items` - 약재 마스터 (보험코드 기준) +- `suppliers` - 도매상 정보 +- `purchase_receipts` - 입고장 헤더 +- `purchase_receipt_lines` - 입고장 상세 +- `inventory_lots` - 로트별 재고 +- `formulas` - 처방 마스터 +- `formula_ingredients` - 처방 구성 약재 +- `compounds` - 조제 작업 +- `compound_consumptions` - 로트별 차감 내역 +- `stock_ledger` - 재고 원장 (모든 변동 기록) + +### 핵심 개념 +- **1제 = 20첩 = 30파우치** (기본값, 조정 가능) +- **로트 관리**: 입고 시점별로 재고를 구분 관리 +- **FIFO 차감**: 오래된 재고부터 우선 사용 +- **원가 추적**: 로트별 단가 기준 정확한 원가 계산 + +## 설치 방법 + +### 1. 필수 요구사항 +- Python 3.8 이상 +- pip (Python 패키지 관리자) + +### 2. 설치 과정 + +```bash +# 1. 프로젝트 클론 또는 다운로드 +cd kdrug + +# 2. 가상환경 생성 및 활성화 +python3 -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate + +# 3. 필요 패키지 설치 +pip install flask flask-cors pandas openpyxl + +# 4. 서버 실행 +python app.py +``` + +### 3. 웹 브라우저에서 접속 +``` +http://localhost:5001 +``` + +## 사용 방법 + +### 1. 초기 설정 +1. 시스템 실행 후 웹 브라우저로 접속 +2. 약재 마스터 데이터 준비 (Excel 업로드로 자동 생성 가능) + +### 2. Excel 입고장 업로드 +Excel 파일 형식 (필수 컬럼): +- 제품코드 (보험코드 9자리) +- 업체명 +- 약재명 +- 구입일자 (YYYYMMDD) +- 구입량 (그램 단위) +- 구입액 (원) +- 원산지 + +### 3. 환자 등록 +1. 좌측 메뉴에서 "환자 관리" 클릭 +2. "새 환자 등록" 버튼 클릭 +3. 필수 정보 입력 (이름, 전화번호) +4. 선택 정보 입력 (주민번호, 성별, 생년월일 등) + +### 4. 처방 등록 (약속 처방) +1. 좌측 메뉴에서 "처방 관리" 클릭 +2. "새 처방 등록" 버튼 클릭 +3. 처방명 입력 (예: 쌍화탕, 보중익기탕 등) +4. 구성 약재 추가 + - 약재 선택 + - 1첩당 용량(g) 입력 +5. 저장 + +### 5. 조제 실행 +1. 좌측 메뉴에서 "조제 관리" 클릭 +2. "새 조제" 버튼 클릭 +3. 환자 선택 +4. 처방 선택 (등록된 처방 템플릿) +5. 제수 입력 (기본 1제) +6. 약재 가감 (필요시) + - 용량 조정 + - 약재 추가/삭제 +7. "조제 실행" 클릭 +8. 자동으로 재고 차감 및 원가 계산 + +### 6. 재고 확인 +1. 좌측 메뉴에서 "재고 현황" 클릭 +2. 약재명으로 검색 가능 +3. 현재 재고량, 로트 수, 평균 단가, 재고 금액 확인 + +## 시스템 특징 + +### 장점 +- **간편한 Excel 업로드**: 기존 Excel 입고장을 그대로 활용 +- **약속 처방 기능**: 자주 사용하는 처방을 템플릿으로 저장 +- **가감 기능**: 처방 기본 구성에서 유연한 조정 가능 +- **정확한 원가 계산**: 로트별 단가 추적으로 정확한 원가 산출 +- **FIFO 재고 관리**: 선입선출 원칙 자동 적용 +- **웹 기반**: 별도 설치 없이 브라우저에서 사용 + +### 보안 고려사항 +- 주민번호 등 민감 정보는 암호화 필요 (프로덕션 환경) +- 사용자 인증/권한 관리 기능 추가 필요 +- HTTPS 적용 권장 + +## 향후 개발 계획 + +1. **보험 청구 연동** + - 건강보험 첩약 코드 완벽 지원 + - 청구 자료 자동 생성 + +2. **고급 리포트** + - 월별/분기별 매출 분석 + - 약재별 사용량 통계 + - 환자별 처방 이력 + +3. **모바일 지원** + - 반응형 웹 디자인 + - 모바일 앱 개발 + +4. **재고 알림** + - 최소 재고 수준 설정 + - 재고 부족 알림 + - 유효기간 임박 알림 + +5. **다중 사용자** + - 사용자 계정 관리 + - 권한별 접근 제어 + - 작업 이력 추적 + +## 기술 스택 + +- **Backend**: Flask 3.1.2 (Python Web Framework) +- **Database**: SQLite (경량 관계형 데이터베이스) +- **Frontend**: Bootstrap 5.1.3 + jQuery 3.6.0 +- **Excel 처리**: pandas + openpyxl +- **API**: RESTful JSON API + +## 라이선스 + +이 프로젝트는 교육 및 테스트 목적으로 제작되었습니다. +상업적 사용 시 별도 협의가 필요합니다. + +## 문의 + +기술 지원 및 문의사항은 이슈 트래커를 통해 등록해주세요. \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..bc51861 --- /dev/null +++ b/app.py @@ -0,0 +1,513 @@ +#!/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 + +# Flask 앱 초기화 +app = Flask(__name__, static_folder='static', template_folder='templates') +app.config['SECRET_KEY'] = 'your-secret-key-change-in-production' +app.config['DATABASE'] = 'database/kdrug.db' +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') # 외래키 제약 활성화 + 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) + print("Database initialized successfully") + +# 라우트: 메인 페이지 +@app.route('/') +def index(): + return render_template('index.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 + 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', 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 + +# ==================== 약재 관리 API ==================== + +@app.route('/api/herbs', methods=['GET']) +def get_herbs(): + """약재 목록 조회""" + try: + with get_db() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT h.*, COALESCE(s.total_quantity, 0) as current_stock + FROM herb_items h + LEFT JOIN v_current_stock s ON h.herb_item_id = s.herb_item_id + WHERE h.is_active = 1 + ORDER BY h.herb_name + """) + herbs = [dict(row) for row in cursor.fetchall()] + return jsonify({'success': True, 'data': herbs}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +# ==================== 처방 관리 API ==================== + +@app.route('/api/formulas', methods=['GET']) +def get_formulas(): + """처방 목록 조회""" + 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 + FROM formulas + WHERE is_active = 1 + ORDER BY formula_name + """) + formulas = [dict(row) for row in cursor.fetchall()] + 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, created_by) + 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('created_by', 'system') + )) + formula_id = cursor.lastrowid + + # 구성 약재 추가 + if 'ingredients' in data: + for idx, ingredient in enumerate(data['ingredients']): + cursor.execute(""" + INSERT INTO formula_ingredients (formula_id, herb_item_id, + grams_per_cheop, notes, sort_order) + VALUES (?, ?, ?, ?, ?) + """, ( + formula_id, + ingredient['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//ingredients', methods=['GET']) +def get_formula_ingredients(formula_id): + """처방 구성 약재 조회""" + try: + with get_db() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT fi.*, h.herb_name, h.insurance_code + FROM formula_ingredients fi + JOIN herb_items h ON fi.herb_item_id = h.herb_item_id + WHERE fi.formula_id = ? + ORDER BY fi.sort_order + """, (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 + +# ==================== 입고 관리 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 + + # 파일 저장 + 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 파일 읽기 + df = pd.read_excel(filepath) + + # 컬럼 매핑 (Excel 컬럼명 -> DB 필드) + column_mapping = { + '제품코드': 'insurance_code', + '업체명': 'supplier_name', + '약재명': 'herb_name', + '구입일자': 'receipt_date', + '구입량': 'quantity', + '구입액': 'total_amount', + '원산지': 'origin_country' + } + + df = df.rename(columns=column_mapping) + + # 데이터 처리 + with get_db() as conn: + cursor = conn.cursor() + + # 날짜별, 업체별로 그룹화 + grouped = df.groupby(['receipt_date', 'supplier_name']) + + for (receipt_date, supplier_name), group in grouped: + # 공급업체 확인/생성 + cursor.execute("SELECT supplier_id FROM suppliers WHERE name = ?", (supplier_name,)) + supplier = cursor.fetchone() + + if not supplier: + cursor.execute(""" + INSERT INTO suppliers (name) VALUES (?) + """, (supplier_name,)) + supplier_id = cursor.lastrowid + else: + supplier_id = supplier[0] + + # 입고장 헤더 생성 + total_amount = group['total_amount'].sum() + cursor.execute(""" + INSERT INTO purchase_receipts (supplier_id, receipt_date, total_amount, source_file) + VALUES (?, ?, ?, ?) + """, (supplier_id, str(receipt_date), total_amount, filename)) + receipt_id = cursor.lastrowid + + # 입고장 라인 생성 + for _, row in group.iterrows(): + # 약재 확인/생성 + cursor.execute(""" + SELECT herb_item_id FROM herb_items + WHERE insurance_code = ? OR herb_name = ? + """, (row.get('insurance_code'), row['herb_name'])) + herb = cursor.fetchone() + + if not herb: + cursor.execute(""" + INSERT INTO herb_items (insurance_code, herb_name) + VALUES (?, ?) + """, (row.get('insurance_code'), 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, str(receipt_date), + 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)) + + return jsonify({ + 'success': True, + 'message': f'입고 데이터가 성공적으로 처리되었습니다', + 'filename': filename + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +# ==================== 조제 관리 API ==================== + +@app.route('/api/compounds', methods=['POST']) +def create_compound(): + """조제 실행""" + try: + data = request.json + + with get_db() as conn: + cursor = conn.cursor() + + # 조제 마스터 생성 + cursor.execute(""" + INSERT INTO compounds (patient_id, formula_id, compound_date, + je_count, cheop_total, pouch_total, + prescription_no, notes, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + data.get('patient_id'), + data.get('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') + )) + compound_id = cursor.lastrowid + + total_cost = 0 + + # 조제 약재 처리 + for ingredient in data['ingredients']: + herb_item_id = ingredient['herb_item_id'] + total_grams = ingredient['total_grams'] + + # 조제 약재 구성 기록 + cursor.execute(""" + INSERT INTO compound_ingredients (compound_id, herb_item_id, + grams_per_cheop, total_grams) + VALUES (?, ?, ?, ?) + """, (compound_id, herb_item_id, + ingredient['grams_per_cheop'], total_grams)) + + # 재고 차감 (FIFO 방식) + remaining_qty = total_grams + 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 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/inventory/summary', methods=['GET']) +def get_inventory_summary(): + """재고 현황 요약""" + try: + with get_db() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT + h.herb_item_id, + h.insurance_code, + h.herb_name, + COALESCE(SUM(il.quantity_onhand), 0) as total_quantity, + COUNT(DISTINCT il.lot_id) as lot_count, + AVG(il.unit_price_per_g) as avg_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 il.is_depleted = 0 + GROUP BY h.herb_item_id, h.insurance_code, h.herb_name + HAVING total_quantity > 0 + ORDER BY h.herb_name + """) + inventory = [dict(row) for row in cursor.fetchall()] + + # 전체 요약 + total_value = sum(item['total_value'] for item in inventory) + total_items = len(inventory) + + return jsonify({ + 'success': True, + 'data': inventory, + 'summary': { + 'total_items': total_items, + 'total_value': total_value + } + }) + 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) \ No newline at end of file diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..1a3da1f --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,230 @@ +-- 한약 재고관리 시스템 데이터베이스 스키마 +-- SQLite 기준 + +-- 1) 도매상/공급업체 +CREATE TABLE IF NOT EXISTS suppliers ( + supplier_id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + business_no TEXT, + contact_person TEXT, + phone TEXT, + address TEXT, + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 2) 약재 마스터 (보험코드 9자리 기준) +CREATE TABLE IF NOT EXISTS herb_items ( + herb_item_id INTEGER PRIMARY KEY AUTOINCREMENT, + insurance_code TEXT UNIQUE, -- 보험코드 (9자리) + herb_name TEXT NOT NULL, -- 약재명 + specification TEXT, -- 규격/품질 + default_unit TEXT DEFAULT 'g', -- 기본 단위 + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 3) 환자 정보 +CREATE TABLE IF NOT EXISTS patients ( + patient_id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + phone TEXT NOT NULL, + jumin_no TEXT, -- 주민번호 (암호화 필요) + gender TEXT CHECK(gender IN ('M', 'F')), + birth_date DATE, + address TEXT, + notes TEXT, + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(phone, name) +); + +-- 4) 입고장 헤더 +CREATE TABLE IF NOT EXISTS purchase_receipts ( + receipt_id INTEGER PRIMARY KEY AUTOINCREMENT, + supplier_id INTEGER NOT NULL, + receipt_date DATE NOT NULL, + receipt_no TEXT, -- 입고 번호/전표번호 + vat_included INTEGER DEFAULT 1, -- 부가세 포함 여부 + vat_rate REAL DEFAULT 0.10, -- 부가세율 + total_amount REAL, -- 총 입고액 + source_file TEXT, -- Excel 파일명 + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (supplier_id) REFERENCES suppliers(supplier_id) +); + +-- 5) 입고장 상세 라인 +CREATE TABLE IF NOT EXISTS purchase_receipt_lines ( + line_id INTEGER PRIMARY KEY AUTOINCREMENT, + receipt_id INTEGER NOT NULL, + herb_item_id INTEGER NOT NULL, + origin_country TEXT, -- 원산지 + quantity_g REAL NOT NULL, -- 구입량(g) + unit_price_per_g REAL NOT NULL, -- g당 단가 (VAT 포함) + line_total REAL, -- 라인 총액 + expiry_date DATE, -- 유효기간 + lot_number TEXT, -- 로트번호 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (receipt_id) REFERENCES purchase_receipts(receipt_id), + FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id) +); + +-- 6) 재고 로트 (입고 라인별 재고 관리) +CREATE TABLE IF NOT EXISTS inventory_lots ( + lot_id INTEGER PRIMARY KEY AUTOINCREMENT, + herb_item_id INTEGER NOT NULL, + supplier_id INTEGER NOT NULL, + receipt_line_id INTEGER NOT NULL, + received_date DATE NOT NULL, + origin_country TEXT, + unit_price_per_g REAL NOT NULL, + quantity_received REAL NOT NULL, -- 입고 수량 + quantity_onhand REAL NOT NULL, -- 현재 재고 + expiry_date DATE, + lot_number TEXT, + is_depleted INTEGER DEFAULT 0, -- 소진 여부 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id), + FOREIGN KEY (supplier_id) REFERENCES suppliers(supplier_id), + FOREIGN KEY (receipt_line_id) REFERENCES purchase_receipt_lines(line_id) +); + +-- 7) 재고 원장 (모든 재고 변동 기록) +CREATE TABLE IF NOT EXISTS stock_ledger ( + ledger_id INTEGER PRIMARY KEY AUTOINCREMENT, + event_time DATETIME DEFAULT CURRENT_TIMESTAMP, + event_type TEXT NOT NULL CHECK(event_type IN ('RECEIPT', 'CONSUME', 'ADJUST', 'DISCARD', 'RETURN')), + herb_item_id INTEGER NOT NULL, + lot_id INTEGER, + quantity_delta REAL NOT NULL, -- 증감량 (+입고, -사용) + unit_cost_per_g REAL, + reference_table TEXT, -- 참조 테이블 (compounds, adjustments 등) + reference_id INTEGER, -- 참조 ID + notes TEXT, + created_by TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id), + FOREIGN KEY (lot_id) REFERENCES inventory_lots(lot_id) +); + +-- 8) 처방 마스터 (약속 처방) +CREATE TABLE IF NOT EXISTS formulas ( + formula_id INTEGER PRIMARY KEY AUTOINCREMENT, + formula_code TEXT UNIQUE, -- 처방 코드 + formula_name TEXT NOT NULL, -- 처방명 (예: 쌍화탕) + formula_type TEXT DEFAULT 'CUSTOM', -- INSURANCE(보험), CUSTOM(약속처방) + base_cheop INTEGER DEFAULT 20, -- 기본 첩수 (1제 기준) + base_pouches INTEGER DEFAULT 30, -- 기본 파우치수 (1제 기준) + description TEXT, + is_active INTEGER DEFAULT 1, + created_by TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 9) 처방 구성 약재 +CREATE TABLE IF NOT EXISTS formula_ingredients ( + ingredient_id INTEGER PRIMARY KEY AUTOINCREMENT, + formula_id INTEGER NOT NULL, + herb_item_id INTEGER NOT NULL, + grams_per_cheop REAL NOT NULL, -- 1첩당 그램수 + notes TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (formula_id) REFERENCES formulas(formula_id), + FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id), + UNIQUE (formula_id, herb_item_id) +); + +-- 10) 조제 작업 (처방 실행) +CREATE TABLE IF NOT EXISTS compounds ( + compound_id INTEGER PRIMARY KEY AUTOINCREMENT, + patient_id INTEGER, + formula_id INTEGER, + compound_date DATE NOT NULL, + je_count REAL NOT NULL, -- 제수 (1제, 0.5제 등) + cheop_total REAL NOT NULL, -- 총 첩수 + pouch_total REAL NOT NULL, -- 총 파우치수 + cost_total REAL, -- 원가 총액 + sell_price_total REAL, -- 판매 총액 + prescription_no TEXT, -- 처방전 번호 + status TEXT DEFAULT 'PREPARED', -- PREPARED, DISPENSED, CANCELLED + notes TEXT, + created_by TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (patient_id) REFERENCES patients(patient_id), + FOREIGN KEY (formula_id) REFERENCES formulas(formula_id) +); + +-- 11) 조제 약재 구성 (실제 조제시 사용된 약재 - 가감 포함) +CREATE TABLE IF NOT EXISTS compound_ingredients ( + compound_ingredient_id INTEGER PRIMARY KEY AUTOINCREMENT, + compound_id INTEGER NOT NULL, + herb_item_id INTEGER NOT NULL, + grams_per_cheop REAL NOT NULL, -- 1첩당 그램수 (가감 반영) + total_grams REAL NOT NULL, -- 총 사용량 + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (compound_id) REFERENCES compounds(compound_id), + FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id) +); + +-- 12) 조제 소비 내역 (로트별 차감) +CREATE TABLE IF NOT EXISTS compound_consumptions ( + consumption_id INTEGER PRIMARY KEY AUTOINCREMENT, + compound_id INTEGER NOT NULL, + herb_item_id INTEGER NOT NULL, + lot_id INTEGER NOT NULL, + quantity_used REAL NOT NULL, -- 사용량(g) + unit_cost_per_g REAL NOT NULL, -- 단가 + cost_amount REAL, -- 원가액 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (compound_id) REFERENCES compounds(compound_id), + FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id), + FOREIGN KEY (lot_id) REFERENCES inventory_lots(lot_id) +); + +-- 인덱스 생성 +CREATE INDEX IF NOT EXISTS idx_herb_items_name ON herb_items(herb_name); +CREATE INDEX IF NOT EXISTS idx_herb_items_code ON herb_items(insurance_code); +CREATE INDEX IF NOT EXISTS idx_inventory_lots_herb ON inventory_lots(herb_item_id, is_depleted); +CREATE INDEX IF NOT EXISTS idx_stock_ledger_herb ON stock_ledger(herb_item_id, event_time); +CREATE INDEX IF NOT EXISTS idx_compounds_patient ON compounds(patient_id); +CREATE INDEX IF NOT EXISTS idx_compounds_date ON compounds(compound_date); +CREATE INDEX IF NOT EXISTS idx_patients_phone ON patients(phone); + +-- 뷰 생성 (자주 사용되는 조회) +-- 현재 재고 현황 +CREATE VIEW IF NOT EXISTS v_current_stock AS +SELECT + h.herb_item_id, + h.insurance_code, + h.herb_name, + SUM(il.quantity_onhand) as total_quantity, + COUNT(DISTINCT il.lot_id) as lot_count, + AVG(il.unit_price_per_g) as avg_unit_price +FROM herb_items h +LEFT JOIN inventory_lots il ON h.herb_item_id = il.herb_item_id AND il.is_depleted = 0 +GROUP BY h.herb_item_id, h.insurance_code, h.herb_name; + +-- 처방별 구성 약재 뷰 +CREATE VIEW IF NOT EXISTS v_formula_details AS +SELECT + f.formula_id, + f.formula_name, + f.formula_code, + h.herb_name, + fi.grams_per_cheop, + h.insurance_code +FROM formulas f +JOIN formula_ingredients fi ON f.formula_id = fi.formula_id +JOIN herb_items h ON fi.herb_item_id = h.herb_item_id +WHERE f.is_active = 1 +ORDER BY f.formula_id, fi.sort_order; \ No newline at end of file diff --git a/gitea.md b/gitea.md new file mode 100644 index 0000000..9bb8278 --- /dev/null +++ b/gitea.md @@ -0,0 +1,309 @@ +# Gitea 리포지토리 생성 및 푸시 가이드 + +## 🏠 서버 정보 + +- **Gitea 서버**: `git.0bin.in` +- **사용자명**: `thug0bin` +- **이메일**: `thug0bin@gmail.com` +- **액세스 토큰**: `d83f70b219c6028199a498fb94009f4c1debc9a9` + +## 🚀 새 리포지토리 생성 및 푸시 과정 + +### 1. 로컬 Git 리포지토리 초기화 + +```bash +# 프로젝트 디렉토리로 이동 +cd /path/to/your/project + +# Git 초기화 +git init + +# .gitignore 파일 생성 (필요시) +cat > .gitignore << 'EOF' +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build outputs +dist/ +build/ + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ + +# Database +*.db +*.sqlite +*.sqlite3 +EOF +``` + +### 2. Git 사용자 설정 확인 + +```bash +# Git 사용자 정보 확인 +git config --list | grep -E "user" + +# 설정되지 않은 경우 설정 +git config --global user.name "시골약사" +git config --global user.email "thug0bin@gmail.com" +``` + +### 3. 첫 번째 커밋 + +```bash +# 모든 파일 스테이징 +git add . + +# 첫 커밋 (상세한 커밋 메시지 예시) +git commit -m "$(cat <<'EOF' +Initial commit: [프로젝트명] + +✨ [주요 기능 설명] +- 기능 1 +- 기능 2 +- 기능 3 + +🛠️ 기술 스택: +- 사용된 기술들 나열 + +🔧 주요 구성: +- 프로젝트 구조 설명 + +🤖 Generated with [Claude Code](https://claude.ai/code) + +Co-Authored-By: Claude +EOF +)" +``` + +### 4. 원격 리포지토리 연결 및 푸시 + +```bash +# 원격 리포지토리 추가 (리포지토리명을 실제 이름으로 변경) +git remote add origin https://thug0bin:d83f70b219c6028199a498fb94009f4c1debc9a9@git.0bin.in/thug0bin/[REPOSITORY_NAME].git + +# 브랜치를 main으로 변경 +git branch -M main + +# 원격 리포지토리로 푸시 +git push -u origin main +``` + +## 📝 리포지토리명 네이밍 규칙 + +### 권장 네이밍 패턴: +- **프론트엔드 프로젝트**: `project-name-frontend` +- **백엔드 프로젝트**: `project-name-backend` +- **풀스택 프로젝트**: `project-name-fullstack` +- **도구/유틸리티**: `tool-name-utils` +- **문서/가이드**: `project-name-docs` + +### 예시: +- `figma-admin-dashboard` ✅ +- `anipharm-api-server` ✅ +- `inventory-management-system` ✅ +- `member-portal-frontend` ✅ + +## 🔄 기존 리포지토리에 추가 커밋 + +```bash +# 변경사항 확인 +git status + +# 변경된 파일 스테이징 +git add . + +# 또는 특정 파일만 스테이징 +git add path/to/specific/file + +# 커밋 +git commit -m "커밋 메시지" + +# 푸시 +git push origin main +``` + +## 🌿 브랜치 작업 + +```bash +# 새 브랜치 생성 및 전환 +git checkout -b feature/new-feature + +# 브랜치에서 작업 후 커밋 +git add . +git commit -m "Feature: 새로운 기능 추가" + +# 브랜치 푸시 +git push -u origin feature/new-feature + +# main 브랜치로 돌아가기 +git checkout main + +# 브랜치 병합 (필요시) +git merge feature/new-feature +``` + +## 🛠️ 자주 사용하는 Git 명령어 + +```bash +# 현재 상태 확인 +git status + +# 변경 내역 확인 +git diff + +# 커밋 히스토리 확인 +git log --oneline + +# 원격 리포지토리 정보 확인 +git remote -v + +# 특정 포트 프로세스 종료 (개발 서버 관련) +lsof -ti:PORT_NUMBER | xargs -r kill -9 +``` + +## 🔧 포트 관리 스크립트 + +```bash +# 특정 포트 종료 함수 추가 (bashrc에 추가 가능) +killport() { + if [ -z "$1" ]; then + echo "Usage: killport " + return 1 + fi + lsof -ti:$1 | xargs -r kill -9 + echo "Killed processes on port $1" +} + +# 사용 예시 +# killport 7738 +# killport 5000 +``` + +## 📋 VS Code 워크스페이스 설정 + +여러 리포지토리를 동시에 관리하려면 워크스페이스 파일을 생성하세요: + +```json +{ + "folders": [ + { + "name": "Main Repository", + "path": "." + }, + { + "name": "New Project", + "path": "./new-project-folder" + } + ], + "settings": { + "git.enableSmartCommit": true, + "git.confirmSync": false, + "git.autofetch": true + } +} +``` + +## 🚨 문제 해결 + +### 1. 인증 실패 +```bash +# 토큰이 만료된 경우, 새 토큰으로 원격 URL 업데이트 +git remote set-url origin https://thug0bin:NEW_TOKEN@git.0bin.in/thug0bin/repo-name.git +``` + +### 2. 푸시 거부 +```bash +# 원격 변경사항을 먼저 가져오기 +git pull origin main --rebase + +# 충돌 해결 후 푸시 +git push origin main +``` + +### 3. 대용량 파일 문제 +```bash +# Git LFS 설정 (필요시) +git lfs install +git lfs track "*.zip" +git lfs track "*.gz" +git add .gitattributes +``` + +## 📊 커밋 메시지 템플릿 + +### 기본 템플릿: +``` +타입: 간단한 설명 + +상세한 설명 (선택사항) + +🤖 Generated with [Claude Code](https://claude.ai/code) + +Co-Authored-By: Claude +``` + +### 타입별 예시: +- `✨ feat: 새로운 기능 추가` +- `🐛 fix: 버그 수정` +- `📝 docs: 문서 업데이트` +- `🎨 style: 코드 포맷팅` +- `♻️ refactor: 코드 리팩토링` +- `⚡ perf: 성능 개선` +- `✅ test: 테스트 추가` +- `🔧 chore: 빌드 설정 변경` + +## 🔗 유용한 링크 + +- **Gitea 웹 인터페이스**: https://git.0bin.in/ +- **내 리포지토리 목록**: https://git.0bin.in/thug0bin +- **새 리포지토리 생성**: https://git.0bin.in/repo/create + +## 💡 팁과 모범 사례 + +1. **정기적인 커밋**: 작은 단위로 자주 커밋하세요 +2. **의미있는 커밋 메시지**: 변경 사항을 명확히 설명하세요 +3. **브랜치 활용**: 기능별로 브랜치를 나누어 작업하세요 +4. **.gitignore 활용**: 불필요한 파일은 제외하세요 +5. **문서화**: README.md와 같은 문서를 항상 업데이트하세요 + +--- + +**작성일**: 2025년 7월 29일 +**마지막 업데이트**: 토큰 및 서버 정보 최신화 +**참고**: 이 가이드는 재사용 가능하도록 작성되었습니다. 새 프로젝트마다 참고하세요. + +> 💡 **중요**: 액세스 토큰은 보안이 중요한 정보입니다. 공개 저장소에 업로드하지 마세요! \ No newline at end of file diff --git a/sample/order_view_20260215154829.xlsx b/sample/order_view_20260215154829.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..50891ac692ea192d0026af63b9dbe32841b3afe4 GIT binary patch literal 7915 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 literal 0 HcmV?d00001 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..46becc3 --- /dev/null +++ b/static/app.js @@ -0,0 +1,596 @@ +// 한약 재고관리 시스템 - Frontend JavaScript + +$(document).ready(function() { + // 페이지 네비게이션 + $('.sidebar .nav-link').on('click', function(e) { + e.preventDefault(); + const page = $(this).data('page'); + + // Active 상태 변경 + $('.sidebar .nav-link').removeClass('active'); + $(this).addClass('active'); + + // 페이지 전환 + $('.main-content').removeClass('active'); + $(`#${page}`).addClass('active'); + + // 페이지별 데이터 로드 + loadPageData(page); + }); + + // 초기 데이터 로드 + loadPageData('dashboard'); + + // 페이지별 데이터 로드 함수 + function loadPageData(page) { + switch(page) { + case 'dashboard': + loadDashboard(); + break; + case 'patients': + loadPatients(); + break; + case 'formulas': + loadFormulas(); + break; + case 'compound': + loadCompounds(); + loadPatientsForSelect(); + loadFormulasForSelect(); + break; + case 'inventory': + loadInventory(); + break; + case 'herbs': + loadHerbs(); + break; + } + } + + // 대시보드 데이터 로드 + function loadDashboard() { + // 환자 수 + $.get('/api/patients', function(response) { + if (response.success) { + $('#totalPatients').text(response.data.length); + } + }); + + // 재고 현황 + $.get('/api/inventory/summary', function(response) { + if (response.success) { + $('#totalHerbs').text(response.data.length); + $('#inventoryValue').text(formatCurrency(response.summary.total_value)); + } + }); + + // TODO: 오늘 조제 수, 최근 조제 내역 + } + + // 환자 목록 로드 + function loadPatients() { + $.get('/api/patients', function(response) { + if (response.success) { + const tbody = $('#patientsList'); + tbody.empty(); + + response.data.forEach(patient => { + tbody.append(` + + ${patient.name} + ${patient.phone} + ${patient.gender === 'M' ? '남' : patient.gender === 'F' ? '여' : '-'} + ${patient.birth_date || '-'} + ${patient.notes || '-'} + + + + + `); + }); + } + }); + } + + // 환자 등록 + $('#savePatientBtn').on('click', function() { + const patientData = { + name: $('#patientName').val(), + phone: $('#patientPhone').val(), + jumin_no: $('#patientJumin').val(), + gender: $('#patientGender').val(), + birth_date: $('#patientBirth').val(), + address: $('#patientAddress').val(), + notes: $('#patientNotes').val() + }; + + $.ajax({ + url: '/api/patients', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(patientData), + success: function(response) { + if (response.success) { + alert('환자가 등록되었습니다.'); + $('#patientModal').modal('hide'); + $('#patientForm')[0].reset(); + loadPatients(); + } + }, + error: function(xhr) { + alert('오류: ' + xhr.responseJSON.error); + } + }); + }); + + // 처방 목록 로드 + function loadFormulas() { + $.get('/api/formulas', function(response) { + if (response.success) { + const tbody = $('#formulasList'); + tbody.empty(); + + response.data.forEach(formula => { + tbody.append(` + + ${formula.formula_code || '-'} + ${formula.formula_name} + ${formula.base_cheop}첩 + ${formula.base_pouches}파우치 + + + + + + + + `); + }); + + // 구성 약재 보기 + $('.view-ingredients').on('click', function() { + const formulaId = $(this).data('id'); + $.get(`/api/formulas/${formulaId}/ingredients`, function(response) { + if (response.success) { + let ingredientsList = response.data.map(ing => + `${ing.herb_name}: ${ing.grams_per_cheop}g` + ).join(', '); + alert('구성 약재:\n' + ingredientsList); + } + }); + }); + } + }); + } + + // 처방 구성 약재 추가 (모달) + let formulaIngredientCount = 0; + $('#addFormulaIngredientBtn').on('click', function() { + formulaIngredientCount++; + $('#formulaIngredients').append(` + + + + + + + + + + + + + + + `); + + // 약재 목록 로드 + const selectElement = $(`#formulaIngredients tr[data-row="${formulaIngredientCount}"] .herb-select`); + loadHerbsForSelect(selectElement); + + // 삭제 버튼 이벤트 + $(`#formulaIngredients tr[data-row="${formulaIngredientCount}"] .remove-ingredient`).on('click', function() { + $(this).closest('tr').remove(); + }); + }); + + // 처방 저장 + $('#saveFormulaBtn').on('click', function() { + const ingredients = []; + $('#formulaIngredients tr').each(function() { + const herbId = $(this).find('.herb-select').val(); + const grams = $(this).find('.grams-input').val(); + + if (herbId && grams) { + ingredients.push({ + herb_item_id: parseInt(herbId), + grams_per_cheop: parseFloat(grams), + notes: $(this).find('.notes-input').val() + }); + } + }); + + const formulaData = { + formula_code: $('#formulaCode').val(), + formula_name: $('#formulaName').val(), + formula_type: $('#formulaType').val(), + base_cheop: parseInt($('#baseCheop').val()), + base_pouches: parseInt($('#basePouches').val()), + description: $('#formulaDescription').val(), + ingredients: ingredients + }; + + $.ajax({ + url: '/api/formulas', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(formulaData), + success: function(response) { + if (response.success) { + alert('처방이 등록되었습니다.'); + $('#formulaModal').modal('hide'); + $('#formulaForm')[0].reset(); + $('#formulaIngredients').empty(); + loadFormulas(); + } + }, + error: function(xhr) { + alert('오류: ' + xhr.responseJSON.error); + } + }); + }); + + // 조제 관리 + $('#newCompoundBtn').on('click', function() { + $('#compoundForm').show(); + $('#compoundEntryForm')[0].reset(); + $('#compoundIngredients').empty(); + }); + + $('#cancelCompoundBtn').on('click', function() { + $('#compoundForm').hide(); + }); + + // 제수 변경 시 첩수 자동 계산 + $('#jeCount').on('input', function() { + const jeCount = parseFloat($(this).val()) || 0; + const cheopTotal = jeCount * 20; + const pouchTotal = jeCount * 30; + + $('#cheopTotal').val(cheopTotal); + $('#pouchTotal').val(pouchTotal); + + // 약재별 총 용량 재계산 + updateIngredientTotals(); + }); + + // 처방 선택 시 구성 약재 로드 + $('#compoundFormula').on('change', function() { + const formulaId = $(this).val(); + if (!formulaId) { + $('#compoundIngredients').empty(); + return; + } + + $.get(`/api/formulas/${formulaId}/ingredients`, function(response) { + if (response.success) { + $('#compoundIngredients').empty(); + + response.data.forEach(ing => { + const cheopTotal = parseFloat($('#cheopTotal').val()) || 0; + const totalGrams = ing.grams_per_cheop * cheopTotal; + + $('#compoundIngredients').append(` + + ${ing.herb_name} + + + + ${totalGrams.toFixed(1)} + 확인중... + + + + + `); + }); + + // 재고 확인 + checkStockForCompound(); + + // 용량 변경 이벤트 + $('.grams-per-cheop').on('input', updateIngredientTotals); + + // 삭제 버튼 이벤트 + $('.remove-compound-ingredient').on('click', function() { + $(this).closest('tr').remove(); + }); + } + }); + }); + + // 약재별 총 용량 업데이트 + function updateIngredientTotals() { + const cheopTotal = parseFloat($('#cheopTotal').val()) || 0; + + $('#compoundIngredients tr').each(function() { + const gramsPerCheop = parseFloat($(this).find('.grams-per-cheop').val()) || 0; + const totalGrams = gramsPerCheop * cheopTotal; + $(this).find('.total-grams').text(totalGrams.toFixed(1)); + }); + + checkStockForCompound(); + } + + // 재고 확인 + function checkStockForCompound() { + $('#compoundIngredients tr').each(function() { + const herbId = $(this).data('herb-id'); + const totalGrams = parseFloat($(this).find('.total-grams').text()) || 0; + const $stockStatus = $(this).find('.stock-status'); + + // TODO: API 호출로 실제 재고 확인 + $stockStatus.text('재고 확인 필요'); + }); + } + + // 조제 약재 추가 + $('#addIngredientBtn').on('click', function() { + const newRow = $(` + + + + + + + + 0.0 + - + + + + + `); + + $('#compoundIngredients').append(newRow); + + // 약재 목록 로드 + loadHerbsForSelect(newRow.find('.herb-select-compound')); + + // 이벤트 바인딩 + newRow.find('.grams-per-cheop').on('input', updateIngredientTotals); + newRow.find('.remove-compound-ingredient').on('click', function() { + $(this).closest('tr').remove(); + }); + newRow.find('.herb-select-compound').on('change', function() { + const herbId = $(this).val(); + $(this).closest('tr').attr('data-herb-id', herbId); + updateIngredientTotals(); + }); + }); + + // 조제 실행 + $('#compoundEntryForm').on('submit', function(e) { + e.preventDefault(); + + const ingredients = []; + $('#compoundIngredients tr').each(function() { + const herbId = $(this).data('herb-id'); + const gramsPerCheop = parseFloat($(this).find('.grams-per-cheop').val()); + const totalGrams = parseFloat($(this).find('.total-grams').text()); + + if (herbId && gramsPerCheop) { + ingredients.push({ + herb_item_id: parseInt(herbId), + grams_per_cheop: gramsPerCheop, + total_grams: totalGrams + }); + } + }); + + const compoundData = { + patient_id: $('#compoundPatient').val() ? parseInt($('#compoundPatient').val()) : null, + formula_id: $('#compoundFormula').val() ? parseInt($('#compoundFormula').val()) : null, + je_count: parseFloat($('#jeCount').val()), + cheop_total: parseFloat($('#cheopTotal').val()), + pouch_total: parseFloat($('#pouchTotal').val()), + ingredients: ingredients + }; + + $.ajax({ + url: '/api/compounds', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(compoundData), + success: function(response) { + if (response.success) { + alert(`조제가 완료되었습니다.\n원가: ${formatCurrency(response.total_cost)}`); + $('#compoundForm').hide(); + loadCompounds(); + } + }, + error: function(xhr) { + alert('오류: ' + xhr.responseJSON.error); + } + }); + }); + + // 조제 내역 로드 + function loadCompounds() { + // TODO: 조제 내역 API 구현 필요 + $('#compoundsList').html('조제 내역이 없습니다.'); + } + + // 재고 현황 로드 + function loadInventory() { + $.get('/api/inventory/summary', function(response) { + if (response.success) { + const tbody = $('#inventoryList'); + tbody.empty(); + + response.data.forEach(item => { + tbody.append(` + + ${item.insurance_code || '-'} + ${item.herb_name} + ${item.total_quantity.toFixed(1)} + ${item.lot_count} + ${item.avg_price ? formatCurrency(item.avg_price) : '-'} + ${formatCurrency(item.total_value)} + + `); + }); + } + }); + } + + // 약재 목록 로드 + function loadHerbs() { + $.get('/api/herbs', function(response) { + if (response.success) { + const tbody = $('#herbsList'); + tbody.empty(); + + response.data.forEach(herb => { + tbody.append(` + + ${herb.insurance_code || '-'} + ${herb.herb_name} + ${herb.specification || '-'} + ${herb.current_stock ? herb.current_stock.toFixed(1) + 'g' : '0g'} + + + + + `); + }); + } + }); + } + + // 입고장 업로드 + $('#purchaseUploadForm').on('submit', function(e) { + e.preventDefault(); + + const formData = new FormData(); + const fileInput = $('#purchaseFile')[0]; + + if (fileInput.files.length === 0) { + alert('파일을 선택해주세요.'); + return; + } + + formData.append('file', fileInput.files[0]); + + $('#uploadResult').html('
업로드 중...
'); + + $.ajax({ + url: '/api/upload/purchase', + method: 'POST', + data: formData, + processData: false, + contentType: false, + success: function(response) { + if (response.success) { + $('#uploadResult').html( + `
+ ${response.message} +
` + ); + $('#purchaseUploadForm')[0].reset(); + } + }, + error: function(xhr) { + $('#uploadResult').html( + `
+ 오류: ${xhr.responseJSON.error} +
` + ); + } + }); + }); + + // 검색 기능 + $('#patientSearch').on('keyup', function() { + const value = $(this).val().toLowerCase(); + $('#patientsList tr').filter(function() { + $(this).toggle($(this).text().toLowerCase().indexOf(value) > -1); + }); + }); + + $('#inventorySearch').on('keyup', function() { + const value = $(this).val().toLowerCase(); + $('#inventoryList tr').filter(function() { + $(this).toggle($(this).text().toLowerCase().indexOf(value) > -1); + }); + }); + + // 헬퍼 함수들 + function loadPatientsForSelect() { + $.get('/api/patients', function(response) { + if (response.success) { + const select = $('#compoundPatient'); + select.empty().append(''); + + response.data.forEach(patient => { + select.append(``); + }); + } + }); + } + + function loadFormulasForSelect() { + $.get('/api/formulas', function(response) { + if (response.success) { + const select = $('#compoundFormula'); + select.empty().append(''); + + response.data.forEach(formula => { + select.append(``); + }); + } + }); + } + + function loadHerbsForSelect(selectElement) { + $.get('/api/herbs', function(response) { + if (response.success) { + selectElement.empty().append(''); + + response.data.forEach(herb => { + selectElement.append(``); + }); + } + }); + } + + function formatCurrency(amount) { + if (amount === null || amount === undefined) return '0원'; + return new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: 'KRW' + }).format(amount); + } +}); \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..94a20c4 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,556 @@ + + + + + + 한약 재고관리 시스템 + + + + + + + + +
+
+ + + + +
+ +
+

대시보드

+
+
+
+
총 환자수
+
0
+
+
+
+
+
재고 품목
+
0
+
+
+
+
+
오늘 조제
+
0
+
+
+
+
+
재고 자산
+
0
+
+
+
+ +
+
+
+
+
최근 조제 내역
+
+
+ + + + + + + + + + + + + + +
조제일환자명처방명제수파우치상태
+
+
+
+
+
+ + +
+
+

환자 관리

+ +
+
+
+
+ +
+ + + + + + + + + + + + + + +
환자명전화번호성별생년월일메모작업
+
+
+
+ + +
+
+

입고 관리

+
+
+
+
Excel 파일 업로드
+
+
+ + +
+ 양식: 제품코드, 업체명, 약재명, 구입일자, 구입량, 구입액, 원산지 +
+
+ +
+
+
+
+
+ + +
+
+

처방 관리

+ +
+
+
+ + + + + + + + + + + + + + +
처방코드처방명기본 첩수기본 파우치구성 약재작업
+
+
+
+ + +
+
+

조제 관리

+ +
+ + +
+
+
조제 내역
+
+
+ + + + + + + + + + + + + + + +
조제일환자명처방명제수파우치원가상태
+
+
+
+ + +
+

재고 현황

+
+
+
+ +
+ + + + + + + + + + + + + + +
보험코드약재명현재 재고(g)로트 수평균 단가재고 금액
+
+
+
+ + +
+
+

약재 관리

+ +
+
+
+ + + + + + + + + + + + + +
보험코드약재명규격현재 재고작업
+
+
+
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/uploads/20260215_074050_order_view_20260215154829.xlsx b/uploads/20260215_074050_order_view_20260215154829.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..50891ac692ea192d0026af63b9dbe32841b3afe4 GIT binary patch literal 7915 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 literal 0 HcmV?d00001 diff --git a/기획문서.md b/기획문서.md new file mode 100644 index 0000000..017d9a3 --- /dev/null +++ b/기획문서.md @@ -0,0 +1,294 @@ +1) 핵심 개념 정리 +단위/판매 단위 +1제 = 20첩 = 30파우치 + +1파우치 = 2/3첩 + +ERP에서 원가/마진 계산은 결국 “파우치 1개당 원가” 로 떨어지게 설계하면 현장 감각이 맞습니다. + +약재(원재료) 관리 방식 +약재는 입고 시 원산지/업체/입고시점/그람당 단가/입고량(g) 을 가진 로트(lot) 로 쌓이고 + +조제 시 처방 구성(예: 숙지황 5g, 작약 3g …)을 기준으로 로트를 차감합니다. + +차감 정책은 보통 FEFO(유통기한 우선) 또는 FIFO. + +VAT(부가세) +질문에서 “기본 포함금액”이라 했으니 DB는 기본을 VAT 포함 단가로 저장하고, + +필요하면 vat_included + vat_rate 로 “세금계산서/관리용”으로만 분리 계산할 수 있게 합니다. + +2) 테이블 설계 (권장: “전표/로트/원장” 3층 구조) +A. 마스터 +suppliers (도매상/입고업체) + +herb_items (약재 마스터: 첩약보험 9자리 코드가 핵심 FK) + +(옵션) origins (원산지 표준화) + +B. 입고장(매입 전표) +purchase_receipts (입고장 헤더) + +purchase_receipt_lines (입고장 라인: 약재별 단가/수량/원산지) + +C. 재고(로트) +inventory_lots + +입고 라인 1건 → 로트 1건 생성(또는 같은 조건이면 합산 로트 정책 가능) + +로트에 “현재 잔량”을 두고 즉시 조회가 빠르게. + +D. 재고 원장(증감 기록, 트랜잭션 근거) +stock_ledger + +입고(+), 조제(-), 조정(+/-), 폐기(-) 모두 기록 + +정합성의 근거는 원장, inventory_lots.qty_onhand_g 는 캐시(빠른 조회용)로 유지 + +E. 조제/처방(보험코드 기반) +formulas (처방 마스터: “쌍화탕” 등, 보험코드) + +formula_ingredients (구성 약재: herb_item + grams_per_첩 등) + +F. 조제 실행(배치/제조) +compounds (조제 작업 헤더: 몇 제, 몇 파우치, 판매/조제 일자) + +compound_consumptions (로트별 차감 내역: lot_id, qty_used_g) + +이렇게 하면: + +입고장은 회계/증빙 + +재고는 로트로 실물 관리 + +조제는 로트에서 차감 + +모든 변화는 원장으로 추적 가능 → 감사/오류 수정/원가추적에 강함 + +3) DDL 예시 (PostgreSQL 기준, SQLite에서도 거의 그대로 사용 가능) +SQLite는 NUMERIC/DECIMAL을 내부적으로 유연하게 저장하니, 정밀도는 애플리케이션에서 관리(또는 정수로 “원/그램×1000” 방식)하면 더 안전합니다. + +-- 1) Suppliers +CREATE TABLE suppliers ( + supplier_id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + biz_no TEXT, + contact TEXT, + phone TEXT, + address TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- 2) Herb items (첩약보험 9자리 코드) +CREATE TABLE herb_items ( + herb_item_id INTEGER PRIMARY KEY, + insurance_code9 TEXT NOT NULL UNIQUE, -- 9자리 + herb_name TEXT NOT NULL, + spec TEXT, -- 규격/품질 등 + default_unit TEXT NOT NULL DEFAULT 'g', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- 3) Purchase receipt header +CREATE TABLE purchase_receipts ( + receipt_id INTEGER PRIMARY KEY, + supplier_id INTEGER NOT NULL, + receipt_date TEXT NOT NULL, -- 입고 시점(YYYY-MM-DD) + vat_included INTEGER NOT NULL DEFAULT 1, + vat_rate NUMERIC NOT NULL DEFAULT 0.10, + note TEXT, + source_file TEXT, -- xls 파일명/해시 등 + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (supplier_id) REFERENCES suppliers(supplier_id) +); + +-- 4) Purchase receipt lines +CREATE TABLE purchase_receipt_lines ( + line_id INTEGER PRIMARY KEY, + receipt_id INTEGER NOT NULL, + herb_item_id INTEGER NOT NULL, + origin_country TEXT, -- 원산지 + qty_g NUMERIC NOT NULL, -- 구입량(g) + unit_price_per_g NUMERIC NOT NULL, -- g당 단가 (VAT 포함 기준) + line_total NUMERIC, -- qty_g * unit_price_per_g (캐시) + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (receipt_id) REFERENCES purchase_receipts(receipt_id), + FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id) +); + +-- 5) Inventory lots (입고 라인 기반 로트) +CREATE TABLE inventory_lots ( + lot_id INTEGER PRIMARY KEY, + herb_item_id INTEGER NOT NULL, + supplier_id INTEGER NOT NULL, + receipt_line_id INTEGER NOT NULL UNIQUE, + received_date TEXT NOT NULL, + origin_country TEXT, + unit_price_per_g NUMERIC NOT NULL, + qty_received_g NUMERIC NOT NULL, + qty_onhand_g NUMERIC NOT NULL, + expiry_date TEXT, -- 있으면 FEFO 가능 + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id), + FOREIGN KEY (supplier_id) REFERENCES suppliers(supplier_id), + FOREIGN KEY (receipt_line_id) REFERENCES purchase_receipt_lines(line_id) +); + +-- 6) Stock ledger (재고 원장) +CREATE TABLE stock_ledger ( + ledger_id INTEGER PRIMARY KEY, + event_time TEXT NOT NULL DEFAULT (datetime('now')), + event_type TEXT NOT NULL, -- 'RECEIPT','CONSUME','ADJUST','DISCARD' + herb_item_id INTEGER NOT NULL, + lot_id INTEGER, -- 로트 단위 증감이면 기록 + qty_delta_g NUMERIC NOT NULL, -- +입고, -차감 + unit_cost_per_g NUMERIC, -- 원가 추적용(입고/차감 시점) + ref_table TEXT, -- 'purchase_receipts','compounds' 등 + ref_id INTEGER, + note TEXT, + FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id), + FOREIGN KEY (lot_id) REFERENCES inventory_lots(lot_id) +); + +-- 7) Formulas (처방 마스터) +CREATE TABLE formulas ( + formula_id INTEGER PRIMARY KEY, + insurance_code9 TEXT NOT NULL UNIQUE, -- 처방 보험코드(요구사항) + formula_name TEXT NOT NULL, -- 예: 쌍화탕 + base_pouches_per_je INTEGER NOT NULL DEFAULT 30, + base_cheop_per_je INTEGER NOT NULL DEFAULT 20, + note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- 8) Formula ingredients (구성 약재) +-- grams_per_cheop: 1첩 기준 몇 g +CREATE TABLE formula_ingredients ( + formula_ingredient_id INTEGER PRIMARY KEY, + formula_id INTEGER NOT NULL, + herb_item_id INTEGER NOT NULL, + grams_per_cheop NUMERIC NOT NULL, + FOREIGN KEY (formula_id) REFERENCES formulas(formula_id), + FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id), + UNIQUE (formula_id, herb_item_id) +); + +-- 9) Compound batch (조제 실행) +CREATE TABLE compounds ( + compound_id INTEGER PRIMARY KEY, + formula_id INTEGER NOT NULL, + compound_date TEXT NOT NULL, + je_count NUMERIC NOT NULL, -- 1제, 0.5제 등 가능하게 + cheop_total NUMERIC NOT NULL, -- = je_count * 20 + pouch_total NUMERIC NOT NULL, -- = je_count * 30 (기본) 또는 입력 + sell_price_total NUMERIC, -- 매출 총액(선택) + note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (formula_id) REFERENCES formulas(formula_id) +); + +-- 10) Compound consumption lines (로트별 차감) +CREATE TABLE compound_consumptions ( + consumption_id INTEGER PRIMARY KEY, + compound_id INTEGER NOT NULL, + herb_item_id INTEGER NOT NULL, + lot_id INTEGER NOT NULL, + qty_used_g NUMERIC NOT NULL, + unit_cost_per_g NUMERIC NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (compound_id) REFERENCES compounds(compound_id), + FOREIGN KEY (herb_item_id) REFERENCES herb_items(herb_item_id), + FOREIGN KEY (lot_id) REFERENCES inventory_lots(lot_id) +); +4) “입고장 자동 작성(XLS)” 처리 흐름 +XLS에서 들어온 컬럼(예시) +입고일자, 약재명, 보험코드9, 원산지, 구입량(g), g당 단가, 도매상(업체명), (옵션) VAT 포함여부 + +자동 처리 로직 +supplier(도매상) 매칭/없으면 생성 + +herb_items는 보험코드9 기준으로 upsert + +보험코드9이 없고 약재명만 있으면: “임시코드/매핑 대기” 상태로 넣고 추후 매칭 + +purchase_receipts 헤더 생성(파일 단위 또는 날짜/업체 단위로 묶기) + +purchase_receipt_lines 생성 + +각 line마다 inventory_lots 생성 + lot.qty_onhand_g = qty_received_g + +stock_ledger에 RECEIPT (+qty) 기록 + +“B. 도매상 선택(입고장 입력)”은 receipt 헤더에 supplier_id를 두면 해결됩니다. +XLS 자체가 입고장 형태면 “파일 1개 = 입고장 1개”, 아니면 “업체+날짜”로 자동 그룹핑 추천. + +5) 조제 시 재고 차감(트랜잭션 처리 핵심) +조제 계산 +cheop_total = je_count * 20 + +각 약재 소요량(g) = grams_per_cheop * cheop_total + +예: 숙지황 5g/첩, 1제(20첩)면 100g 소요 + +로트 선택(예: FEFO) +해당 herb_item의 lot 중 qty_onhand_g > 0 인 것들을 + +expiry_date ASC NULLS LAST, received_date ASC 순으로 소비 + +필요한 g를 로트 여러 개로 쪼개 차감 가능 + +기록 +compounds 1건 생성 + +compound_consumptions에 “lot별 사용량” 생성 + +inventory_lots.qty_onhand_g 감소 + +stock_ledger에 CONSUME (-qty) 기록 + +왜 “로트별 소비 테이블”이 꼭 필요하냐? +원가 계산(가중/실제)을 정확히 하려면 어느 로트(단가)에서 몇 g를 썼는지가 필요 + +나중에 “원산지/업체별 사용량” 리포트도 가능 + +6) 원가/마진 계산(파우치 기준) +조제 1건(compound)의 재료 원가 +material_cost = SUM(compound_consumptions.qty_used_g * unit_cost_per_g) + +파우치 1개당 원가 +cost_per_pouch = material_cost / pouch_total + +마진(매출이 있을 때) +gross_profit = sell_price_total - material_cost + +gross_margin = gross_profit / sell_price_total + +한약은 “부재료/포장재/인건비/탕전 수수료” 같은 간접비가 큼 → +추후 overhead_rules (파우치당 포장비, 제당 탕전비 등) 테이블을 추가하면 확장 쉬워요. + +7) SQLite → PostgreSQL 이관 팁 (처음부터 이렇게 해두면 편함) +ID는 INTEGER PRIMARY KEY(SQLite)로 시작해도 Postgres에서 BIGSERIAL/IDENTITY로 쉽게 변환 + +날짜/시간은 SQLite에서는 TEXT(ISO8601), Postgres에서는 timestamptz/date로 변경 + +금액/단가는 Postgres에서 numeric(12,4) 같은 식으로 고정 권장 + +8) 다음 스텝(바로 구현 순서 제안) +XLS 입고 Import부터: suppliers/herb_items/receipt/lot/ledger까지 자동 생성 + +처방 등록: formulas + formula_ingredients (보험코드9 기반) + +조제 실행: compound 생성 → 필요량 계산 → 로트 차감/원장 기록 + +리포트: 재고현황(약재별 합계), 로트별 재고, 월별 매입/사용/마진 + +원하면 다음 메시지에서 내가 바로 이어서: + +“XLS 컬럼 예시(샘플 헤더)” 를 기준으로 import 매핑표, + +FEFO/FIFO 차감 SQL(또는 파이썬 로직), + +“입고장 자동 그룹핑 규칙(파일단위/업체+날짜단위)” +까지 한 번에 “구현 가능한 수준”으로 적어줄게요. + +우선, 실제 XLS에 보험코드9 열이 항상 있나, 아니면 약재명만 있고 보험코드 매핑이 따로 필요한지 이 두 케이스 중 어느 쪽이 더 많아요? (둘 다 가능하도록 설계는 해뒀고, import 로직만 달라져요.) \ No newline at end of file