From a3ff69b67f356f9d3851b6099d233ee895e3f991 Mon Sep 17 00:00:00 2001 From: thug0bin Date: Thu, 26 Feb 2026 19:28:29 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=ED=86=A1=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=EB=A1=9C=EA=B7=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?+=20=ED=98=84=EC=98=81=20=ED=91=9C=EC=8B=9C=20+=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 알림톡 발송 로그: alimtalk_logs SQLite 테이블 + DB 자동 기록 - /admin/alimtalk 페이지: 서버 로그, NHN Cloud 내역 조회, 수동 발송 테스트 - 적립일시 포맷 수정: %Y-%m-%d %H:%M (16자 초과) → %m/%d %H:%M (11자) - POS GUI 현금영수증(현영) 표시: 청록색 볼드 - 결제수납구조.md: CD_SUNAB/PS_main/SALE_MAIN 3테이블 관계 문서 - 실행구조.md: Flask 서버 + Qt GUI 실행 가이드 Co-Authored-By: Claude Opus 4.6 --- backend/app.py | 101 ++++- backend/db/dbsetup.py | 25 +- backend/db/mileage_schema.sql | 18 + backend/gui/pos_sales_gui.py | 22 +- backend/services/nhn_alimtalk.py | 87 +++- backend/templates/admin.html | 9 +- backend/templates/admin_alimtalk.html | 552 ++++++++++++++++++++++++++ backend/templates/kiosk.html | 17 +- docs/결제수납구조.md | 277 ++++++++++--- docs/실행구조.md | 91 +++++ 10 files changed, 1117 insertions(+), 82 deletions(-) create mode 100644 backend/templates/admin_alimtalk.html create mode 100644 docs/실행구조.md diff --git a/backend/app.py b/backend/app.py index bfd28d1..f649ec9 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1885,6 +1885,97 @@ def admin(): recent_tokens=recent_tokens) +# ============================================================================ +# 알림톡 로그 +# ============================================================================ + +@app.route('/admin/alimtalk') +def admin_alimtalk(): + """알림톡 발송 로그 + NHN 발송 내역""" + conn = db_manager.get_sqlite_connection() + cursor = conn.cursor() + + # 로컬 발송 로그 (최근 50건) + cursor.execute(""" + SELECT a.*, u.nickname, u.phone as user_phone + FROM alimtalk_logs a + LEFT JOIN users u ON a.user_id = u.id + ORDER BY a.created_at DESC + LIMIT 50 + """) + local_logs = [dict(row) for row in cursor.fetchall()] + + # 통계 + cursor.execute(""" + SELECT + COUNT(*) as total, + SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count, + SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as fail_count + FROM alimtalk_logs + """) + stats = dict(cursor.fetchone()) + + # 오늘 통계 + cursor.execute(""" + SELECT + COUNT(*) as today_total, + SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as today_success + FROM alimtalk_logs + WHERE date(created_at) = date('now') + """) + today = dict(cursor.fetchone()) + stats.update(today) + + return render_template('admin_alimtalk.html', local_logs=local_logs, stats=stats) + + +@app.route('/api/admin/alimtalk/nhn-history') +def api_admin_alimtalk_nhn_history(): + """NHN Cloud 실제 발송 내역 API""" + from services.nhn_alimtalk import get_nhn_send_history + + date_str = request.args.get('date', datetime.now().strftime('%Y-%m-%d')) + start = f"{date_str} 00:00" + end = f"{date_str} 23:59" + + messages = get_nhn_send_history(start, end) + + result = [] + for m in messages: + result.append({ + 'requestDate': m.get('requestDate', ''), + 'recipientNo': m.get('recipientNo', ''), + 'templateCode': m.get('templateCode', ''), + 'messageStatus': m.get('messageStatus', ''), + 'resultCode': m.get('resultCode', ''), + 'resultMessage': m.get('resultMessage', ''), + 'content': m.get('content', ''), + }) + + return jsonify({'success': True, 'messages': result}) + + +@app.route('/api/admin/alimtalk/test-send', methods=['POST']) +def api_admin_alimtalk_test_send(): + """관리자 수동 알림톡 발송 테스트""" + from services.nhn_alimtalk import send_mileage_claim_alimtalk + + data = request.get_json() + phone = data.get('phone', '').strip().replace('-', '') + name = data.get('name', '테스트') + + if len(phone) < 10: + return jsonify({'success': False, 'message': '올바른 전화번호를 입력해주세요.'}), 400 + + success, msg = send_mileage_claim_alimtalk( + phone, name, 100, 500, + items=[{'name': '테스트 발송', 'qty': 1, 'total': 1000}], + trigger_source='admin_test' + ) + + return jsonify({'success': success, 'message': msg}) + + # ============================================================================ # 키오스크 적립 # ============================================================================ @@ -2094,9 +2185,15 @@ def api_kiosk_claim(): user_row = cursor.fetchone() user_name = user_row['nickname'] if user_row else '고객' - send_mileage_claim_alimtalk(phone, user_name, claimed_points, new_balance, items=sale_items) + logging.warning(f"[알림톡] 발송 시도: phone={phone}, name={user_name}, points={claimed_points}, balance={new_balance}, items={sale_items}") + success, msg = send_mileage_claim_alimtalk( + phone, user_name, claimed_points, new_balance, + items=sale_items, user_id=user_id, + trigger_source='kiosk', transaction_id=transaction_id + ) + logging.warning(f"[알림톡] 발송 결과: success={success}, msg={msg}") except Exception as alimtalk_err: - logging.warning(f"알림톡 발송 실패 (적립은 완료): {alimtalk_err}") + logging.warning(f"[알림톡] 발송 예외 (적립은 완료): {alimtalk_err}") return jsonify({ 'success': True, diff --git a/backend/db/dbsetup.py b/backend/db/dbsetup.py index cb89293..0168166 100644 --- a/backend/db/dbsetup.py +++ b/backend/db/dbsetup.py @@ -237,7 +237,7 @@ class DatabaseManager: print(f"[DB Manager] SQLite 스키마 초기화 완료") def _migrate_sqlite(self): - """기존 DB에 새 컬럼 추가 (마이그레이션)""" + """기존 DB에 새 컬럼/테이블 추가 (마이그레이션)""" cursor = self.sqlite_conn.cursor() cursor.execute("PRAGMA table_info(users)") columns = [row[1] for row in cursor.fetchall()] @@ -246,6 +246,29 @@ class DatabaseManager: self.sqlite_conn.commit() print("[DB Manager] SQLite 마이그레이션: users.birthday 컬럼 추가") + # alimtalk_logs 테이블 생성 + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='alimtalk_logs'") + if not cursor.fetchone(): + cursor.executescript(""" + CREATE TABLE IF NOT EXISTS alimtalk_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + template_code VARCHAR(50) NOT NULL, + recipient_no VARCHAR(20) NOT NULL, + user_id INTEGER, + trigger_source VARCHAR(20) NOT NULL, + template_params TEXT, + success BOOLEAN NOT NULL, + result_message TEXT, + transaction_id VARCHAR(20), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + CREATE INDEX IF NOT EXISTS idx_alimtalk_created ON alimtalk_logs(created_at); + CREATE INDEX IF NOT EXISTS idx_alimtalk_recipient ON alimtalk_logs(recipient_no); + """) + self.sqlite_conn.commit() + print("[DB Manager] SQLite 마이그레이션: alimtalk_logs 테이블 생성") + def test_connection(self, database='PM_BASE'): """연결 테스트""" try: diff --git a/backend/db/mileage_schema.sql b/backend/db/mileage_schema.sql index f42a36a..48415e7 100644 --- a/backend/db/mileage_schema.sql +++ b/backend/db/mileage_schema.sql @@ -80,3 +80,21 @@ CREATE TABLE IF NOT EXISTS pos_customer_links ( ); CREATE INDEX IF NOT EXISTS idx_links_cuscode ON pos_customer_links(cuscode); + +-- 6. 알림톡 발송 로그 테이블 +CREATE TABLE IF NOT EXISTS alimtalk_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + template_code VARCHAR(50) NOT NULL, + recipient_no VARCHAR(20) NOT NULL, + user_id INTEGER, + trigger_source VARCHAR(20) NOT NULL, -- 'kiosk', 'admin', 'manual' 등 + template_params TEXT, -- JSON 문자열 + success BOOLEAN NOT NULL, + result_message TEXT, + transaction_id VARCHAR(20), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE INDEX IF NOT EXISTS idx_alimtalk_created ON alimtalk_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_alimtalk_recipient ON alimtalk_logs(recipient_no); diff --git a/backend/gui/pos_sales_gui.py b/backend/gui/pos_sales_gui.py index 65f262c..99786d5 100644 --- a/backend/gui/pos_sales_gui.py +++ b/backend/gui/pos_sales_gui.py @@ -78,12 +78,16 @@ class SalesQueryThread(QThread): ISNULL(S.card_total, 0) AS card_total, ISNULL(S.cash_total, 0) AS cash_total, ISNULL(M.SL_MY_total, 0) AS total_amount, - ISNULL(M.SL_MY_discount, 0) AS discount + ISNULL(M.SL_MY_discount, 0) AS discount, + S.cash_receipt_mode, + S.cash_receipt_num FROM SALE_MAIN M OUTER APPLY ( SELECT TOP 1 ISNULL(ETC_CARD, 0) + ISNULL(OTC_CARD, 0) AS card_total, - ISNULL(ETC_CASH, 0) + ISNULL(OTC_CASH, 0) AS cash_total + ISNULL(ETC_CASH, 0) + ISNULL(OTC_CASH, 0) AS cash_total, + nCASHINMODE AS cash_receipt_mode, + nAPPROVAL_NUM AS cash_receipt_num FROM CD_SUNAB WHERE PRESERIAL = M.SL_NO_order ) S @@ -96,7 +100,7 @@ class SalesQueryThread(QThread): sales_list = [] for row in rows: - order_no, insert_time, sale_amount, customer, card_total, cash_total, total_amount, discount = row + order_no, insert_time, sale_amount, customer, card_total, cash_total, total_amount, discount, cash_receipt_mode, cash_receipt_num = row # 품목 수 조회 (SALE_SUB) mssql_cursor.execute(""" @@ -136,12 +140,17 @@ class SalesQueryThread(QThread): # 결제수단 판별 card_amt = float(card_total) if card_total else 0.0 cash_amt = float(cash_total) if cash_total else 0.0 + # 현금영수증: nCASHINMODE='1' AND nAPPROVAL_NUM 존재 (mode=2는 카드거래 자동세팅) + has_cash_receipt = ( + str(cash_receipt_mode or '').strip() == '1' + and str(cash_receipt_num or '').strip() != '' + ) if card_amt > 0 and cash_amt > 0: pay_method = '카드+현금' elif card_amt > 0: pay_method = '카드' elif cash_amt > 0: - pay_method = '현금' + pay_method = '현영' if has_cash_receipt else '현금' else: pay_method = '' paid = (card_amt + cash_amt) > 0 @@ -862,6 +871,11 @@ class POSSalesGUI(QMainWindow): pay_item.setTextAlignment(Qt.AlignCenter) if sale['pay_method'] == '카드': pay_item.setForeground(QColor('#1976D2')) + elif sale['pay_method'] == '현영': + pay_item.setForeground(QColor('#00897B')) # 청록 (현금영수증) + f = QFont() + f.setBold(True) + pay_item.setFont(f) elif sale['pay_method'] == '현금': pay_item.setForeground(QColor('#E65100')) elif sale['pay_method']: diff --git a/backend/services/nhn_alimtalk.py b/backend/services/nhn_alimtalk.py index 11e1f6d..f3af174 100644 --- a/backend/services/nhn_alimtalk.py +++ b/backend/services/nhn_alimtalk.py @@ -1,9 +1,10 @@ """ NHN Cloud 알림톡 발송 서비스 -마일리지 적립 완료 등 알림톡 발송 +마일리지 적립 완료 등 알림톡 발송 + SQLite 로깅 """ import os +import json import logging from datetime import datetime, timezone, timedelta @@ -22,6 +23,34 @@ API_BASE = f'https://api-alimtalk.cloud.toast.com/alimtalk/v2.3/appkeys/{APPKEY} KST = timezone(timedelta(hours=9)) +def _log_to_db(template_code, recipient_no, success, result_message, + template_params=None, user_id=None, trigger_source='unknown', + transaction_id=None): + """발송 결과를 SQLite에 저장""" + try: + from db.dbsetup import db_manager + conn = db_manager.get_sqlite_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO alimtalk_logs + (template_code, recipient_no, user_id, trigger_source, + template_params, success, result_message, transaction_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, ( + template_code, + recipient_no, + user_id, + trigger_source, + json.dumps(template_params, ensure_ascii=False) if template_params else None, + success, + result_message, + transaction_id + )) + conn.commit() + except Exception as e: + logger.warning(f"알림톡 로그 DB 저장 실패: {e}") + + def _send_alimtalk(template_code, recipient_no, template_params): """ 알림톡 발송 공통 함수 @@ -82,7 +111,9 @@ def build_item_summary(items): return f"{first} 외 {len(items) - 1}건" -def send_mileage_claim_alimtalk(phone, name, points, balance, items=None): +def send_mileage_claim_alimtalk(phone, name, points, balance, items=None, + user_id=None, trigger_source='kiosk', + transaction_id=None): """ 마일리지 적립 완료 알림톡 발송 @@ -92,11 +123,14 @@ def send_mileage_claim_alimtalk(phone, name, points, balance, items=None): points: 적립 포인트 balance: 적립 후 총 잔액 items: 구매 품목 리스트 [{'name': ..., 'qty': ..., 'total': ...}, ...] + user_id: 사용자 ID (로그용) + trigger_source: 발송 주체 ('kiosk', 'admin', 'manual') + transaction_id: 거래 ID (로그용) Returns: tuple: (성공 여부, 메시지) """ - now_kst = datetime.now(KST).strftime('%Y-%m-%d %H:%M') + now_kst = datetime.now(KST).strftime('%m/%d %H:%M') item_summary = build_item_summary(items) # MILEAGE_CLAIM_V3 (발송 근거 + 구매품목 포함) 우선 시도 @@ -113,15 +147,56 @@ def send_mileage_claim_alimtalk(phone, name, points, balance, items=None): success, msg = _send_alimtalk(template_code, phone, params) if not success: - # V3 실패 시 V2 폴백 (구매품목 변수 없는 버전) + # V3 실패 로그 + _log_to_db(template_code, phone, False, msg, + template_params=params, user_id=user_id, + trigger_source=trigger_source, transaction_id=transaction_id) + + # V2 폴백 template_code = 'MILEAGE_CLAIM_V2' - params_v2 = { + params = { '고객명': name, '적립포인트': f'{points:,}', '총잔액': f'{balance:,}', '적립일시': now_kst, '전화번호': phone } - success, msg = _send_alimtalk(template_code, phone, params_v2) + success, msg = _send_alimtalk(template_code, phone, params) + + # 최종 결과 로그 + _log_to_db(template_code, phone, success, msg, + template_params=params, user_id=user_id, + trigger_source=trigger_source, transaction_id=transaction_id) return (success, msg) + + +def get_nhn_send_history(start_date, end_date, page=1, page_size=15): + """ + NHN Cloud API에서 실제 발송 내역 조회 + + Args: + start_date: 시작일 (YYYY-MM-DD HH:mm) + end_date: 종료일 (YYYY-MM-DD HH:mm) + + Returns: + list: 발송 메시지 목록 + """ + url = (f'{API_BASE}/messages' + f'?startRequestDate={start_date}' + f'&endRequestDate={end_date}' + f'&pageNum={page}&pageSize={page_size}') + headers = { + 'Content-Type': 'application/json;charset=UTF-8', + 'X-Secret-Key': SECRET_KEY + } + + try: + resp = requests.get(url, headers=headers, timeout=10) + data = resp.json() + if data.get('messageSearchResultResponse'): + return data['messageSearchResultResponse'].get('messages', []) + return [] + except Exception as e: + logger.warning(f"NHN 발송내역 조회 실패: {e}") + return [] diff --git a/backend/templates/admin.html b/backend/templates/admin.html index 95c77ea..7aae828 100644 --- a/backend/templates/admin.html +++ b/backend/templates/admin.html @@ -393,9 +393,12 @@
-
-
📊 관리자 대시보드
-
청춘약국 마일리지 관리
+
+
+
📊 관리자 대시보드
+
청춘약국 마일리지 관리
+
+ 📨 알림톡 로그
diff --git a/backend/templates/admin_alimtalk.html b/backend/templates/admin_alimtalk.html new file mode 100644 index 0000000..3ed50b9 --- /dev/null +++ b/backend/templates/admin_alimtalk.html @@ -0,0 +1,552 @@ + + + + + + 알림톡 발송 로그 - 청춘약국 + + + + + + +
+
+
+
알림톡 발송 로그
+
NHN Cloud 카카오 알림톡 발송 기록 및 상태 모니터링
+
+ +
+
+ +
+ +
+
+
전체 발송
+
{{ stats.total or 0 }}
+
+
+
성공
+
{{ stats.success_count or 0 }}
+
+
+
실패
+
{{ stats.fail_count or 0 }}
+
+
+
오늘 발송
+
{{ stats.today_total or 0 }}
+
+
+ + +
+ + + +
+ + +
+
+
+
서버 발송 로그 (최근 50건)
+
+ {% if local_logs %} + + + + + + + + + + + + + + {% for log in local_logs %} + + + + + + + + + + {% endfor %} + +
시간수신번호고객템플릿발송 주체결과상세
{{ log.created_at[:16] if log.created_at else '-' }}{{ log.recipient_no[:3] + '-' + log.recipient_no[3:7] + '-' + log.recipient_no[7:] if log.recipient_no|length >= 11 else log.recipient_no }}{{ log.nickname or '-' }}{{ log.template_code }} + {% if log.trigger_source == 'kiosk' %} + 키오스크 + {% elif log.trigger_source == 'admin_test' %} + 관리자 + {% else %} + {{ log.trigger_source }} + {% endif %} + + {% if log.success %} + 성공 + {% else %} + 실패 + {% endif %} + + {% if log.template_params %} + 변수 보기 +
{{ log.template_params }}
+ {% endif %} + {% if not log.success and log.result_message %} +
{{ log.result_message }}
+ {% endif %} +
+ {% else %} +
+
📭
+
아직 발송 기록이 없습니다
+
+ {% endif %} +
+
+ + +
+
+
+
NHN Cloud 발송 내역
+
+
+
+ + +
+
+
+
+
🔍
+
날짜를 선택하고 조회를 눌러주세요
+
+
+
+
+ + +
+
+
+
수동 알림톡 발송 테스트
+
+
+
+ + +
+
+ + +
+ +
+
+ 안내
+ - MILEAGE_CLAIM_V3 템플릿으로 테스트 메시지를 발송합니다.
+ - 테스트 값: 적립 100P, 잔액 500P, 품목 "테스트 발송"
+ - 발송 결과는 "발송 로그 (서버)" 탭에서 확인 가능합니다. +
+
+
+
+ +
+ + + + diff --git a/backend/templates/kiosk.html b/backend/templates/kiosk.html index 3064670..21a2454 100644 --- a/backend/templates/kiosk.html +++ b/backend/templates/kiosk.html @@ -59,7 +59,8 @@ width: 100%; max-width: 780px; position: relative; - height: 380px; + height: 450px; + overflow: hidden; } .slide { position: absolute; @@ -98,31 +99,31 @@ display: inline-block; padding: 6px 16px; border-radius: 20px; - font-size: 13px; + font-size: 15px; font-weight: 700; letter-spacing: 0.5px; } .slide-title { - font-size: 30px; + font-size: 42px; font-weight: 900; color: #fff; letter-spacing: -0.8px; line-height: 1.3; } .slide-desc { - font-size: 17px; - color: rgba(255,255,255,0.65); + font-size: 23px; + color: rgba(255,255,255,0.7); line-height: 1.6; - max-width: 500px; + max-width: 520px; } .slide-highlight { display: inline-block; - padding: 10px 28px; + padding: 12px 32px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); border-radius: 14px; color: rgba(255,255,255,0.9); - font-size: 15px; + font-size: 19px; font-weight: 600; margin-top: 4px; } diff --git a/docs/결제수납구조.md b/docs/결제수납구조.md index 85ada02..16bd225 100644 --- a/docs/결제수납구조.md +++ b/docs/결제수납구조.md @@ -1,22 +1,217 @@ -# PIT3000 결제/수납/할인 데이터 구조 +# PIT3000 판매/조제/수납 데이터 구조 ## 핵심 테이블 관계 ``` -SALE_MAIN (판매) - └── SL_NO_order (PK, 주문번호) +CD_SUNAB (수납/결제) ─── 모든 거래의 결제 기록 (130건/일 기준) + │ + ├── PS_main (처방접수) ─── 조제 건만 (89건/일 기준) + │ │ 조인: PS_main.PreSerial = CD_SUNAB.PRESERIAL + │ │ 조인: PS_main.Indate = CD_SUNAB.INDATE + │ │ + │ ├── PS_sub_hosp (처방 의약품 상세) + │ └── PS_sub_pharm (조제 의약품 상세) + │ + └── SALE_MAIN (OTC 판매) ─── OTC 직접 판매만 (39건/일 기준) + │ 조인: SALE_MAIN.SL_NO_order = CD_SUNAB.PRESERIAL │ - ├── SALE_SUB (품목 상세) — SL_NO_order로 조인 - │ - └── CD_SUNAB (수납/결제) — CD_SUNAB.PRESERIAL = SALE_MAIN.SL_NO_order + └── SALE_SUB (판매 품목 상세) ─── SL_NO_order로 조인 ``` -**주의**: `CD_SUNAB.PRESERIAL`은 `SALE_MAIN.SL_NO_order`(주문번호)와 매칭됨. -`SALE_MAIN.PRESERIAL`(처방번호)과는 다른 키임. +## 테이블별 역할 + +### 1. CD_SUNAB — 수납/결제 (모든 거래 포함) +- **역할**: 조제 + OTC 모든 거래의 결제/수납 기록 +- **1주문 = 1행** (복수행 없음) +- **키**: `PRESERIAL` (주문번호), `INDATE` (수납일) +- **건수**: 하루 약 130건 (조제 91 + OTC 39) + +| 컬럼 | 설명 | +|------|------| +| `PRESERIAL` | 주문번호 (PS_main.PreSerial 또는 SALE_MAIN.SL_NO_order와 매칭) | +| `INDATE` | 수납일 (YYYYMMDD) | +| `DAY_SERIAL` | 일련번호 | +| `CUSCODE` | 고객코드 | +| `ETC_CARD` | 조제 카드결제 금액 | +| `ETC_CASH` | 조제 현금결제 금액 | +| `ETC_PAPER` | 조제 외상 금액 | +| `OTC_CARD` | 일반약 카드결제 금액 | +| `OTC_CASH` | 일반약 현금결제 금액 | +| `OTC_PAPER` | 일반약 외상 금액 | +| `pAPPROVAL_NUM` | 카드 승인번호 | +| `pMCHDATA` | 카드사 이름 | +| `pCARDINMODE` | 카드 입력방식 (1=IC칩) | +| `pTRDTYPE` | 거래유형 (D1=일반승인) | +| `nCASHINMODE` | 현금영수증 모드 (1=발행, 2=카드거래 자동세팅) | +| `nAPPROVAL_NUM` | 현금영수증 승인번호 | +| `Appr_Gubun` | 승인구분 (1, 2, 9 등) | +| `APPR_DATE` | 승인일시 (YYYYMMDDHHmmss) | +| `DaeRiSunab` | 대리수납 여부 | +| `YOHUDATE` | 요후일 | +| 총 **54개 컬럼** | | + +### 2. PS_main — 처방전 접수 (조제 전용) +- **역할**: 처방전 기반 조제 접수 기록 +- **키**: `PreSerial` (처방번호 = CD_SUNAB.PRESERIAL) +- **건수**: 하루 약 89건 +- **SALE_MAIN에는 없음** — 조제건은 SALE_MAIN을 거치지 않음 + +| 컬럼 | 설명 | +|------|------| +| `PreSerial` | 처방번호 (= CD_SUNAB.PRESERIAL) | +| `Day_Serial` | 일일 접수 순번 (1~89) | +| `Indate` | 접수일 (YYYYMMDD) | +| `CusCode` | 환자 코드 | +| `Paname` | 환자명 | +| `PaNum` | 주민번호 | +| `InsName` | 보험구분 (건강보험, 의료급여 등) | +| `OrderName` | 의료기관명 | +| `Drname` | 처방의사명 | +| `PresTime` | 접수 시간 | +| `PRICE_T` | 총금액 | +| `PRICE_P` | 본인부담금 | +| `PRICE_C` | 보험자부담금 | +| `Pre_State` | 처방 상태 | +| `InsertTime` | 입력 시간 | +| 총 **58개 컬럼** | | + +### 3. SALE_MAIN — OTC 직접 판매 +- **역할**: 일반의약품(OTC) 직접 판매 기록 +- **키**: `SL_NO_order` (주문번호 = CD_SUNAB.PRESERIAL) +- **건수**: 하루 약 39건 +- **조제건은 포함되지 않음** + +| 컬럼 | 설명 | +|------|------| +| `SL_NO_order` | 주문번호 (= CD_SUNAB.PRESERIAL) | +| `SL_DT_appl` | 판매일 (YYYYMMDD) | +| `SL_NM_custom` | 고객명 (대부분 빈값 → `[비고객]`) | +| `SL_MY_total` | 원가 (할인 전) | +| `SL_MY_discount` | 할인 금액 | +| `SL_MY_sale` | 실판매가 (= total - discount) | +| `InsertTime` | 입력 시간 | +| `PRESERIAL` | 처방번호 (OTC는 'V' 고정, 의미 없음) | +| 총 **30개 컬럼** | | --- -## SALE_MAIN 금액 컬럼 +## 데이터 흐름 정리 + +### 조제 (처방전 기반) +``` +처방전 접수 → PS_main 생성 → 조제 → CD_SUNAB 수납 기록 + (ETC_CARD/ETC_CASH에 금액) +``` +- SALE_MAIN에는 **기록되지 않음** +- SALE_SUB에도 품목이 **들어가지 않음** +- 환자명은 PS_main.Paname에 있음 + +### OTC 판매 (직접 판매) +``` +POS에서 품목 선택 → SALE_MAIN + SALE_SUB 생성 → CD_SUNAB 수납 기록 + (OTC_CARD/OTC_CASH에 금액) +``` +- PS_main에는 **기록되지 않음** +- 고객명은 보통 빈값 (`[비고객]`) + +### 조제 + OTC 동시 (하루 약 10건) +``` +처방전 조제 + 일반약 동시 구매 +→ PS_main (조제 부분) +→ SALE_MAIN + SALE_SUB (OTC 부분) +→ CD_SUNAB 1행에 ETC + OTC 금액 모두 기록 +``` + +--- + +## 조인 키 관계 + +``` +CD_SUNAB.PRESERIAL = PS_main.PreSerial (조제건) +CD_SUNAB.PRESERIAL = SALE_MAIN.SL_NO_order (OTC건) +``` + +**주의**: `SALE_MAIN.PRESERIAL`은 OTC에서 항상 `'V'`로, 조인키가 아님. +실제 조인키는 `SALE_MAIN.SL_NO_order`임. + +--- + +## 건수 관계 (2025-02-25 기준) + +| 구분 | 건수 | 설명 | +|------|------|------| +| CD_SUNAB | 130 | 모든 수납 기록 | +| PS_main | 89 | 처방전 접수 (= 조제) | +| SALE_MAIN | 39 | OTC 직접 판매 | +| CD_SUNAB에만 존재 | 91 | 조제건 (SALE_MAIN 없음) | +| PS_main 매칭 | 89 | 91건 중 PS_main과 매칭 | +| 미매칭 | 2 | PS_main 없이 수납만 존재 (미수금 수납 등 특수 케이스) | + +### 130건 = 39 (OTC) + 89 (조제) + 2 (특수) + +--- + +## 조제/OTC 구분 방법 + +CD_SUNAB의 ETC/OTC 금액으로 판별: + +```python +etc_total = ETC_CARD + ETC_CASH # 조제 금액 +otc_total = OTC_CARD + OTC_CASH # 일반약 금액 + +if etc_total > 0 and otc_total > 0: + 구분 = "조제+판매" +elif etc_total > 0: + 구분 = "조제" +elif otc_total > 0: + 구분 = "판매(OTC)" +else: + 구분 = "본인부담금 없음" # 건강보험 전액 부담 +``` + +--- + +## 결제수단 판별 + +```python +card_total = ETC_CARD + OTC_CARD +cash_total = ETC_CASH + OTC_CASH + +# 현금영수증 판별 (nCASHINMODE=2는 카드거래 자동세팅이므로 제외) +has_cash_receipt = (nCASHINMODE == '1' and nAPPROVAL_NUM != '') + +if card_total > 0 and cash_total > 0: + 결제 = "카드+현금" +elif card_total > 0: + 결제 = "카드" +elif cash_total > 0: + 결제 = "현영" if has_cash_receipt else "현금" +else: + 결제 = "-" +``` + +--- + +## GUI 표시 색상 + +### 결제 컬럼 +- **카드**: 파란색 (#1976D2) +- **현영**: 청록색 볼드 (#00897B) — 현금영수증 발행 +- **현금**: 주황색 (#E65100) — 현금영수증 미발행 +- **카드+현금**: 보라색 (#7B1FA2) +- **-**: 회색 (수납 없음) + +### 수납 컬럼 +- **✓**: 녹색 (#4CAF50) +- **-**: 회색 (미수납) + +### 할인 표시 +- 할인 없음: `12,000원` +- 할인 있음: `54,000원 (-6,000)` 주황색 볼드 + 툴팁 + +--- + +## SALE_MAIN 금액 컬럼 상세 | 컬럼 | 설명 | 예시 | |------|------|------| @@ -40,30 +235,7 @@ SL_MY_recive ≈ SL_MY_sale / 1.1 (부가세 제외 금액 추정) --- -## CD_SUNAB 결제수단 컬럼 - -### 금액 기반 결제수단 구분 -단일 구분 컬럼이 없음. **금액이 0보다 크면 해당 결제수단 사용**. - -| 구분 | 카드 | 현금 | 외상 | -|------|------|------|------| -| 조제(ETC, 전문의약품) | `ETC_CARD` | `ETC_CASH` | `ETC_PAPER` | -| OTC(일반의약품) | `OTC_CARD` | `OTC_CASH` | `OTC_PAPER` | - -### 결제수단 판별 로직 -```python -card_total = ETC_CARD + OTC_CARD -cash_total = ETC_CASH + OTC_CASH - -if card_total > 0 and cash_total > 0: - 결제수단 = "카드+현금" -elif card_total > 0: - 결제수단 = "카드" -elif cash_total > 0: - 결제수단 = "현금" -else: - 결제수단 = "-" (미수납 또는 외상) -``` +## CD_SUNAB 카드/현금 상세 컬럼 ### 카드 상세 정보 | 컬럼 | 설명 | 예시 | @@ -79,32 +251,13 @@ else: ### 현금 상세 정보 | 컬럼 | 설명 | 예시 | |------|------|------| -| `nCASHINMODE` | 현금영수증 입력 방식 | 1, 2 (빈값=미발행) | -| `nAPPROVAL_NUM` | 현금영수증 승인번호 | | -| `nCHK_GUBUN` | 현금 체크 구분 | TASA | +| `nCASHINMODE` | 현금영수증 입력 방식 | 1=실제발행, 2=카드거래 자동세팅 | +| `nAPPROVAL_NUM` | 현금영수증 승인번호 | 116624870 | +| `nCHK_GUBUN` | 현금 체크 구분 | KOV, TASA | --- -## GUI 표시 방식 - -### 결제 컬럼 -- **카드**: 파란색 (#1976D2) -- **현금**: 주황색 (#E65100) -- **카드+현금**: 보라색 (#7B1FA2) -- **-**: 회색 (수납 정보 없음) - -### 수납 컬럼 -- **✓**: 녹색 (card + cash > 0) -- **-**: 회색 (미수납) - -### 할인 표시 -- 할인 없는 건: `12,000원` (기본) -- 할인 있는 건: `54,000원 (-6,000)` 주황색 볼드 -- 마우스 툴팁: 원가 / 할인 / 결제 상세 - ---- - -## SQL 쿼리 (GUI에서 사용) +## SQL 쿼리 (현재 GUI에서 사용) ```sql SELECT @@ -115,12 +268,16 @@ SELECT ISNULL(S.card_total, 0) AS card_total, ISNULL(S.cash_total, 0) AS cash_total, ISNULL(M.SL_MY_total, 0) AS total_amount, - ISNULL(M.SL_MY_discount, 0) AS discount + ISNULL(M.SL_MY_discount, 0) AS discount, + S.cash_receipt_mode, + S.cash_receipt_num FROM SALE_MAIN M OUTER APPLY ( SELECT TOP 1 ISNULL(ETC_CARD, 0) + ISNULL(OTC_CARD, 0) AS card_total, - ISNULL(ETC_CASH, 0) + ISNULL(OTC_CASH, 0) AS cash_total + ISNULL(ETC_CASH, 0) + ISNULL(OTC_CASH, 0) AS cash_total, + nCASHINMODE AS cash_receipt_mode, + nAPPROVAL_NUM AS cash_receipt_num FROM CD_SUNAB WHERE PRESERIAL = M.SL_NO_order ) S @@ -128,6 +285,10 @@ WHERE M.SL_DT_appl = ? ORDER BY M.InsertTime DESC ``` +**한계**: SALE_MAIN 기준이므로 OTC 판매(39건)만 표시됨. +조제건(~89건)은 표시되지 않음. 조제건까지 보려면 CD_SUNAB을 +기본 테이블로 사용하거나 PS_main과 조인하는 쿼리 재설계 필요. + --- ## 카드사 분포 (전체 데이터 기준) diff --git a/docs/실행구조.md b/docs/실행구조.md new file mode 100644 index 0000000..8c4b0fa --- /dev/null +++ b/docs/실행구조.md @@ -0,0 +1,91 @@ +# 청춘약국 마일리지 시스템 — 실행 구조 + +## 실행해야 할 프로그램 (2개) + +### 1. Flask 서버 (`backend/app.py`) +```bash +cd c:\Users\청춘약국\source\pharmacy-pos-qr-system +python backend/app.py +``` +- **포트**: 7001 (0.0.0.0) +- **외부 도메인**: `mile.0bin.in` (→ 내부 7001 포트로 프록시) +- **역할**: 웹 서비스 전체 담당 + +#### 제공하는 페이지/API +| 경로 | 설명 | +|------|------| +| `/` | 메인 페이지 | +| `/signup` | 회원가입 | +| `/claim` | QR 적립 (폰번호 방식) | +| `/claim/kakao/start` | QR 적립 (카카오 로그인) | +| `/my-page` | 마이페이지 | +| `/kiosk` | **키오스크 대기 화면** (약국 내 태블릿) | +| `/admin` | 관리자 페이지 | +| `/admin/transaction/` | 거래 상세 | +| `/admin/user/` | 회원 상세 | +| `/admin/search/user` | 회원 검색 | +| `/admin/search/product` | 상품 검색 | +| `/api/kiosk/trigger` | 키오스크 QR 트리거 (POST) | +| `/api/kiosk/current` | 키오스크 현재 상태 | +| `/api/kiosk/claim` | 키오스크 적립 처리 (POST) | + +#### 사용하는 DB +- **SQLite** (`backend/db/mileage.db`) — 회원, 적립, QR 토큰 +- **MSSQL** (`192.168.0.4\PM2014`, DB: `PM_PRES`) — POS 판매 데이터 (읽기 전용) + +--- + +### 2. Qt POS GUI (`backend/gui/pos_sales_gui.py`) +```bash +cd c:\Users\청춘약국\source\pharmacy-pos-qr-system +python backend/gui/pos_sales_gui.py +``` +- **역할**: POS 판매 내역 조회 + QR 라벨 발행 +- **PyQt5 기반** 데스크톱 앱 +- Flask 서버와 **독립적으로 실행** (별도 프로세스) + +#### 주요 기능 +- 일자별 판매 내역 조회 (SALE_MAIN + CD_SUNAB) +- 결제수단 표시 (카드/현금/현영) +- 할인 표시 +- QR 라벨 프린터 출력 (Zebra / POS 프린터) +- 적립자 클릭 → 회원 적립 내역 팝업 + +#### 사용하는 DB +- **MSSQL** — SALE_MAIN, SALE_SUB, CD_SUNAB 조회 +- **SQLite** — claim_tokens, users 조회 (적립 정보) + +--- + +## 실행 순서 + +``` +1. Flask 서버 먼저 실행 (키오스크, 웹 서비스 제공) +2. Qt POS GUI 실행 (판매 내역 조회, QR 발행) +``` + +순서는 상관없으나, Flask가 먼저 떠 있어야 키오스크(`mile.0bin.in/kiosk`)와 +웹 서비스(`mile.0bin.in`)가 접속 가능. + +--- + +## 프로세스 확인 + +```bash +# 실행 중인 Python 프로세스 확인 +tasklist /FI "IMAGENAME eq python.exe" + +# 정상 상태: Python 프로세스 3개 +# - Flask 서버 (메인) +# - Flask 서버 (debug reloader 워커) +# - Qt POS GUI +``` + +--- + +## 주의사항 + +- `taskkill /F /IM python.exe` 사용 시 **Flask + GUI 모두 종료됨** +- GUI만 재시작하려면 해당 PID만 종료할 것 +- Flask 서버는 `debug=True`로 실행되어 코드 변경 시 자동 리로드 +- Python 경로: `C:\Users\청춘약국\AppData\Local\Programs\Python\Python312\python.exe`