초기 커밋: 한약 재고관리 시스템

 주요 기능
- 환자 관리: 환자 등록 및 조회 (이름, 전화번호, 주민번호, 성별)
- 입고 관리: 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 <noreply@anthropic.com>
This commit is contained in:
시골약사 2026-02-15 07:55:52 +00:00
commit 2fddc89bca
10 changed files with 2771 additions and 0 deletions

68
.gitignore vendored Normal file
View File

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

205
README.md Normal file
View File

@ -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
## 라이선스
이 프로젝트는 교육 및 테스트 목적으로 제작되었습니다.
상업적 사용 시 별도 협의가 필요합니다.
## 문의
기술 지원 및 문의사항은 이슈 트래커를 통해 등록해주세요.

513
app.py Normal file
View File

@ -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/<int:formula_id>/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)

230
database/schema.sql Normal file
View File

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

309
gitea.md Normal file
View File

@ -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 <noreply@anthropic.com>
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 <port_number>"
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 <noreply@anthropic.com>
```
### 타입별 예시:
- `✨ 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일
**마지막 업데이트**: 토큰 및 서버 정보 최신화
**참고**: 이 가이드는 재사용 가능하도록 작성되었습니다. 새 프로젝트마다 참고하세요.
> 💡 **중요**: 액세스 토큰은 보안이 중요한 정보입니다. 공개 저장소에 업로드하지 마세요!

Binary file not shown.

596
static/app.js Normal file
View File

@ -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(`
<tr>
<td>${patient.name}</td>
<td>${patient.phone}</td>
<td>${patient.gender === 'M' ? '남' : patient.gender === 'F' ? '여' : '-'}</td>
<td>${patient.birth_date || '-'}</td>
<td>${patient.notes || '-'}</td>
<td>
<button class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</button>
</td>
</tr>
`);
});
}
});
}
// 환자 등록
$('#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(`
<tr>
<td>${formula.formula_code || '-'}</td>
<td>${formula.formula_name}</td>
<td>${formula.base_cheop}</td>
<td>${formula.base_pouches}파우치</td>
<td>
<button class="btn btn-sm btn-outline-info view-ingredients"
data-id="${formula.formula_id}">
<i class="bi bi-eye"></i>
</button>
</td>
<td>
<button class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</button>
</td>
</tr>
`);
});
// 구성 약재 보기
$('.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(`
<tr data-row="${formulaIngredientCount}">
<td>
<select class="form-control form-control-sm herb-select">
<option value="">약재 선택</option>
</select>
</td>
<td>
<input type="number" class="form-control form-control-sm grams-input"
min="0.1" step="0.1" placeholder="0.0">
</td>
<td>
<input type="text" class="form-control form-control-sm notes-input">
</td>
<td>
<button type="button" class="btn btn-sm btn-outline-danger remove-ingredient">
<i class="bi bi-x"></i>
</button>
</td>
</tr>
`);
// 약재 목록 로드
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(`
<tr data-herb-id="${ing.herb_item_id}">
<td>${ing.herb_name}</td>
<td>
<input type="number" class="form-control form-control-sm grams-per-cheop"
value="${ing.grams_per_cheop}" min="0.1" step="0.1">
</td>
<td class="total-grams">${totalGrams.toFixed(1)}</td>
<td class="stock-status">확인중...</td>
<td>
<button type="button" class="btn btn-sm btn-outline-danger remove-compound-ingredient">
<i class="bi bi-x"></i>
</button>
</td>
</tr>
`);
});
// 재고 확인
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 = $(`
<tr>
<td>
<select class="form-control form-control-sm herb-select-compound">
<option value="">약재 선택</option>
</select>
</td>
<td>
<input type="number" class="form-control form-control-sm grams-per-cheop"
min="0.1" step="0.1" placeholder="0.0">
</td>
<td class="total-grams">0.0</td>
<td class="stock-status">-</td>
<td>
<button type="button" class="btn btn-sm btn-outline-danger remove-compound-ingredient">
<i class="bi bi-x"></i>
</button>
</td>
</tr>
`);
$('#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('<tr><td colspan="7" class="text-center">조제 내역이 없습니다.</td></tr>');
}
// 재고 현황 로드
function loadInventory() {
$.get('/api/inventory/summary', function(response) {
if (response.success) {
const tbody = $('#inventoryList');
tbody.empty();
response.data.forEach(item => {
tbody.append(`
<tr>
<td>${item.insurance_code || '-'}</td>
<td>${item.herb_name}</td>
<td>${item.total_quantity.toFixed(1)}</td>
<td>${item.lot_count}</td>
<td>${item.avg_price ? formatCurrency(item.avg_price) : '-'}</td>
<td>${formatCurrency(item.total_value)}</td>
</tr>
`);
});
}
});
}
// 약재 목록 로드
function loadHerbs() {
$.get('/api/herbs', function(response) {
if (response.success) {
const tbody = $('#herbsList');
tbody.empty();
response.data.forEach(herb => {
tbody.append(`
<tr>
<td>${herb.insurance_code || '-'}</td>
<td>${herb.herb_name}</td>
<td>${herb.specification || '-'}</td>
<td>${herb.current_stock ? herb.current_stock.toFixed(1) + 'g' : '0g'}</td>
<td>
<button class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</button>
</td>
</tr>
`);
});
}
});
}
// 입고장 업로드
$('#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('<div class="alert alert-info">업로드 중...</div>');
$.ajax({
url: '/api/upload/purchase',
method: 'POST',
data: formData,
processData: false,
contentType: false,
success: function(response) {
if (response.success) {
$('#uploadResult').html(
`<div class="alert alert-success">
<i class="bi bi-check-circle"></i> ${response.message}
</div>`
);
$('#purchaseUploadForm')[0].reset();
}
},
error: function(xhr) {
$('#uploadResult').html(
`<div class="alert alert-danger">
<i class="bi bi-x-circle"></i> : ${xhr.responseJSON.error}
</div>`
);
}
});
});
// 검색 기능
$('#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('<option value="">환자를 선택하세요</option>');
response.data.forEach(patient => {
select.append(`<option value="${patient.patient_id}">${patient.name} (${patient.phone})</option>`);
});
}
});
}
function loadFormulasForSelect() {
$.get('/api/formulas', function(response) {
if (response.success) {
const select = $('#compoundFormula');
select.empty().append('<option value="">처방을 선택하세요</option>');
response.data.forEach(formula => {
select.append(`<option value="${formula.formula_id}">${formula.formula_name}</option>`);
});
}
});
}
function loadHerbsForSelect(selectElement) {
$.get('/api/herbs', function(response) {
if (response.success) {
selectElement.empty().append('<option value="">약재 선택</option>');
response.data.forEach(herb => {
selectElement.append(`<option value="${herb.herb_item_id}">${herb.herb_name}</option>`);
});
}
});
}
function formatCurrency(amount) {
if (amount === null || amount === undefined) return '0원';
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW'
}).format(amount);
}
});

556
templates/index.html Normal file
View File

@ -0,0 +1,556 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>한약 재고관리 시스템</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.1/font/bootstrap-icons.css" rel="stylesheet">
<style>
body {
font-family: 'Noto Sans KR', sans-serif;
background-color: #f8f9fa;
}
.navbar-brand {
font-weight: bold;
color: #2c3e50 !important;
}
.sidebar {
min-height: calc(100vh - 56px);
background-color: #fff;
box-shadow: 2px 0 5px rgba(0,0,0,0.05);
}
.sidebar .nav-link {
color: #495057;
padding: 12px 20px;
border-left: 3px solid transparent;
transition: all 0.3s;
}
.sidebar .nav-link:hover {
background-color: #f8f9fa;
border-left-color: #007bff;
}
.sidebar .nav-link.active {
background-color: #e7f1ff;
border-left-color: #007bff;
color: #007bff;
font-weight: 500;
}
.content-area {
padding: 20px;
}
.stat-card {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.08);
margin-bottom: 20px;
}
.stat-card h5 {
color: #6c757d;
font-size: 14px;
margin-bottom: 10px;
}
.stat-card .value {
font-size: 24px;
font-weight: bold;
color: #2c3e50;
}
.main-content {
display: none;
}
.main-content.active {
display: block;
}
</style>
</head>
<body>
<!-- Navigation Bar -->
<nav class="navbar navbar-expand-lg navbar-light bg-white shadow-sm">
<div class="container-fluid">
<a class="navbar-brand" href="#">
<i class="bi bi-heart-pulse-fill text-primary"></i> 한약 재고관리 시스템
</a>
<div class="navbar-nav ms-auto">
<span class="navbar-text me-3">
<i class="bi bi-person-circle"></i> 관리자
</span>
<button class="btn btn-outline-secondary btn-sm">
<i class="bi bi-box-arrow-right"></i> 로그아웃
</button>
</div>
</div>
</nav>
<div class="container-fluid">
<div class="row">
<!-- Sidebar -->
<div class="col-md-2 sidebar p-0">
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link active" href="#" data-page="dashboard">
<i class="bi bi-speedometer2"></i> 대시보드
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" data-page="patients">
<i class="bi bi-people"></i> 환자 관리
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" data-page="purchase">
<i class="bi bi-file-earmark-arrow-up"></i> 입고 관리
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" data-page="formulas">
<i class="bi bi-journal-medical"></i> 처방 관리
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" data-page="compound">
<i class="bi bi-prescription2"></i> 조제 관리
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" data-page="inventory">
<i class="bi bi-box-seam"></i> 재고 현황
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" data-page="herbs">
<i class="bi bi-flower1"></i> 약재 관리
</a>
</li>
</ul>
</div>
<!-- Main Content Area -->
<div class="col-md-10 content-area">
<!-- Dashboard Page -->
<div id="dashboard" class="main-content active">
<h3 class="mb-4">대시보드</h3>
<div class="row">
<div class="col-md-3">
<div class="stat-card">
<h5><i class="bi bi-people-fill"></i> 총 환자수</h5>
<div class="value" id="totalPatients">0</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<h5><i class="bi bi-box-seam-fill"></i> 재고 품목</h5>
<div class="value" id="totalHerbs">0</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<h5><i class="bi bi-calendar-check"></i> 오늘 조제</h5>
<div class="value" id="todayCompounds">0</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<h5><i class="bi bi-cash-stack"></i> 재고 자산</h5>
<div class="value" id="inventoryValue">0</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">최근 조제 내역</h5>
</div>
<div class="card-body">
<table class="table table-hover">
<thead>
<tr>
<th>조제일</th>
<th>환자명</th>
<th>처방명</th>
<th>제수</th>
<th>파우치</th>
<th>상태</th>
</tr>
</thead>
<tbody id="recentCompounds">
<!-- Dynamic content -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Patients Page -->
<div id="patients" class="main-content">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3>환자 관리</h3>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#patientModal">
<i class="bi bi-person-plus"></i> 새 환자 등록
</button>
</div>
<div class="card">
<div class="card-body">
<div class="mb-3">
<input type="text" class="form-control" id="patientSearch" placeholder="환자명 또는 전화번호로 검색...">
</div>
<table class="table table-hover">
<thead>
<tr>
<th>환자명</th>
<th>전화번호</th>
<th>성별</th>
<th>생년월일</th>
<th>메모</th>
<th>작업</th>
</tr>
</thead>
<tbody id="patientsList">
<!-- Dynamic content -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Purchase Page -->
<div id="purchase" class="main-content">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3>입고 관리</h3>
</div>
<div class="card">
<div class="card-body">
<h5>Excel 파일 업로드</h5>
<form id="purchaseUploadForm" enctype="multipart/form-data">
<div class="mb-3">
<label for="purchaseFile" class="form-label">입고 Excel 파일 선택</label>
<input type="file" class="form-control" id="purchaseFile" accept=".xlsx,.xls" required>
<div class="form-text">
양식: 제품코드, 업체명, 약재명, 구입일자, 구입량, 구입액, 원산지
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-upload"></i> 업로드 및 처리
</button>
</form>
<div id="uploadResult" class="mt-3"></div>
</div>
</div>
</div>
<!-- Formulas Page -->
<div id="formulas" class="main-content">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3>처방 관리</h3>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#formulaModal">
<i class="bi bi-plus-circle"></i> 새 처방 등록
</button>
</div>
<div class="card">
<div class="card-body">
<table class="table table-hover">
<thead>
<tr>
<th>처방코드</th>
<th>처방명</th>
<th>기본 첩수</th>
<th>기본 파우치</th>
<th>구성 약재</th>
<th>작업</th>
</tr>
</thead>
<tbody id="formulasList">
<!-- Dynamic content -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Compound Page -->
<div id="compound" class="main-content">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3>조제 관리</h3>
<button class="btn btn-primary" id="newCompoundBtn">
<i class="bi bi-plus-circle"></i> 새 조제
</button>
</div>
<div class="card" id="compoundForm" style="display: none;">
<div class="card-body">
<h5>조제 입력</h5>
<form id="compoundEntryForm">
<div class="row">
<div class="col-md-6">
<label class="form-label">환자 선택</label>
<select class="form-control" id="compoundPatient" required>
<option value="">환자를 선택하세요</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">처방 선택</label>
<select class="form-control" id="compoundFormula" required>
<option value="">처방을 선택하세요</option>
</select>
</div>
</div>
<div class="row mt-3">
<div class="col-md-4">
<label class="form-label">제수</label>
<input type="number" class="form-control" id="jeCount" value="1" min="0.5" step="0.5" required>
</div>
<div class="col-md-4">
<label class="form-label">총 첩수</label>
<input type="number" class="form-control" id="cheopTotal" readonly>
</div>
<div class="col-md-4">
<label class="form-label">총 파우치수</label>
<input type="number" class="form-control" id="pouchTotal" required>
</div>
</div>
<div class="mt-3">
<h6>약재 구성 (가감 가능)</h6>
<table class="table table-sm">
<thead>
<tr>
<th>약재명</th>
<th>1첩당 용량(g)</th>
<th>총 용량(g)</th>
<th>재고</th>
<th>작업</th>
</tr>
</thead>
<tbody id="compoundIngredients">
<!-- Dynamic content -->
</tbody>
</table>
<button type="button" class="btn btn-sm btn-outline-primary" id="addIngredientBtn">
<i class="bi bi-plus"></i> 약재 추가
</button>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-success">
<i class="bi bi-check-circle"></i> 조제 실행
</button>
<button type="button" class="btn btn-secondary ms-2" id="cancelCompoundBtn">
취소
</button>
</div>
</form>
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">조제 내역</h5>
</div>
<div class="card-body">
<table class="table table-hover">
<thead>
<tr>
<th>조제일</th>
<th>환자명</th>
<th>처방명</th>
<th>제수</th>
<th>파우치</th>
<th>원가</th>
<th>상태</th>
</tr>
</thead>
<tbody id="compoundsList">
<!-- Dynamic content -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Inventory Page -->
<div id="inventory" class="main-content">
<h3 class="mb-4">재고 현황</h3>
<div class="card">
<div class="card-body">
<div class="mb-3">
<input type="text" class="form-control" id="inventorySearch" placeholder="약재명으로 검색...">
</div>
<table class="table table-hover">
<thead>
<tr>
<th>보험코드</th>
<th>약재명</th>
<th>현재 재고(g)</th>
<th>로트 수</th>
<th>평균 단가</th>
<th>재고 금액</th>
</tr>
</thead>
<tbody id="inventoryList">
<!-- Dynamic content -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Herbs Page -->
<div id="herbs" class="main-content">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3>약재 관리</h3>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#herbModal">
<i class="bi bi-plus-circle"></i> 새 약재 등록
</button>
</div>
<div class="card">
<div class="card-body">
<table class="table table-hover">
<thead>
<tr>
<th>보험코드</th>
<th>약재명</th>
<th>규격</th>
<th>현재 재고</th>
<th>작업</th>
</tr>
</thead>
<tbody id="herbsList">
<!-- Dynamic content -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Patient Modal -->
<div class="modal fade" id="patientModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">환자 등록</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="patientForm">
<div class="mb-3">
<label class="form-label">환자명 *</label>
<input type="text" class="form-control" id="patientName" required>
</div>
<div class="mb-3">
<label class="form-label">전화번호 *</label>
<input type="tel" class="form-control" id="patientPhone" required>
</div>
<div class="mb-3">
<label class="form-label">주민번호</label>
<input type="text" class="form-control" id="patientJumin" placeholder="000000-0000000">
</div>
<div class="mb-3">
<label class="form-label">성별</label>
<select class="form-control" id="patientGender">
<option value="">선택</option>
<option value="M">남성</option>
<option value="F">여성</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">생년월일</label>
<input type="date" class="form-control" id="patientBirth">
</div>
<div class="mb-3">
<label class="form-label">주소</label>
<input type="text" class="form-control" id="patientAddress">
</div>
<div class="mb-3">
<label class="form-label">메모</label>
<textarea class="form-control" id="patientNotes" rows="2"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
<button type="button" class="btn btn-primary" id="savePatientBtn">저장</button>
</div>
</div>
</div>
</div>
<!-- Formula Modal -->
<div class="modal fade" id="formulaModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">처방 등록</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="formulaForm">
<div class="row">
<div class="col-md-6">
<label class="form-label">처방코드</label>
<input type="text" class="form-control" id="formulaCode">
</div>
<div class="col-md-6">
<label class="form-label">처방명 *</label>
<input type="text" class="form-control" id="formulaName" required>
</div>
</div>
<div class="row mt-3">
<div class="col-md-4">
<label class="form-label">처방 유형</label>
<select class="form-control" id="formulaType">
<option value="CUSTOM">약속처방</option>
<option value="INSURANCE">보험처방</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">기본 첩수</label>
<input type="number" class="form-control" id="baseCheop" value="20">
</div>
<div class="col-md-4">
<label class="form-label">기본 파우치수</label>
<input type="number" class="form-control" id="basePouches" value="30">
</div>
</div>
<div class="mt-3">
<label class="form-label">설명</label>
<textarea class="form-control" id="formulaDescription" rows="2"></textarea>
</div>
<div class="mt-3">
<h6>구성 약재</h6>
<table class="table table-sm">
<thead>
<tr>
<th>약재명</th>
<th>1첩당 용량(g)</th>
<th>비고</th>
<th>삭제</th>
</tr>
</thead>
<tbody id="formulaIngredients">
<!-- Dynamic content -->
</tbody>
</table>
<button type="button" class="btn btn-sm btn-outline-primary" id="addFormulaIngredientBtn">
<i class="bi bi-plus"></i> 약재 추가
</button>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
<button type="button" class="btn btn-primary" id="saveFormulaBtn">저장</button>
</div>
</div>
</div>
</div>
<!-- Scripts -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/app.js"></script>
</body>
</html>

Binary file not shown.

294
기획문서.md Normal file
View File

@ -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 로직만 달라져요.)