초기 커밋: 한약 재고관리 시스템
✨ 주요 기능 - 환자 관리: 환자 등록 및 조회 (이름, 전화번호, 주민번호, 성별) - 입고 관리: Excel 파일 업로드로 대량 입고 처리 - 처방 관리: 약속 처방 템플릿 등록 및 관리 - 조제 관리: 처방 기반 조제 및 약재 가감 기능 - 재고 관리: 실시간 재고 현황 및 로트별 관리 🛠️ 기술 스택 - Backend: Flask (Python 웹 프레임워크) - Database: SQLite (경량 관계형 데이터베이스) - Frontend: Bootstrap + jQuery - Excel 처리: pandas + openpyxl 🔧 핵심 개념 - 1제 = 20첩 = 30파우치 (기본값) - FIFO 방식 재고 차감 - 로트별 원산지/단가 관리 - 정확한 조제 원가 계산 📁 프로젝트 구조 - app.py: Flask 백엔드 서버 - database/: 데이터베이스 스키마 및 파일 - templates/: HTML 템플릿 - static/: JavaScript 및 CSS - sample/: 샘플 Excel 파일 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
2fddc89bca
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