Initial commit: Korean Traditional Medicine Inventory Management System
This project is a comprehensive web-based inventory and dispensing management system for Korean traditional medicine clinics and pharmacies. Main Features: - Patient Management: Registration, search, and treatment history tracking - Purchase Management: Bulk import via Excel files, supplier tracking, lot-based inventory - Formula Management: Reusable prescription templates with customizable herb compositions - Dispensing Management: Automated dispensing with FIFO stock deduction and cost calculation - Inventory Tracking: Real-time stock status, lot-level tracking with origin and pricing Technical Stack: - Backend: Flask 3.1.2 (Python Web Framework) - Database: SQLite with comprehensive schema for inventory, patients, formulas, and dispensing - Frontend: Bootstrap 5.1.3 + jQuery for responsive web interface - Excel Processing: pandas + openpyxl for bulk data import Key Concepts: - 1 Je (제) = 20 Cheop (첩) = 30 Pouches (파우치) - Lot Management: Separate tracking by purchase date with origin country and unit price - FIFO Deduction: Oldest inventory consumed first - Cost Tracking: Accurate cost calculation based on lot-level pricing Project Structure: - app.py: Main Flask application with REST API endpoints - database/schema.sql: Complete database schema - templates/: HTML templates for web interface - static/: CSS and JavaScript assets - README.md: Comprehensive documentation in Korean 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
6e523e0cca
68
.gitignore
vendored
Normal file
68
.gitignore
vendored
Normal 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
205
README.md
Normal 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
513
app.py
Normal 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
230
database/schema.sql
Normal 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
309
gitea.md
Normal 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일
|
||||||
|
**마지막 업데이트**: 토큰 및 서버 정보 최신화
|
||||||
|
**참고**: 이 가이드는 재사용 가능하도록 작성되었습니다. 새 프로젝트마다 참고하세요.
|
||||||
|
|
||||||
|
> 💡 **중요**: 액세스 토큰은 보안이 중요한 정보입니다. 공개 저장소에 업로드하지 마세요!
|
||||||
BIN
sample/order_view_20260215154829.xlsx
Normal file
BIN
sample/order_view_20260215154829.xlsx
Normal file
Binary file not shown.
596
static/app.js
Normal file
596
static/app.js
Normal 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
556
templates/index.html
Normal 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>
|
||||||
BIN
uploads/20260215_074050_order_view_20260215154829.xlsx
Normal file
BIN
uploads/20260215_074050_order_view_20260215154829.xlsx
Normal file
Binary file not shown.
294
기획문서.md
Normal file
294
기획문서.md
Normal 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 로직만 달라져요.)
|
||||||
Loading…
Reference in New Issue
Block a user