feat(cupang): 쿠팡 밀크런 모듈 추가 (출고 달력/박스계산/입고센터)

- app/modules/cupang: router/db/store/itemcode + 템플릿 5종
- cupang_db 전용(JSON 폴백 없음). CUPANG_DB_URL 미설정 시 안내 페이지
- 월간 달력 + 출고 묶음(헤더+라인), 박스 입수량 자동계산(서버 재계산)
- 입고센터 관리(사용중 soft delete), 박스규칙 upsert, 엑셀 내보내기
- 상품 검색은 itemcode_db 읽기 전용(미설정 시 수동 입력)
- MODULE_KEYS/메뉴/아이콘/라벨 동기화, scripts/sql/cupang_db_init.sql(멱등)
- app/data/ gitignore 추가(PII)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 20:47:32 +09:00
parent 6918426264
commit 9e707be5ac
21 changed files with 2586 additions and 1 deletions
+17
View File
@@ -20,3 +20,20 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/
# 설정하면 PostgreSQL 사용, 미설정 시 DATA_DIR/expense.json 사용. # 설정하면 PostgreSQL 사용, 미설정 시 DATA_DIR/expense.json 사용.
# DB/역할 생성: scripts/sql/expense_db_init.sql 참고. # DB/역할 생성: scripts/sql/expense_db_init.sql 참고.
# EXPENSE_DB_URL=postgresql://expense_app:replace-me@postgres-db:5432/expense_db # EXPENSE_DB_URL=postgresql://expense_app:replace-me@postgres-db:5432/expense_db
# ─── 쿠팡 밀크런 모듈 (cupang_db) ───
# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음).
# DB/역할/스키마/센터 seed 생성: scripts/sql/cupang_db_init.sql 참고.
# CUPANG_DB_URL=postgresql://cupang_app:replace-me@postgres-db:5432/cupang_db
# ─── 상품 검색 (itemcode_db 읽기 전용) ───
# cupang 출고 라인의 제품코드/제품명을 itemcode_db 에서 검색한다(읽기만).
# 미설정 시 검색 비활성 → 폼에서 수동 입력. itemcode_db 의 실제 테이블/컬럼은
# 운영 서버에서 `\dt` / `\d <table>` 로 확인 후 아래 값을 채운다.
# ITEMCODE_DB_URL=postgresql://itemcode_ro:replace-me@postgres-db:5432/itemcode_db
# ITEMCODE_TABLE=products
# ITEMCODE_CODE_COL=product_code
# ITEMCODE_NAME_COL=product_name
# ITEMCODE_TYPE_COL=
# 낱개/세트가 별도 테이블이면 아래 UNION SQL 직접 지정(code/name/type 별칭, %(q)s, %(limit)s 사용):
# ITEMCODE_SEARCH_SQL=
+3
View File
@@ -2,6 +2,9 @@
.env .env
.env.local .env.local
# 런타임 데이터 (사용자 이메일/경비 등 PII — 커밋 금지. 운영은 DATA_DIR 볼륨)
app/data/
# 로컬 전용 스크립트 (자격증명 포함 가능) # 로컬 전용 스크립트 (자격증명 포함 가능)
push-gitea.bat push-gitea.bat
run-local.bat run-local.bat
+2
View File
@@ -30,6 +30,8 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아
- CS관리 - CS관리
- 반품관리 - 반품관리
- 외부 쇼핑몰 API 연동 (카페24, 네이버 스마트스토어, 사방넷 등) - 외부 쇼핑몰 API 연동 (카페24, 네이버 스마트스토어, 사방넷 등)
- 개인경비 (`app/modules/expense/`, `expense_db`)
- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 입수량 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용
상세는 `docs/PROJECT_OVERVIEW.md`. 상세는 `docs/PROJECT_OVERVIEW.md`.
+20
View File
@@ -13,6 +13,8 @@ from jinja2 import ChoiceLoader, FileSystemLoader
from pydantic import BaseModel from pydantic import BaseModel
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from .modules.cupang import build_cupang_store, build_itemcode_reader
from .modules.cupang import router as cupang_router
from .modules.expense import build_expense_store from .modules.expense import build_expense_store
from .modules.expense import router as expense_router from .modules.expense import router as expense_router
from .store import ( from .store import (
@@ -31,6 +33,7 @@ MODULE_LABELS: dict[str, str] = {
"order": "Order", "order": "Order",
"expense": "개인경비", "expense": "개인경비",
"vacation": "휴가", "vacation": "휴가",
"cupang": "쿠팡 밀크런",
"expense_approver": "개인경비", "expense_approver": "개인경비",
"vacation_approver": "휴가", "vacation_approver": "휴가",
} }
@@ -101,6 +104,7 @@ app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="stat
# 신규 모듈 추가 시 아래 리스트에 `BASE_DIR / "modules" / "<name>" / "templates"` 만 추가. # 신규 모듈 추가 시 아래 리스트에 `BASE_DIR / "modules" / "<name>" / "templates"` 만 추가.
_MODULE_TEMPLATE_DIRS = [ _MODULE_TEMPLATE_DIRS = [
BASE_DIR / "modules" / "expense" / "templates", BASE_DIR / "modules" / "expense" / "templates",
BASE_DIR / "modules" / "cupang" / "templates",
] ]
templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
templates.env.loader = ChoiceLoader( templates.env.loader = ChoiceLoader(
@@ -120,9 +124,14 @@ app.state.expense_store = build_expense_store(
dsn=env("EXPENSE_DB_URL") or None, dsn=env("EXPENSE_DB_URL") or None,
json_path=DATA_DIR / "expense.json", json_path=DATA_DIR / "expense.json",
) )
# 쿠팡 밀크런: CUPANG_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
# 상품 검색은 itemcode_db 읽기 전용(미설정 시 수동 입력 폴백).
app.state.cupang_store = build_cupang_store(dsn=env("CUPANG_DB_URL") or None)
app.state.itemcode_reader = build_itemcode_reader()
# 모듈 라우터 등록 — 신규 모듈 추가 시 여기 한 줄. # 모듈 라우터 등록 — 신규 모듈 추가 시 여기 한 줄.
app.include_router(expense_router) app.include_router(expense_router)
app.include_router(cupang_router)
def public_url_for(request: Request, route_name: str) -> str: def public_url_for(request: Request, route_name: str) -> str:
@@ -245,6 +254,16 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
"status": "ready", "status": "ready",
"category": "관리", "category": "관리",
}, },
{
"key": "cupang",
"title": "쿠팡 밀크런",
"subtitle": "Coupang Milk-run",
"description": "쿠팡 밀크런 출고 일정·박스 계산·입고센터를 달력에서 관리합니다.",
"url": "/cupang/",
"health_url": "/cupang/health",
"status": "ready",
"category": "운영",
},
{ {
"key": "vacation", "key": "vacation",
"title": "휴가", "title": "휴가",
@@ -270,6 +289,7 @@ def _icon_svg(name: str) -> str:
"vacation": '<path d="M8 2v4"/><path d="M16 2v4"/><rect x="3" y="6" width="18" height="15" rx="2"/><path d="M3 11h18"/>', "vacation": '<path d="M8 2v4"/><path d="M16 2v4"/><rect x="3" y="6" width="18" height="15" rx="2"/><path d="M3 11h18"/>',
"corm": '<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z"/>', "corm": '<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z"/>',
"order": '<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>', "order": '<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>',
"cupang": '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/>',
"modules": '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>', "modules": '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
} }
body = paths.get(name, paths["modules"]) body = paths.get(name, paths["modules"])
+46
View File
@@ -0,0 +1,46 @@
"""쿠팡 밀크런(cupang) 모듈.
라우터/저장소/템플릿을 한 디렉토리에서 관리한다.
- 라우터: `router.py` (FastAPI APIRouter, prefix=/cupang)
- 저장소: `db.py` (cupang_db / PostgreSQL 전용) + `store.py` (상수/계산)
- 상품검색: `itemcode.py` (itemcode_db 읽기 전용)
- 템플릿: `templates/cupang/`
데이터 저장은 cupang_db 전용이다. CUPANG_DB_URL 미설정 시 build_cupang_store 는
None 을 반환하고, 라우터가 "설정 필요" 안내 페이지를 보여준다(앱은 죽지 않음).
"""
from typing import Any
from .router import router
from .store import DEFAULT_CENTERS, SHEET_COLUMNS, SHIP_METHODS, STATUSES, compute_boxes
__all__ = [
"router",
"STATUSES",
"SHIP_METHODS",
"SHEET_COLUMNS",
"DEFAULT_CENTERS",
"compute_boxes",
"build_cupang_store",
"build_itemcode_reader",
]
def build_cupang_store(*, dsn: str | None) -> Any:
"""CUPANG_DB_URL 이 있으면 CupangDBStore, 없으면 None.
JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 페이지 표시.
"""
if not dsn:
return None
from .db import CupangDBStore # 지연 import (개발 환경 deps 없을 수 있음)
return CupangDBStore(dsn)
def build_itemcode_reader() -> Any:
"""itemcode_db 읽기 전용 상품 검색 리더. 설정 없으면 비활성(enabled=False)."""
from .itemcode import ItemcodeReader # 지연 import
return ItemcodeReader()
+584
View File
@@ -0,0 +1,584 @@
"""cupang_db PostgreSQL 저장소.
- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — expense 모듈과 동일 패턴.
- 연결 정보: 환경변수 `CUPANG_DB_URL`
(예: postgresql://cupang_app:<pwd>@postgres-db:5432/cupang_db)
- 스키마(테이블/인덱스/트리거/seed)는 앱이 만들지 않는다.
`scripts/sql/cupang_db_init.sql` 을 superuser 가 사전 적용한다.
앱 계정(cupang_app)은 SELECT/INSERT/UPDATE/DELETE 권한만 받는다.
- 연결 풀은 lazy open — 부팅 시 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
박스 수 계산은 서버에서 `store.compute_boxes` 로 재계산하여 저장한다.
클라이언트가 보낸 박스 수는 신뢰하지 않는다.
"""
from __future__ import annotations
from datetime import date, datetime, timezone
from typing import Any
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from .store import STATUSES, compute_boxes
class CupangDBStore:
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5):
self._pool = ConnectionPool(
conninfo=dsn,
min_size=min_size,
max_size=max_size,
kwargs={"row_factory": dict_row, "autocommit": True},
open=False,
)
self._pool.open(wait=False)
def close(self) -> None:
self._pool.close()
# ════════════════════════════════════════════════════════════
# 입고센터 (cupang_centers)
# ════════════════════════════════════════════════════════════
def list_centers(self, *, include_inactive: bool = False) -> list[dict[str, Any]]:
where = "" if include_inactive else "WHERE active = TRUE"
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM cupang_centers {where} "
"ORDER BY active DESC, sort_order ASC, name ASC"
).fetchall()
return [self._center_serialize(r) for r in rows]
def get_center(self, *, center_id: int) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM cupang_centers WHERE id = %s", (center_id,)
).fetchone()
return self._center_serialize(row) if row else None
def create_center(self, *, name: str, sort_order: int = 0) -> dict[str, Any]:
name = (name or "").strip()
if not name:
raise ValueError("센터명 필수")
with self._pool.connection() as conn:
row = conn.execute(
"""
INSERT INTO cupang_centers (name, sort_order)
VALUES (%s, %s)
ON CONFLICT (name) DO UPDATE
SET active = TRUE, sort_order = EXCLUDED.sort_order
RETURNING *
""",
(name, sort_order),
).fetchone()
return self._center_serialize(row)
def update_center(
self,
*,
center_id: int,
name: str | None = None,
active: bool | None = None,
sort_order: int | None = None,
) -> dict[str, Any]:
sets: list[str] = []
params: list[Any] = []
if name is not None:
n = name.strip()
if not n:
raise ValueError("센터명은 비울 수 없습니다.")
sets.append("name = %s")
params.append(n)
if active is not None:
sets.append("active = %s")
params.append(bool(active))
if sort_order is not None:
sets.append("sort_order = %s")
params.append(int(sort_order))
if not sets:
raise ValueError("변경할 값이 없습니다.")
params.append(center_id)
with self._pool.connection() as conn:
row = conn.execute(
f"UPDATE cupang_centers SET {', '.join(sets)} "
"WHERE id = %s RETURNING *",
params,
).fetchone()
if not row:
raise KeyError(center_id)
return self._center_serialize(row)
def center_in_use(self, *, center_id: int) -> bool:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT 1 FROM cupang_shipments WHERE center_id = %s LIMIT 1",
(center_id,),
).fetchone()
return row is not None
def delete_center(self, *, center_id: int) -> dict[str, Any]:
"""사용 중이면 hard delete 하지 않고 active=false 로 비활성화한다.
반환: {"deleted": bool, "deactivated": bool}
"""
if self.center_in_use(center_id=center_id):
self.update_center(center_id=center_id, active=False)
return {"deleted": False, "deactivated": True}
with self._pool.connection() as conn:
cur = conn.execute(
"DELETE FROM cupang_centers WHERE id = %s", (center_id,)
)
if cur.rowcount == 0:
raise KeyError(center_id)
return {"deleted": True, "deactivated": False}
# ════════════════════════════════════════════════════════════
# 박스 입수량 규칙 (cupang_box_rules)
# ════════════════════════════════════════════════════════════
def list_box_rules(self, *, include_inactive: bool = False) -> list[dict[str, Any]]:
where = "" if include_inactive else "WHERE active = TRUE"
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM cupang_box_rules {where} "
"ORDER BY product_code ASC"
).fetchall()
return [self._rule_serialize(r) for r in rows]
def get_box_rule(self, *, product_code: str) -> dict[str, Any] | None:
code = (product_code or "").strip()
if not code:
return None
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM cupang_box_rules WHERE product_code = %s AND active = TRUE",
(code,),
).fetchone()
return self._rule_serialize(row) if row else None
def upsert_box_rule(
self,
*,
product_code: str,
units_per_box: int,
product_name_snapshot: str = "",
box_name: str = "쿠팡박스",
memo: str = "",
) -> dict[str, Any]:
code = (product_code or "").strip()
if not code:
raise ValueError("제품코드 필수")
upb = int(units_per_box)
if upb <= 0:
raise ValueError("박스당 입수량은 1 이상이어야 합니다.")
with self._pool.connection() as conn:
row = conn.execute(
"""
INSERT INTO cupang_box_rules
(product_code, product_name_snapshot, box_name, units_per_box, memo)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (product_code) DO UPDATE
SET product_name_snapshot = EXCLUDED.product_name_snapshot,
box_name = EXCLUDED.box_name,
units_per_box = EXCLUDED.units_per_box,
memo = EXCLUDED.memo,
active = TRUE
RETURNING *
""",
(code, product_name_snapshot.strip(), (box_name or "쿠팡박스").strip(), upb, memo.strip()),
).fetchone()
return self._rule_serialize(row)
def deactivate_box_rule(self, *, rule_id: int) -> None:
with self._pool.connection() as conn:
cur = conn.execute(
"UPDATE cupang_box_rules SET active = FALSE WHERE id = %s",
(rule_id,),
)
if cur.rowcount == 0:
raise KeyError(rule_id)
def box_rule_map(self) -> dict[str, dict[str, Any]]:
"""product_code → rule. 폼/계산에서 빠르게 조회하기 위한 dict."""
return {r["product_code"]: r for r in self.list_box_rules()}
# ════════════════════════════════════════════════════════════
# 출고 묶음 (cupang_shipments + cupang_shipment_lines)
# ════════════════════════════════════════════════════════════
def list_shipments(
self,
*,
year: int | None = None,
month: int | None = None,
date_from: str | None = None,
date_to: str | None = None,
) -> list[dict[str, Any]]:
"""헤더 목록(라인 제외). 달력/리스트 표시에 사용.
year+month 가 주어지면 작성일/출고일/센터입고일 중 하나라도 해당 월에
걸치는 묶음을 모두 포함한다(달력 표시용).
"""
clauses: list[str] = []
params: list[Any] = []
if year and month:
clauses.append(
"(date_trunc('month', document_date) = make_date(%s, %s, 1)"
" OR date_trunc('month', ship_date) = make_date(%s, %s, 1)"
" OR date_trunc('month', center_arrival_date) = make_date(%s, %s, 1))"
)
params.extend([year, month, year, month, year, month])
if date_from:
clauses.append("ship_date >= %s")
params.append(date_from)
if date_to:
clauses.append("ship_date <= %s")
params.append(date_to)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM cupang_shipments {where} "
"ORDER BY ship_date ASC, id ASC",
params,
).fetchall()
return [self._shipment_serialize(r) for r in rows]
def get_shipment(self, *, shipment_id: int, with_lines: bool = True) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM cupang_shipments WHERE id = %s", (shipment_id,)
).fetchone()
if not row:
return None
head = self._shipment_serialize(row)
if with_lines:
line_rows = conn.execute(
"SELECT * FROM cupang_shipment_lines WHERE shipment_id = %s "
"ORDER BY line_no ASC",
(shipment_id,),
).fetchall()
head["lines"] = [self._line_serialize(r) for r in line_rows]
return head
def create_shipment(
self, *, created_by: str, header: dict[str, Any], lines: list[dict[str, Any]]
) -> dict[str, Any]:
h = self._normalize_header(header)
norm_lines = self._normalize_lines(lines)
if not norm_lines:
raise ValueError("품목 라인이 최소 1개 필요합니다.")
with self._pool.connection() as conn:
with conn.transaction():
row = conn.execute(
"""
INSERT INTO cupang_shipments
(document_no, created_by, document_date, ship_date,
center_arrival_date, center_id, center_name_snapshot,
ship_method, outbound_summary, worker, status, memo)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""",
(
h["document_no"] or None,
created_by.lower().strip(),
h["document_date"],
h["ship_date"],
h["center_arrival_date"],
h["center_id"],
h["center_name_snapshot"],
h["ship_method"],
h["outbound_summary"],
h["worker"],
h["status"],
h["memo"],
),
).fetchone()
shipment_id = row["id"]
self._insert_lines(conn, shipment_id, norm_lines)
return self.get_shipment(shipment_id=shipment_id)
def update_shipment(
self, *, shipment_id: int, header: dict[str, Any], lines: list[dict[str, Any]]
) -> dict[str, Any]:
h = self._normalize_header(header)
norm_lines = self._normalize_lines(lines)
if not norm_lines:
raise ValueError("품목 라인이 최소 1개 필요합니다.")
with self._pool.connection() as conn:
with conn.transaction():
row = conn.execute(
"""
UPDATE cupang_shipments
SET document_no = %s, document_date = %s, ship_date = %s,
center_arrival_date = %s, center_id = %s,
center_name_snapshot = %s, ship_method = %s,
outbound_summary = %s, worker = %s, memo = %s
WHERE id = %s
RETURNING *
""",
(
h["document_no"] or None,
h["document_date"],
h["ship_date"],
h["center_arrival_date"],
h["center_id"],
h["center_name_snapshot"],
h["ship_method"],
h["outbound_summary"],
h["worker"],
h["memo"],
shipment_id,
),
).fetchone()
if not row:
raise KeyError(shipment_id)
# 라인 전체 교체 (CASCADE 아님 — 명시적 삭제 후 재삽입)
conn.execute(
"DELETE FROM cupang_shipment_lines WHERE shipment_id = %s",
(shipment_id,),
)
self._insert_lines(conn, shipment_id, norm_lines)
return self.get_shipment(shipment_id=shipment_id)
def set_status(self, *, shipment_id: int, status: str) -> dict[str, Any]:
if status not in STATUSES:
raise ValueError(f"허용되지 않는 상태: {status}")
with self._pool.connection() as conn:
row = conn.execute(
"UPDATE cupang_shipments SET status = %s WHERE id = %s RETURNING *",
(status, shipment_id),
).fetchone()
if not row:
raise KeyError(shipment_id)
return self._shipment_serialize(row)
def soft_delete(self, *, shipment_id: int) -> dict[str, Any]:
"""운영 안전을 위한 기본 삭제 — status='취소'."""
return self.set_status(shipment_id=shipment_id, status="취소")
def hard_delete(self, *, shipment_id: int) -> None:
"""완전 삭제(라인은 ON DELETE CASCADE). 취소 처리로 충분하므로 기본 미사용."""
with self._pool.connection() as conn:
cur = conn.execute(
"DELETE FROM cupang_shipments WHERE id = %s", (shipment_id,)
)
if cur.rowcount == 0:
raise KeyError(shipment_id)
def _insert_lines(self, conn: Any, shipment_id: int, lines: list[dict[str, Any]]) -> None:
for idx, ln in enumerate(lines, start=1):
calc = compute_boxes(ln["quantity"], ln.get("units_per_box"))
conn.execute(
"""
INSERT INTO cupang_shipment_lines
(shipment_id, line_no, product_code, product_name_snapshot,
quantity, box_rule_id, units_per_box, calculated_boxes,
remainder_units, manual_box_text, memo)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""",
(
shipment_id,
idx,
ln["product_code"],
ln["product_name_snapshot"],
ln["quantity"],
ln.get("box_rule_id"),
calc["units_per_box"],
calc["required_boxes"],
calc["remainder_units"],
ln.get("manual_box_text", ""),
ln.get("memo", ""),
),
)
# ════════════════════════════════════════════════════════════
# 달력 집계
# ════════════════════════════════════════════════════════════
def calendar_counts(self, *, year: int, month: int) -> dict[str, dict[str, int]]:
"""해당 월의 날짜별 작성/출고/입고 건수.
반환: { "YYYY-MM-DD": {"document": n, "ship": n, "arrival": n} }
취소 상태는 제외한다.
"""
first = (year, month)
out: dict[str, dict[str, int]] = {}
def _accumulate(rows: list[dict[str, Any]], key: str) -> None:
for r in rows:
d = r["d"]
ds = d.isoformat() if isinstance(d, date) else str(d)
out.setdefault(ds, {"document": 0, "ship": 0, "arrival": 0})
out[ds][key] = int(r["c"])
with self._pool.connection() as conn:
doc = conn.execute(
"SELECT document_date AS d, COUNT(*) AS c FROM cupang_shipments "
"WHERE status <> '취소' AND date_trunc('month', document_date) = make_date(%s,%s,1) "
"GROUP BY 1",
first,
).fetchall()
ship = conn.execute(
"SELECT ship_date AS d, COUNT(*) AS c FROM cupang_shipments "
"WHERE status <> '취소' AND date_trunc('month', ship_date) = make_date(%s,%s,1) "
"GROUP BY 1",
first,
).fetchall()
arr = conn.execute(
"SELECT center_arrival_date AS d, COUNT(*) AS c FROM cupang_shipments "
"WHERE status <> '취소' AND date_trunc('month', center_arrival_date) = make_date(%s,%s,1) "
"GROUP BY 1",
first,
).fetchall()
_accumulate(doc, "document")
_accumulate(ship, "ship")
_accumulate(arr, "arrival")
return out
def shipments_for_export(
self, *, year: int | None = None, month: int | None = None, shipment_id: int | None = None
) -> list[dict[str, Any]]:
"""엑셀 내보내기용 — 헤더+라인 평탄화 전 단계. 라인 포함 묶음 리스트 반환."""
if shipment_id is not None:
s = self.get_shipment(shipment_id=shipment_id)
return [s] if s else []
heads = self.list_shipments(year=year, month=month)
result = []
for h in heads:
full = self.get_shipment(shipment_id=h["id"])
if full:
result.append(full)
return result
# ════════════════════════════════════════════════════════════
# 정규화 / 직렬화 helpers
# ════════════════════════════════════════════════════════════
@staticmethod
def _normalize_header(header: dict[str, Any]) -> dict[str, Any]:
def _d(key: str) -> str:
v = str(header.get(key) or "").strip()
return v
document_date = _d("document_date")
ship_date = _d("ship_date")
center_arrival_date = _d("center_arrival_date")
if not document_date or not ship_date or not center_arrival_date:
raise ValueError("작성일/출고일/센터입고일은 필수입니다.")
status = str(header.get("status") or "작성중").strip()
if status not in STATUSES:
status = "작성중"
center_id_raw = header.get("center_id")
try:
center_id = int(center_id_raw) if center_id_raw not in (None, "", "0") else None
except (TypeError, ValueError):
center_id = None
return {
"document_no": str(header.get("document_no") or "").strip(),
"document_date": document_date,
"ship_date": ship_date,
"center_arrival_date": center_arrival_date,
"center_id": center_id,
"center_name_snapshot": str(header.get("center_name_snapshot") or "").strip(),
"ship_method": str(header.get("ship_method") or "택배").strip() or "택배",
"outbound_summary": str(header.get("outbound_summary") or "").strip(),
"worker": str(header.get("worker") or "").strip(),
"status": status,
"memo": str(header.get("memo") or "").strip(),
}
@staticmethod
def _normalize_lines(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for raw in lines or []:
code = str(raw.get("product_code") or "").strip()
name = str(raw.get("product_name_snapshot") or raw.get("product_name") or "").strip()
try:
qty = int(raw.get("quantity") or 0)
except (TypeError, ValueError):
qty = 0
if not code or qty <= 0:
continue # 빈 라인 스킵
upb_raw = raw.get("units_per_box")
try:
upb = int(upb_raw) if upb_raw not in (None, "", "0") else None
except (TypeError, ValueError):
upb = None
rule_id_raw = raw.get("box_rule_id")
try:
rule_id = int(rule_id_raw) if rule_id_raw not in (None, "") else None
except (TypeError, ValueError):
rule_id = None
out.append(
{
"product_code": code,
"product_name_snapshot": name or code,
"quantity": qty,
"units_per_box": upb,
"box_rule_id": rule_id,
"manual_box_text": str(raw.get("manual_box_text") or "").strip(),
"memo": str(raw.get("memo") or "").strip(),
}
)
return out
@staticmethod
def _center_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
out["id"] = int(out["id"])
out["active"] = bool(out.get("active", True))
out["sort_order"] = int(out.get("sort_order", 0))
for k in ("created_at", "updated_at"):
v = out.get(k)
if isinstance(v, datetime):
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds")
return out
@staticmethod
def _rule_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
out["id"] = int(out["id"])
out["units_per_box"] = int(out.get("units_per_box", 0))
out["active"] = bool(out.get("active", True))
for k in ("created_at", "updated_at"):
v = out.get(k)
if isinstance(v, datetime):
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds")
return out
@staticmethod
def _shipment_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
out["id"] = int(out["id"])
out["center_id"] = int(out["center_id"]) if out.get("center_id") is not None else None
for k in ("document_date", "ship_date", "center_arrival_date"):
v = out.get(k)
if isinstance(v, date):
out[k] = v.isoformat()
for k in ("created_at", "updated_at"):
v = out.get(k)
if isinstance(v, datetime):
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds")
return out
@staticmethod
def _line_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
out["id"] = int(out["id"])
out["line_no"] = int(out.get("line_no", 0))
out["quantity"] = int(out.get("quantity", 0))
for k in ("box_rule_id", "units_per_box", "calculated_boxes", "remainder_units"):
v = out.get(k)
out[k] = int(v) if v is not None else None
for k in ("created_at", "updated_at"):
v = out.get(k)
if isinstance(v, datetime):
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds")
return out
+140
View File
@@ -0,0 +1,140 @@
r"""itemcode_db 읽기 전용 상품 검색.
cupang 모듈은 itemcode_db 의 상품(낱개코드/세트코드)을 **읽기만** 한다.
cupang_db 에 상품을 복제 저장하지 않는다. 출고 라인에는 product_code 와
product_name_snapshot 만 보존한다.
⚠️ itemcode_db 의 실제 테이블/컬럼명은 이 저장소(main-app)에 정의되어 있지 않다.
운영 서버에서 다음으로 먼저 구조를 확인한 뒤 환경변수를 설정해야 한다:
docker exec -it postgres-db psql -U postgres -d itemcode_db -c "\dt"
docker exec -it postgres-db psql -U postgres -d itemcode_db -c "\d <테이블명>"
환경변수 (모두 미설정 시 검색 비활성 → 폼에서 수동 입력으로 폴백):
ITEMCODE_DB_URL 읽기 전용 DSN. 예: postgresql://itemcode_ro:<pwd>@postgres-db:5432/itemcode_db
ITEMCODE_SEARCH_SQL (선택) 검색 SQL 직접 지정. 아래 자동 생성 대신 사용.
반드시 code, name, type 컬럼을 별칭으로 반환하고,
%(q)s 파라미터를 LIKE 패턴으로 받는다.
자동 생성용 (ITEMCODE_SEARCH_SQL 미설정 시):
ITEMCODE_TABLE 검색 대상 테이블/뷰 (예: products 또는 item_master)
ITEMCODE_CODE_COL 코드 컬럼명 (기본: product_code)
ITEMCODE_NAME_COL 상품명 컬럼명 (기본: product_name)
ITEMCODE_TYPE_COL (선택) 단품/세트 구분 컬럼명. 없으면 type 은 빈 문자열.
낱개코드와 세트코드가 별도 테이블이면 ITEMCODE_SEARCH_SQL 에 UNION 으로 직접 작성한다.
"""
from __future__ import annotations
import os
import re
from typing import Any
# 안전한 SQL 식별자(테이블/컬럼)만 허용 — 인젝션 방지.
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*$")
def _ident(value: str, *, what: str) -> str:
v = (value or "").strip()
if not _IDENT_RE.match(v):
raise ValueError(f"안전하지 않은 {what} 식별자: {value!r}")
return v
class ItemcodeReader:
"""itemcode_db 읽기 전용 커넥션 풀 + 상품 검색.
설정이 없거나 불완전하면 `enabled=False` 로 두고, search()는 빈 리스트를 반환한다.
앱 부팅이나 cupang 모듈 진입을 막지 않는다.
"""
def __init__(self) -> None:
self._pool: Any = None
self._sql: str | None = None
self.enabled = False
self.reason = ""
self._configure()
def _configure(self) -> None:
dsn = os.getenv("ITEMCODE_DB_URL", "").strip()
if not dsn:
self.reason = "ITEMCODE_DB_URL 미설정 — 상품 검색 비활성(수동 입력 사용)."
return
custom_sql = os.getenv("ITEMCODE_SEARCH_SQL", "").strip()
if custom_sql:
self._sql = custom_sql
else:
table = os.getenv("ITEMCODE_TABLE", "").strip()
if not table:
self.reason = (
"ITEMCODE_TABLE(또는 ITEMCODE_SEARCH_SQL) 미설정 — "
"상품 검색 비활성(수동 입력 사용)."
)
return
try:
table_id = _ident(table, what="테이블")
code_col = _ident(os.getenv("ITEMCODE_CODE_COL", "product_code"), what="코드 컬럼")
name_col = _ident(os.getenv("ITEMCODE_NAME_COL", "product_name"), what="상품명 컬럼")
type_col_raw = os.getenv("ITEMCODE_TYPE_COL", "").strip()
type_expr = _ident(type_col_raw, what="구분 컬럼") if type_col_raw else "''"
except ValueError as exc:
self.reason = f"itemcode 검색 설정 오류: {exc}"
return
self._sql = (
f"SELECT {code_col} AS code, {name_col} AS name, {type_expr} AS type "
f"FROM {table_id} "
f"WHERE {code_col} ILIKE %(q)s OR {name_col} ILIKE %(q)s "
f"ORDER BY {code_col} ASC LIMIT %(limit)s"
)
# 풀은 lazy open — 부팅 시 itemcode_db 가 잠시 끊겨도 죽지 않게.
try:
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
self._pool = ConnectionPool(
conninfo=dsn,
min_size=1,
max_size=3,
kwargs={"row_factory": dict_row, "autocommit": True},
open=False,
)
self._pool.open(wait=False)
self.enabled = True
self.reason = ""
except Exception as exc: # noqa: BLE001 — 설정/드라이버 문제로 모듈을 죽이지 않음
self.reason = f"itemcode_db 연결 풀 생성 실패: {type(exc).__name__}"
def search(self, query: str, *, limit: int = 20) -> list[dict[str, Any]]:
"""code/name 부분 일치 검색. 반환: [{"code","name","type"}].
비활성 상태이거나 조회 실패 시 빈 리스트(예외 비전파 — UI 는 수동 입력 폴백).
"""
q = (query or "").strip()
if not self.enabled or not self._pool or not self._sql or not q:
return []
like = f"%{q}%"
try:
with self._pool.connection() as conn:
rows = conn.execute(
self._sql, {"q": like, "limit": int(limit)}
).fetchall()
except Exception: # noqa: BLE001 — 비밀값 노출 없이 빈 결과. 상세는 서버 로그.
return []
out: list[dict[str, Any]] = []
for r in rows:
out.append(
{
"code": str(r.get("code") or "").strip(),
"name": str(r.get("name") or "").strip(),
"type": str(r.get("type") or "").strip(),
}
)
return out
def close(self) -> None:
if self._pool is not None:
self._pool.close()
+726
View File
@@ -0,0 +1,726 @@
"""쿠팡 밀크런 모듈 라우터.
- 경로: /cupang
- 권한: 로그인 + `cupang` 모듈 권한 (관리자는 항상 통과). 서버 측 검사.
- 데이터: CupangDBStore (cupang_db / PostgreSQL) 전용.
CUPANG_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내를 보여준다.
- 상품 검색: itemcode_db 읽기 전용(ItemcodeReader). 미설정 시 수동 입력 폴백.
"""
from __future__ import annotations
import calendar as _calendar
import io
import json
from datetime import date, datetime
from typing import Any
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import (
HTMLResponse,
JSONResponse,
RedirectResponse,
StreamingResponse,
)
from .store import DEFAULT_CENTERS, SHEET_COLUMNS, SHIP_METHODS, STATUSES, compute_boxes
router = APIRouter(prefix="/cupang", tags=["cupang"])
# ────────────────────────────────────────────────────────────
# 공용 헬퍼
# ────────────────────────────────────────────────────────────
def _store(request: Request) -> Any:
"""CupangDBStore 또는 None(CUPANG_DB_URL 미설정)."""
return getattr(request.app.state, "cupang_store", None)
def _itemcode(request: Request) -> Any:
return getattr(request.app.state, "itemcode_reader", None)
def _require_user(request: Request) -> dict[str, Any]:
from app.main import get_current_user_record # noqa: WPS433
from app.store import has_module # noqa: WPS433
user = get_current_user_record(request)
if user is None:
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
if not has_module(user, "cupang"):
raise HTTPException(status_code=403, detail="쿠팡 밀크런 모듈 권한이 없습니다.")
return user
def _render_config_needed(request: Request, user: dict[str, Any]) -> HTMLResponse:
from app.main import build_erp_nav, render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
return render_template(
request,
"denied.html",
{
"reason": "쿠팡 밀크런 모듈이 아직 설정되지 않았습니다. "
"CUPANG_DB_URL 환경변수를 설정하고 scripts/sql/cupang_db_init.sql 로 "
"cupang_db 를 초기화한 뒤 컨테이너를 재기동하세요.",
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"),
},
status_code=503,
)
def _guard(request: Request) -> tuple[Any, dict[str, Any]] | HTMLResponse | RedirectResponse:
"""로그인+권한+store 점검을 한 번에. 페이지 핸들러 진입부에서 사용."""
from app.main import get_current_user_record, render_template # noqa: WPS433
from app.store import has_module, is_admin # noqa: WPS433
user = get_current_user_record(request)
if user is None:
return RedirectResponse(url="/login", status_code=303)
if not has_module(user, "cupang"):
return render_template(
request,
"denied.html",
{"reason": "쿠팡 밀크런 모듈 접근 권한이 없습니다.", "is_admin": is_admin(user)},
status_code=403,
)
store = _store(request)
if store is None:
return _render_config_needed(request, user)
return store, user
def _parse_lines(lines_json: str) -> list[dict[str, Any]]:
try:
data = json.loads(lines_json or "[]")
except (json.JSONDecodeError, TypeError):
raise HTTPException(status_code=400, detail="라인 데이터 형식 오류")
if not isinstance(data, list):
raise HTTPException(status_code=400, detail="라인 데이터는 배열이어야 합니다.")
return data
def _ym(request: Request) -> tuple[int, int]:
today = date.today()
try:
year = int(request.query_params.get("year") or today.year)
month = int(request.query_params.get("month") or today.month)
except ValueError:
year, month = today.year, today.month
if not (1 <= month <= 12):
year, month = today.year, today.month
return year, month
# ════════════════════════════════════════════════════════════
# 메인 — 월간 달력 + 선택일 출고 리스트
# ════════════════════════════════════════════════════════════
@router.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
from app.main import build_erp_nav, render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
store, user = guard
year, month = _ym(request)
counts = store.calendar_counts(year=year, month=month)
shipments = store.list_shipments(year=year, month=month)
# 선택 날짜 (기본: 오늘이 이번 달이면 오늘, 아니면 1일)
sel = request.query_params.get("date") or ""
today = date.today()
if not sel:
sel = today.isoformat() if (today.year == year and today.month == month) else f"{year:04d}-{month:02d}-01"
# 선택일에 걸친 묶음(출고일 기준 우선, 작성/입고 포함)
sel_shipments = [
s for s in shipments
if sel in (s.get("ship_date"), s.get("document_date"), s.get("center_arrival_date"))
]
cal = _calendar.Calendar(firstweekday=6) # 일요일 시작
weeks = cal.monthdatescalendar(year, month)
cal_weeks = [
[
{
"date": d.isoformat(),
"day": d.day,
"in_month": d.month == month,
"is_today": d == today,
"is_selected": d.isoformat() == sel,
"counts": counts.get(d.isoformat(), {}),
}
for d in week
]
for week in weeks
]
prev_y, prev_m = (year - 1, 12) if month == 1 else (year, month - 1)
next_y, next_m = (year + 1, 1) if month == 12 else (year, month + 1)
return render_template(
request,
"cupang/index.html",
{
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"),
"page_title": "쿠팡 밀크런",
"page_subtitle": f"{year}{month}월 출고 일정",
"year": year,
"month": month,
"prev_y": prev_y, "prev_m": prev_m,
"next_y": next_y, "next_m": next_m,
"weekdays": ["", "", "", "", "", "", ""],
"cal_weeks": cal_weeks,
"selected_date": sel,
"sel_shipments": sel_shipments,
"statuses": list(STATUSES),
},
)
@router.get("/api/calendar")
async def api_calendar(
request: Request, _: dict[str, Any] = Depends(_require_user)
) -> JSONResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
year, month = _ym(request)
return JSONResponse(
{"year": year, "month": month, "counts": store.calendar_counts(year=year, month=month)}
)
# ════════════════════════════════════════════════════════════
# 출고 묶음 — 등록 / 수정 / 상세
# ════════════════════════════════════════════════════════════
def _form_context(request: Request, store: Any, user: dict[str, Any]) -> dict[str, Any]:
from app.main import build_erp_nav # noqa: WPS433
from app.store import is_admin # noqa: WPS433
reader = _itemcode(request)
return {
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"),
"centers": store.list_centers(),
"box_rules": store.list_box_rules(),
"ship_methods": list(SHIP_METHODS),
"statuses": list(STATUSES),
"search_enabled": bool(reader and reader.enabled),
}
@router.get("/new", response_class=HTMLResponse)
async def new_form(request: Request) -> HTMLResponse:
from app.main import render_template # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
store, user = guard
ctx = _form_context(request, store, user)
ctx.update(
{
"page_title": "쿠팡 밀크런 — 신규 등록",
"page_subtitle": "공통 헤더 1개 + 품목 라인",
"mode": "new",
"shipment": None,
"default_date": date.today().isoformat(),
}
)
return render_template(request, "cupang/form.html", ctx)
@router.post("/new")
async def create(
request: Request,
lines_json: str = Form("[]"),
document_no: str = Form(""),
document_date: str = Form(...),
ship_date: str = Form(...),
center_arrival_date: str = Form(...),
center_id: str = Form(""),
center_name_snapshot: str = Form(""),
ship_method: str = Form("택배"),
outbound_summary: str = Form(""),
worker: str = Form(""),
status: str = Form("작성중"),
memo: str = Form(""),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
header = {
"document_no": document_no,
"document_date": document_date,
"ship_date": ship_date,
"center_arrival_date": center_arrival_date,
"center_id": center_id,
"center_name_snapshot": center_name_snapshot,
"ship_method": ship_method,
"outbound_summary": outbound_summary,
"worker": worker,
"status": status,
"memo": memo,
}
try:
ship = store.create_shipment(
created_by=user["email"], header=header, lines=_parse_lines(lines_json)
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/cupang/{ship['id']}", status_code=303)
@router.get("/{shipment_id:int}", response_class=HTMLResponse)
async def detail(request: Request, shipment_id: int) -> HTMLResponse:
from app.main import build_erp_nav, render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
store, user = guard
ship = store.get_shipment(shipment_id=shipment_id)
if not ship:
return render_template(
request, "denied.html",
{"reason": "출고 묶음을 찾을 수 없습니다.", "is_admin": is_admin(user)},
status_code=404,
)
return render_template(
request,
"cupang/detail.html",
{
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"),
"page_title": f"출고 #{ship['id']}",
"page_subtitle": f"{ship['ship_date']} · {ship['center_name_snapshot']}",
"shipment": ship,
"statuses": list(STATUSES),
},
)
@router.get("/{shipment_id:int}/edit", response_class=HTMLResponse)
async def edit_form(request: Request, shipment_id: int) -> HTMLResponse:
from app.main import render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
store, user = guard
ship = store.get_shipment(shipment_id=shipment_id)
if not ship:
return render_template(
request, "denied.html",
{"reason": "출고 묶음을 찾을 수 없습니다.", "is_admin": is_admin(user)},
status_code=404,
)
ctx = _form_context(request, store, user)
ctx.update(
{
"page_title": f"출고 #{ship['id']} 수정",
"page_subtitle": "헤더/라인 수정 후 저장",
"mode": "edit",
"shipment": ship,
"default_date": ship["document_date"],
}
)
return render_template(request, "cupang/form.html", ctx)
@router.post("/{shipment_id:int}/edit")
async def update(
request: Request,
shipment_id: int,
lines_json: str = Form("[]"),
document_no: str = Form(""),
document_date: str = Form(...),
ship_date: str = Form(...),
center_arrival_date: str = Form(...),
center_id: str = Form(""),
center_name_snapshot: str = Form(""),
ship_method: str = Form("택배"),
outbound_summary: str = Form(""),
worker: str = Form(""),
memo: str = Form(""),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
header = {
"document_no": document_no,
"document_date": document_date,
"ship_date": ship_date,
"center_arrival_date": center_arrival_date,
"center_id": center_id,
"center_name_snapshot": center_name_snapshot,
"ship_method": ship_method,
"outbound_summary": outbound_summary,
"worker": worker,
"memo": memo,
}
try:
store.update_shipment(
shipment_id=shipment_id, header=header, lines=_parse_lines(lines_json)
)
except KeyError:
raise HTTPException(status_code=404, detail="출고 묶음을 찾을 수 없습니다.")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/cupang/{shipment_id}", status_code=303)
@router.post("/{shipment_id:int}/status")
async def change_status(
request: Request,
shipment_id: int,
status: str = Form(...),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
store.set_status(shipment_id=shipment_id, status=status)
except KeyError:
raise HTTPException(status_code=404, detail="출고 묶음을 찾을 수 없습니다.")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/cupang/{shipment_id}", status_code=303)
@router.post("/{shipment_id:int}/delete")
async def delete(
request: Request,
shipment_id: int,
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
"""운영 안전: 기본은 status='취소' soft delete."""
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
store.soft_delete(shipment_id=shipment_id)
except KeyError:
raise HTTPException(status_code=404, detail="출고 묶음을 찾을 수 없습니다.")
return RedirectResponse(url=f"/cupang/{shipment_id}", status_code=303)
# ════════════════════════════════════════════════════════════
# 입고센터 관리
# ════════════════════════════════════════════════════════════
@router.get("/centers", response_class=HTMLResponse)
async def centers_page(request: Request) -> HTMLResponse:
from app.main import build_erp_nav, render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
store, user = guard
centers = store.list_centers(include_inactive=True)
# 사용 중 여부 표시
for c in centers:
c["in_use"] = store.center_in_use(center_id=c["id"])
return render_template(
request,
"cupang/centers.html",
{
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"),
"page_title": "쿠팡 밀크런 — 입고센터 관리",
"page_subtitle": "추가 · 수정 · 비활성화. 사용 중 센터는 삭제되지 않고 비활성화됩니다.",
"centers": centers,
},
)
@router.post("/centers")
async def center_create(
request: Request,
name: str = Form(...),
sort_order: int = Form(0),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
store.create_center(name=name, sort_order=sort_order)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url="/cupang/centers", status_code=303)
@router.post("/centers/{center_id}/edit")
async def center_edit(
request: Request,
center_id: int,
name: str = Form(""),
active: str = Form(""),
sort_order: str = Form(""),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
kwargs: dict[str, Any] = {"center_id": center_id}
if name.strip():
kwargs["name"] = name
if active != "":
kwargs["active"] = active in ("1", "true", "on", "True")
if sort_order.strip():
try:
kwargs["sort_order"] = int(sort_order)
except ValueError:
pass
try:
store.update_center(**kwargs)
except KeyError:
raise HTTPException(status_code=404, detail="센터를 찾을 수 없습니다.")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url="/cupang/centers", status_code=303)
@router.post("/centers/{center_id}/delete")
async def center_delete(
request: Request,
center_id: int,
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
store.delete_center(center_id=center_id)
except KeyError:
raise HTTPException(status_code=404, detail="센터를 찾을 수 없습니다.")
return RedirectResponse(url="/cupang/centers", status_code=303)
# ════════════════════════════════════════════════════════════
# 박스 입수량 관리
# ════════════════════════════════════════════════════════════
@router.get("/box-rules", response_class=HTMLResponse)
async def box_rules_page(request: Request) -> HTMLResponse:
from app.main import build_erp_nav, render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
store, user = guard
return render_template(
request,
"cupang/box_rules.html",
{
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"),
"page_title": "쿠팡 밀크런 — 박스 입수량",
"page_subtitle": "제품코드별 쿠팡박스 1박스당 입수량 설정",
"box_rules": store.list_box_rules(include_inactive=True),
"search_enabled": bool((_itemcode(request)) and _itemcode(request).enabled),
},
)
@router.post("/box-rules")
async def box_rule_upsert(
request: Request,
product_code: str = Form(...),
units_per_box: int = Form(...),
product_name_snapshot: str = Form(""),
box_name: str = Form("쿠팡박스"),
memo: str = Form(""),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
store.upsert_box_rule(
product_code=product_code,
units_per_box=units_per_box,
product_name_snapshot=product_name_snapshot,
box_name=box_name,
memo=memo,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url="/cupang/box-rules", status_code=303)
@router.post("/box-rules/{rule_id}/delete")
async def box_rule_delete(
request: Request,
rule_id: int,
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
store.deactivate_box_rule(rule_id=rule_id)
except KeyError:
raise HTTPException(status_code=404, detail="규칙을 찾을 수 없습니다.")
return RedirectResponse(url="/cupang/box-rules", status_code=303)
# ════════════════════════════════════════════════════════════
# 상품 검색 (itemcode_db 읽기 전용) + 박스 계산 미리보기
# ════════════════════════════════════════════════════════════
@router.get("/api/products/search")
async def product_search(
request: Request,
q: str = "",
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
reader = _itemcode(request)
store = _store(request)
results = reader.search(q) if reader else []
# box_rule 의 units_per_box 를 결과에 병합 (있으면)
rule_map = store.box_rule_map() if store else {}
for r in results:
rule = rule_map.get(r["code"])
r["units_per_box"] = rule["units_per_box"] if rule else None
r["box_rule_id"] = rule["id"] if rule else None
return JSONResponse(
{
"enabled": bool(reader and reader.enabled),
"reason": (reader.reason if reader else "itemcode 리더 미초기화"),
"results": results,
}
)
@router.get("/api/box-calc")
async def box_calc(
request: Request,
product_code: str = "",
quantity: int = 0,
units_per_box: str = "",
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
store = _store(request)
upb: int | None
if units_per_box.strip():
try:
upb = int(units_per_box)
except ValueError:
upb = None
else:
rule = store.get_box_rule(product_code=product_code) if store else None
upb = rule["units_per_box"] if rule else None
return JSONResponse(compute_boxes(quantity, upb))
# ════════════════════════════════════════════════════════════
# 엑셀 내보내기 (구글시트 컬럼 순서 유지)
# ════════════════════════════════════════════════════════════
def _flatten_for_sheet(shipments: list[dict[str, Any]]) -> list[list[Any]]:
"""묶음 → 시트 행. 같은 묶음 2번째 라인부터 공통 헤더 칸은 비운다."""
rows: list[list[Any]] = []
seq = 0
for s in shipments:
lines = s.get("lines") or []
for i, ln in enumerate(lines):
seq += 1
if i == 0:
rows.append([
seq,
s.get("document_date", ""),
s.get("ship_date", ""),
s.get("center_arrival_date", ""),
s.get("center_name_snapshot", ""),
s.get("ship_method", ""),
ln.get("product_code", ""),
ln.get("product_name_snapshot", ""),
ln.get("quantity", 0),
s.get("outbound_summary", ""),
s.get("worker", ""),
])
else:
# 공통 헤더 칸 비움 (구분/제품/수량만 채움)
rows.append([
seq, "", "", "", "", "",
ln.get("product_code", ""),
ln.get("product_name_snapshot", ""),
ln.get("quantity", 0),
"", "",
])
return rows
@router.get("/export")
async def export_month(
request: Request,
user: dict[str, Any] = Depends(_require_user),
) -> StreamingResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
year, month = _ym(request)
shipments = store.shipments_for_export(year=year, month=month)
fname = f"cupang_milkrun_{year:04d}{month:02d}.xlsx"
return _build_xlsx(shipments, fname, sheet_title=f"{year}-{month:02d}")
@router.get("/{shipment_id:int}/export")
async def export_one(
request: Request,
shipment_id: int,
user: dict[str, Any] = Depends(_require_user),
) -> StreamingResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
shipments = store.shipments_for_export(shipment_id=shipment_id)
if not shipments:
raise HTTPException(status_code=404, detail="출고 묶음을 찾을 수 없습니다.")
fname = f"cupang_milkrun_{shipment_id}.xlsx"
return _build_xlsx(shipments, fname, sheet_title=f"출고{shipment_id}")
def _build_xlsx(shipments: list[dict[str, Any]], fname: str, *, sheet_title: str) -> StreamingResponse:
from openpyxl import Workbook # 지연 import
wb = Workbook()
ws = wb.active
ws.title = sheet_title[:31] or "cupang"
ws.append(list(SHEET_COLUMNS))
for row in _flatten_for_sheet(shipments):
ws.append(row)
widths = [6, 12, 12, 12, 12, 10, 14, 26, 8, 24, 12]
for col, w in enumerate(widths, start=1):
ws.column_dimensions[ws.cell(row=1, column=col).column_letter].width = w
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
return StreamingResponse(
buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
@router.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "module": "cupang"}
+111
View File
@@ -0,0 +1,111 @@
"""쿠팡 밀크런 모듈 상수 및 공용 헬퍼.
- 데이터 저장은 cupang_db(PostgreSQL) 전용이다(`db.py`).
운영 데이터가 JSON 과 DB 로 갈라지는 것을 막기 위해 JSON 폴백을 두지 않는다.
CUPANG_DB_URL 미설정 시 라우터가 "설정 필요" 안내 페이지를 보여준다.
- 이 모듈에는 DB/JSON 양쪽이 공유하는 상수와 순수 계산 헬퍼만 둔다.
"""
from __future__ import annotations
import math
from typing import Any
# 출고 묶음 상태 (expense 의 STATUSES 패턴과 동일하게 한글 라벨 그대로 저장)
STATUSES: tuple[str, ...] = (
"작성중",
"출고준비",
"출고완료",
"센터입고완료",
"취소",
)
# 출고방식 기본 후보 (자유 입력 허용, 아래는 select 기본값)
SHIP_METHODS: tuple[str, ...] = ("택배", "직접배송", "화물", "기타")
# 엑셀/시트 컬럼 순서 — 기존 구글시트와 동일하게 유지
SHEET_COLUMNS: tuple[str, ...] = (
"구분",
"작성일",
"출고일",
"센터입고일",
"입고센터",
"출고방식",
"제품코드",
"제품명",
"수량",
"출고",
"작업자",
)
# 초기 입고센터 seed — cupang_db_init.sql 에도 동일 목록을 INSERT 한다.
# 화면에서 추가/수정/비활성화 가능. 사용 중인 센터는 hard delete 하지 않는다.
DEFAULT_CENTERS: tuple[str, ...] = (
"대구3",
"인천32",
"이천1",
"인천42",
"인천26",
"인천16",
"인천28",
"안성8",
"천안8(RC)",
"시흥2",
"인천36",
"MGMH5",
"XRC10(RC)",
"인천14",
"경기광주5",
"경기광주3",
"XRC06(RC)",
"용인1",
"인천30",
"마장1",
"안성4",
"대구6",
"전라광주2",
"창원1",
"고양1",
"동탄1",
"이천4",
"XRC09(RC)",
)
def compute_boxes(quantity: int, units_per_box: int | None) -> dict[str, Any]:
"""수량 + 박스당 입수량으로 필요한 박스 수를 계산한다.
클라이언트 계산을 신뢰하지 않고 서버에서 이 함수로 재계산한다.
- units_per_box 가 없거나 0 이하면 "미설정" — 자동 계산하지 않는다.
- full_boxes = quantity // units_per_box
- remainder_units = quantity % units_per_box
- required_boxes = ceil(quantity / units_per_box)
"""
try:
qty = int(quantity)
except (TypeError, ValueError):
qty = 0
upb: int | None
try:
upb = int(units_per_box) if units_per_box is not None else None
except (TypeError, ValueError):
upb = None
if not upb or upb <= 0 or qty <= 0:
return {
"configured": False,
"units_per_box": upb if (upb and upb > 0) else None,
"full_boxes": None,
"remainder_units": None,
"required_boxes": None,
}
return {
"configured": True,
"units_per_box": upb,
"full_boxes": qty // upb,
"remainder_units": qty % upb,
"required_boxes": math.ceil(qty / upb),
}
@@ -0,0 +1,66 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css" />{% endblock %}
{% block content %}
<section class="cpg">
<div class="erp-page-actions">
<a class="erp-btn erp-btn-outline" href="/cupang/">← 달력</a>
</div>
<!-- 추가/수정 (product_code UNIQUE → upsert) -->
<div class="erp-card cpg-form-card">
<div class="cpg-card-head">
<h2>박스 입수량 추가 / 수정</h2>
<span class="erp-muted">같은 제품코드는 덮어씁니다. 쿠팡박스 1박스당 들어가는 수량.</span>
</div>
<form method="post" action="/cupang/box-rules" class="cpg-rule-grid">
<label class="erp-field"><span>제품코드 *</span>
<input class="erp-input" type="text" name="product_code" required placeholder="예: MS-1001" /></label>
<label class="erp-field"><span>제품명(스냅샷)</span>
<input class="erp-input" type="text" name="product_name_snapshot" placeholder="예: 미라네 1호세트" /></label>
<label class="erp-field"><span>박스명</span>
<input class="erp-input" type="text" name="box_name" value="쿠팡박스" /></label>
<label class="erp-field"><span>박스당 입수량 *</span>
<input class="erp-input" type="number" name="units_per_box" min="1" required /></label>
<label class="erp-field cpg-full"><span>메모</span>
<input class="erp-input" type="text" name="memo" /></label>
<div><button type="submit" class="erp-btn erp-btn-primary">저장</button></div>
</form>
</div>
<!-- 목록 -->
<div class="erp-card cpg-form-card">
<div class="cpg-card-head"><h2>입수량 규칙 ({{ box_rules|length }})</h2></div>
<div class="erp-table-wrap">
<table class="erp-table">
<thead>
<tr><th>제품코드</th><th>제품명</th><th>박스명</th><th>입수량</th><th>상태</th><th>메모</th><th></th></tr>
</thead>
<tbody>
{% for r in box_rules %}
<tr {% if not r.active %}style="opacity:.55"{% endif %}>
<td>{{ r.product_code }}</td>
<td>{{ r.product_name_snapshot or '—' }}</td>
<td>{{ r.box_name }}</td>
<td>{{ r.units_per_box }}</td>
<td>{% if r.active %}<span class="erp-badge erp-badge-success">활성</span>{% else %}<span class="erp-badge erp-badge-neutral">비활성</span>{% endif %}</td>
<td>{{ r.memo or '—' }}</td>
<td>
{% if r.active %}
<form method="post" action="/cupang/box-rules/{{ r.id }}/delete" class="cpg-inline-form"
onsubmit="return confirm('비활성화합니다. 계속할까요?');">
<button type="submit" class="erp-btn erp-btn-danger">비활성화</button>
</form>
{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1,74 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css" />{% endblock %}
{% block content %}
<section class="cpg">
<div class="erp-page-actions">
<a class="erp-btn erp-btn-outline" href="/cupang/">← 달력</a>
</div>
<!-- 추가 -->
<div class="erp-card cpg-form-card">
<div class="cpg-card-head"><h2>입고센터 추가</h2></div>
<form method="post" action="/cupang/centers" class="cpg-inline-form">
<input class="erp-input" type="text" name="name" placeholder="센터명 (예: 대구3)" required />
<input class="erp-input" type="number" name="sort_order" value="0" style="max-width:90px" title="정렬순서" />
<button type="submit" class="erp-btn erp-btn-primary">추가</button>
</form>
</div>
<!-- 목록 -->
<div class="erp-card cpg-form-card">
<div class="cpg-card-head"><h2>센터 목록 ({{ centers|length }})</h2></div>
<div class="erp-table-wrap">
<table class="erp-table">
<thead>
<tr><th>센터명</th><th>정렬</th><th>상태</th><th>사용중</th><th>동작</th></tr>
</thead>
<tbody>
{% for c in centers %}
<tr {% if not c.active %}style="opacity:.55"{% endif %}>
<td>
<form method="post" action="/cupang/centers/{{ c.id }}/edit" class="cpg-inline-form">
<input class="erp-input" type="text" name="name" value="{{ c.name }}" style="max-width:160px" />
<input class="erp-input" type="number" name="sort_order" value="{{ c.sort_order }}" style="max-width:80px" />
<button type="submit" class="erp-btn erp-btn-outline">수정</button>
</form>
</td>
<td>{{ c.sort_order }}</td>
<td>
{% if c.active %}<span class="erp-badge erp-badge-success">활성</span>
{% else %}<span class="erp-badge erp-badge-neutral">비활성</span>{% endif %}
</td>
<td>{% if c.in_use %}<span class="erp-badge erp-badge-inverse">사용중</span>{% else %}—{% endif %}</td>
<td>
<div class="cpg-row-actions">
{% if c.active %}
<form method="post" action="/cupang/centers/{{ c.id }}/edit" class="cpg-inline-form">
<input type="hidden" name="active" value="0" />
<button type="submit" class="erp-btn erp-btn-outline">비활성화</button>
</form>
{% else %}
<form method="post" action="/cupang/centers/{{ c.id }}/edit" class="cpg-inline-form">
<input type="hidden" name="active" value="1" />
<button type="submit" class="erp-btn erp-btn-outline">활성화</button>
</form>
{% endif %}
<form method="post" action="/cupang/centers/{{ c.id }}/delete" class="cpg-inline-form"
onsubmit="return confirm('{% if c.in_use %}사용 중인 센터는 비활성화됩니다.{% else %}삭제합니다.{% endif %} 계속할까요?');">
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="erp-muted">※ 출고 데이터에서 사용 중인 센터는 삭제 시 hard delete 되지 않고 비활성화됩니다.</p>
</div>
</section>
{% endblock %}
@@ -0,0 +1,81 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css" />{% endblock %}
{% block content %}
<section class="cpg">
<div class="erp-page-actions cpg-actions">
<a class="erp-btn erp-btn-primary" href="/cupang/{{ shipment.id }}/edit">수정</a>
<a class="erp-btn erp-btn-outline" href="/cupang/{{ shipment.id }}/export">엑셀</a>
<a class="erp-btn erp-btn-outline" href="/cupang/?date={{ shipment.ship_date }}">달력으로</a>
<form method="post" action="/cupang/{{ shipment.id }}/status" class="cpg-inline-form">
<select class="erp-select" name="status">
{% for s in statuses %}
<option value="{{ s }}" {% if shipment.status == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
<button type="submit" class="erp-btn erp-btn-outline">상태 변경</button>
</form>
<form method="post" action="/cupang/{{ shipment.id }}/delete" class="cpg-inline-form"
onsubmit="return confirm('이 출고 묶음을 취소 처리합니다. 계속할까요?');">
<button type="submit" class="erp-btn erp-btn-danger">취소 처리</button>
</form>
</div>
<!-- 헤더 -->
<div class="erp-card cpg-form-card">
<div class="cpg-card-head">
<h2>출고 #{{ shipment.id }}
{% set badge = 'erp-badge-neutral' %}
{% if shipment.status == '출고완료' %}{% set badge = 'erp-badge-inverse' %}
{% elif shipment.status == '센터입고완료' %}{% set badge = 'erp-badge-success' %}
{% elif shipment.status == '취소' %}{% set badge = 'erp-badge-danger' %}{% endif %}
<span class="erp-badge {{ badge }}">{{ shipment.status }}</span>
</h2>
</div>
<dl class="cpg-detail-grid">
<div><dt>작성일</dt><dd>{{ shipment.document_date }}</dd></div>
<div><dt>출고일</dt><dd>{{ shipment.ship_date }}</dd></div>
<div><dt>센터입고일</dt><dd>{{ shipment.center_arrival_date }}</dd></div>
<div><dt>입고센터</dt><dd>{{ shipment.center_name_snapshot or '—' }}</dd></div>
<div><dt>출고방식</dt><dd>{{ shipment.ship_method }}</dd></div>
<div><dt>작업자</dt><dd>{{ shipment.worker or '—' }}</dd></div>
<div><dt>문서번호</dt><dd>{{ shipment.document_no or '—' }}</dd></div>
<div class="cpg-full"><dt>출고/박스 요약</dt><dd>{{ shipment.outbound_summary or '—' }}</dd></div>
<div class="cpg-full"><dt>메모</dt><dd>{{ shipment.memo or '—' }}</dd></div>
</dl>
</div>
<!-- 라인 -->
<div class="erp-card cpg-form-card">
<div class="cpg-card-head"><h2>품목 ({{ shipment.lines|length }})</h2></div>
<div class="erp-table-wrap">
<table class="erp-table">
<thead>
<tr><th>#</th><th>제품코드</th><th>제품명</th><th>수량</th>
<th>입수량</th><th>필요박스</th><th>잔량</th><th>수동보정</th><th>메모</th></tr>
</thead>
<tbody>
{% for ln in shipment.lines %}
<tr>
<td>{{ ln.line_no }}</td>
<td>{{ ln.product_code }}</td>
<td>{{ ln.product_name_snapshot }}</td>
<td>{{ ln.quantity }}</td>
<td>{{ ln.units_per_box if ln.units_per_box else '미설정' }}</td>
<td>{{ ln.calculated_boxes if ln.calculated_boxes is not none else '—' }}</td>
<td>{{ ln.remainder_units if ln.remainder_units is not none else '—' }}</td>
<td>{{ ln.manual_box_text or '—' }}</td>
<td>{{ ln.memo or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1,122 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css" />{% endblock %}
{% block content %}
<section class="cpg">
{% set action = '/cupang/new' if mode == 'new' else '/cupang/' ~ shipment.id ~ '/edit' %}
<form id="cpg-form" method="post" action="{{ action }}">
<input type="hidden" name="lines_json" id="cpg-lines-json" value="[]" />
<!-- ── 공통 헤더 ── -->
<div class="erp-card cpg-form-card">
<div class="cpg-card-head"><h2>공통 헤더</h2></div>
<div class="cpg-header-grid">
<label class="erp-field"><span>작성일 *</span>
<input class="erp-input" type="date" name="document_date" required
value="{{ shipment.document_date if shipment else default_date }}" /></label>
<label class="erp-field"><span>출고일 *</span>
<input class="erp-input" type="date" name="ship_date" required
value="{{ shipment.ship_date if shipment else default_date }}" /></label>
<label class="erp-field"><span>센터입고일 *</span>
<input class="erp-input" type="date" name="center_arrival_date" required
value="{{ shipment.center_arrival_date if shipment else default_date }}" /></label>
<label class="erp-field"><span>입고센터</span>
<select class="erp-select" name="center_id" id="cpg-center-select">
<option value="">— 선택 —</option>
{% for c in centers %}
<option value="{{ c.id }}" data-name="{{ c.name }}"
{% if shipment and shipment.center_id == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select></label>
<!-- 센터 스냅샷(자유 입력 허용: 과거 명칭 보존/센터 미등록 시) -->
<input type="hidden" name="center_name_snapshot" id="cpg-center-name"
value="{{ shipment.center_name_snapshot if shipment else '' }}" />
<label class="erp-field"><span>출고방식</span>
<select class="erp-select" name="ship_method">
{% for m in ship_methods %}
<option value="{{ m }}" {% if shipment and shipment.ship_method == m %}selected{% endif %}>{{ m }}</option>
{% endfor %}
</select></label>
<label class="erp-field"><span>작업자</span>
<input class="erp-input" type="text" name="worker"
value="{{ shipment.worker if shipment else '' }}" /></label>
{% if mode == 'new' %}
<label class="erp-field"><span>상태</span>
<select class="erp-select" name="status">
{% for s in statuses %}<option value="{{ s }}">{{ s }}</option>{% endfor %}
</select></label>
{% endif %}
<label class="erp-field"><span>문서번호(선택)</span>
<input class="erp-input" type="text" name="document_no"
value="{{ shipment.document_no if shipment else '' }}" /></label>
</div>
<label class="erp-field cpg-full"><span>출고/박스 요약 (수동 보정 메모)</span>
<input class="erp-input" type="text" name="outbound_summary"
placeholder="예: 쿠팡박스 50, 6호상자 1, (50번 박스)"
value="{{ shipment.outbound_summary if shipment else '' }}" /></label>
<label class="erp-field cpg-full"><span>메모</span>
<textarea class="erp-input" name="memo" rows="2">{{ shipment.memo if shipment else '' }}</textarea></label>
</div>
<!-- ── 품목 라인 ── -->
<div class="erp-card cpg-form-card">
<div class="cpg-card-head">
<h2>품목 라인</h2>
<span class="erp-muted">
{% if search_enabled %}제품코드 검색 → 제품명 자동 채움.{% else %}상품 검색 비활성(수동 입력).{% endif %}
수량 입력 시 박스 수 자동 계산.
</span>
</div>
<div class="erp-table-wrap">
<table class="erp-table cpg-lines">
<thead>
<tr>
<th>#</th><th>제품코드</th><th>제품명</th><th>수량</th>
<th>입수량</th><th>박스 계산</th><th>라인메모</th><th></th>
</tr>
</thead>
<tbody id="cpg-lines-body"><!-- JS 렌더 --></tbody>
</table>
</div>
<button type="button" class="erp-btn erp-btn-outline" id="cpg-add-line">+ 라인 추가</button>
</div>
<div class="erp-page-actions">
<button type="submit" class="erp-btn erp-btn-primary">저장</button>
{% if mode == 'edit' %}
<a class="erp-btn erp-btn-outline" href="/cupang/{{ shipment.id }}">취소</a>
{% else %}
<a class="erp-btn erp-btn-outline" href="/cupang/">취소</a>
{% endif %}
</div>
</form>
<!-- 검색 결과 드롭다운 (JS 가 위치 이동) -->
<div id="cpg-search-pop" class="cpg-search-pop" hidden></div>
</section>
<script type="application/json" id="cpg-init-lines">
{% if shipment and shipment.lines %}{{ shipment.lines | tojson }}{% else %}[]{% endif %}
</script>
<script type="application/json" id="cpg-box-rules">
{{ box_rules | tojson }}
</script>
<script>
window.CPG = {
searchEnabled: {{ 'true' if search_enabled else 'false' }},
searchUrl: "/cupang/api/products/search",
calcUrl: "/cupang/api/box-calc"
};
</script>
{% endblock %}
{% block scripts %}<script src="/static/cupang.js" defer></script>{% endblock %}
@@ -0,0 +1,87 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css" />{% endblock %}
{% block content %}
<section class="cpg">
<!-- 페이지 액션 -->
<div class="erp-page-actions cpg-actions">
<a class="erp-btn erp-btn-primary" href="/cupang/new">+ 신규 등록</a>
<a class="erp-btn erp-btn-outline" href="/cupang/centers">입고센터 관리</a>
<a class="erp-btn erp-btn-outline" href="/cupang/box-rules">박스 입수량</a>
<a class="erp-btn erp-btn-outline" href="/cupang/export?year={{ year }}&month={{ month }}">엑셀({{ month }}월)</a>
</div>
<div class="cpg-layout">
<!-- ── 왼쪽: 큰 월간 달력 ── -->
<div class="erp-card cpg-cal-card">
<div class="cpg-cal-head">
<a class="erp-btn erp-btn-outline cpg-nav-btn"
href="/cupang/?year={{ prev_y }}&month={{ prev_m }}"></a>
<h2 class="cpg-cal-title">{{ year }}년 {{ month }}월</h2>
<a class="erp-btn erp-btn-outline cpg-nav-btn"
href="/cupang/?year={{ next_y }}&month={{ next_m }}"></a>
</div>
<div class="cpg-cal-grid">
{% for wd in weekdays %}
<div class="cpg-cal-wd {% if loop.index0 == 0 %}cpg-sun{% elif loop.index0 == 6 %}cpg-sat{% endif %}">{{ wd }}</div>
{% endfor %}
{% for week in cal_weeks %}
{% for cell in week %}
<a class="cpg-cal-cell
{% if not cell.in_month %}cpg-out{% endif %}
{% if cell.is_today %}cpg-today{% endif %}
{% if cell.is_selected %}cpg-selected{% endif %}"
href="/cupang/?year={{ year }}&month={{ month }}&date={{ cell.date }}">
<span class="cpg-cal-day">{{ cell.day }}</span>
<span class="cpg-cal-badges">
{% if cell.counts.document %}<span class="erp-badge erp-badge-neutral cpg-mini">작성 {{ cell.counts.document }}</span>{% endif %}
{% if cell.counts.ship %}<span class="erp-badge erp-badge-inverse cpg-mini">출고 {{ cell.counts.ship }}</span>{% endif %}
{% if cell.counts.arrival %}<span class="erp-badge erp-badge-success cpg-mini">입고 {{ cell.counts.arrival }}</span>{% endif %}
</span>
</a>
{% endfor %}
{% endfor %}
</div>
</div>
<!-- ── 오른쪽: 선택일 출고 묶음 리스트 ── -->
<div class="erp-card cpg-list-card">
<div class="cpg-list-head">
<h2>{{ selected_date }} 출고</h2>
<span class="erp-muted">{{ sel_shipments|length }}건</span>
</div>
{% if sel_shipments %}
<ul class="cpg-list">
{% for s in sel_shipments %}
<li class="cpg-list-item">
<a href="/cupang/{{ s.id }}" class="cpg-list-link">
<div class="cpg-list-top">
<strong>{{ s.center_name_snapshot or '센터 미지정' }}</strong>
{% set badge = 'erp-badge-neutral' %}
{% if s.status == '출고완료' %}{% set badge = 'erp-badge-inverse' %}
{% elif s.status == '센터입고완료' %}{% set badge = 'erp-badge-success' %}
{% elif s.status == '취소' %}{% set badge = 'erp-badge-danger' %}{% endif %}
<span class="erp-badge {{ badge }}">{{ s.status }}</span>
</div>
<div class="cpg-list-meta erp-muted">
출고 {{ s.ship_date }} · 입고 {{ s.center_arrival_date }} · {{ s.ship_method }}
{% if s.outbound_summary %}<br>{{ s.outbound_summary }}{% endif %}
</div>
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="erp-muted cpg-empty">선택한 날짜의 출고 묶음이 없습니다.
<a href="/cupang/new">신규 등록</a></p>
{% endif %}
</div>
</div>
</section>
{% endblock %}
+111
View File
@@ -0,0 +1,111 @@
/* 쿠팡 밀크런 — DESIGN.md 토큰 준수 (흰 배경 / 검정·회색 / 10·14px radius / 카드 16px) */
.cpg { display: flex; flex-direction: column; gap: 16px; }
.cpg-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
/* ── 레이아웃: 왼쪽 큰 달력 + 오른쪽 리스트 ── */
.cpg-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 16px;
align-items: start;
}
@media (max-width: 980px) { .cpg-layout { grid-template-columns: 1fr; } }
.cpg-cal-card, .cpg-list-card { padding: 16px; }
.cpg-cal-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 12px;
}
.cpg-cal-title { font-size: 18px; font-weight: 600; letter-spacing: -0.45px; margin: 0; }
.cpg-nav-btn { min-width: 36px; padding: 4px 10px; font-size: 16px; line-height: 1; }
.cpg-cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
}
.cpg-cal-wd {
text-align: center; font-size: 12px; font-weight: 600;
color: var(--color-midtone-gray); padding: 4px 0;
}
.cpg-sun { color: var(--color-callout-red); }
.cpg-sat { color: #2b5bc2; }
.cpg-cal-cell {
display: flex; flex-direction: column; gap: 4px;
min-height: 84px; padding: 6px;
border: 1px solid var(--color-subtle-ash);
border-radius: 10px;
background: #fff;
transition: border-color .12s, box-shadow .12s;
}
.cpg-cal-cell:hover { border-color: var(--color-midtone-gray); }
.cpg-out { background: var(--color-ghost-gray); color: var(--color-midtone-gray); }
.cpg-today { border-color: var(--color-deep-black); }
.cpg-selected { box-shadow: 0 0 0 2px var(--color-deep-black); border-color: var(--color-deep-black); }
.cpg-cal-day { font-size: 13px; font-weight: 600; }
.cpg-cal-badges { display: flex; flex-direction: column; gap: 3px; }
.cpg-mini { font-size: 11px; padding: 1px 6px; align-self: flex-start; }
/* ── 오른쪽 리스트 ── */
.cpg-list-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; }
.cpg-list-head h2 { font-size: 16px; font-weight: 600; margin: 0; }
.cpg-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.cpg-list-item { border: 1px solid var(--color-subtle-ash); border-radius: 10px; }
.cpg-list-link { display: block; padding: 10px 12px; }
.cpg-list-link:hover { background: var(--color-ghost-gray); border-radius: 10px; }
.cpg-list-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.cpg-list-meta { font-size: 12px; margin-top: 4px; }
.cpg-empty { padding: 16px 0; }
/* ── 폼 ── */
.cpg-form-card { padding: 16px; margin-bottom: 16px; }
.cpg-card-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.cpg-card-head h2 { font-size: 16px; font-weight: 600; margin: 0; }
.cpg-header-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
.cpg-rule-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px; align-items: end;
}
.erp-field { display: flex; flex-direction: column; gap: 4px; }
.erp-field > span { font-size: 12px; color: var(--color-midtone-gray); }
.cpg-full { grid-column: 1 / -1; }
.cpg-inline-form { display: inline-flex; gap: 6px; align-items: center; margin: 0; }
.cpg-row-actions { display: flex; gap: 6px; flex-wrap: wrap; }
/* ── 라인 테이블 ── */
.cpg-lines td { vertical-align: middle; }
.cpg-lines input { width: 100%; }
.cpg-line-calc { font-size: 12px; color: var(--color-midtone-gray); white-space: nowrap; }
.cpg-line-calc.cpg-warn { color: var(--color-callout-red); }
.cpg-line-del { cursor: pointer; }
/* ── 상세 ── */
.cpg-detail-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px; margin: 0;
}
.cpg-detail-grid dt { font-size: 12px; color: var(--color-midtone-gray); }
.cpg-detail-grid dd { margin: 2px 0 0; font-size: 14px; }
/* ── 상품 검색 드롭다운 ── */
.cpg-search-pop {
position: absolute; z-index: 50;
background: #fff; border: 1px solid var(--color-subtle-ash);
border-radius: 10px; box-shadow: 0 4px 16px rgba(0,0,0,.08);
max-height: 260px; overflow-y: auto; min-width: 280px;
}
.cpg-search-item { padding: 8px 12px; cursor: pointer; font-size: 13px; }
.cpg-search-item:hover, .cpg-search-item.is-active { background: var(--color-ghost-gray); }
.cpg-search-item .cpg-sc { font-weight: 600; }
.cpg-search-item .cpg-sn { color: var(--color-midtone-gray); margin-left: 8px; }
.cpg-search-empty { padding: 10px 12px; font-size: 12px; color: var(--color-midtone-gray); }
+174
View File
@@ -0,0 +1,174 @@
/* 쿠팡 밀크런 — 출고 폼 동적 라인 / 상품검색 / 박스계산.
서버가 저장 시 박스 수를 재계산하므로(store.compute_boxes) 여기 계산은 미리보기용. */
(function () {
"use strict";
var CFG = window.CPG || {};
var body = document.getElementById("cpg-lines-body");
var form = document.getElementById("cpg-form");
if (!body || !form) return;
// 입수량 규칙: product_code -> units_per_box
var ruleMap = {};
try {
var rules = JSON.parse(document.getElementById("cpg-box-rules").textContent || "[]");
rules.forEach(function (r) { if (r.active !== false) ruleMap[r.product_code] = r.units_per_box; });
} catch (e) {}
var initLines = [];
try { initLines = JSON.parse(document.getElementById("cpg-init-lines").textContent || "[]"); } catch (e) {}
function ceilDiv(a, b) { return Math.ceil(a / b); }
function recalc(row) {
var qty = parseInt(row.querySelector(".cpg-qty").value, 10) || 0;
var upbInput = row.querySelector(".cpg-upb");
var upb = parseInt(upbInput.value, 10);
var cell = row.querySelector(".cpg-line-calc");
if (!upb || upb <= 0) {
cell.textContent = "미설정";
cell.classList.add("cpg-warn");
return;
}
cell.classList.remove("cpg-warn");
if (qty <= 0) { cell.textContent = "—"; return; }
var boxes = ceilDiv(qty, upb);
var rem = qty % upb;
cell.textContent = boxes + "박스" + (rem ? " +" + rem : " (딱맞음)");
}
function makeRow(data) {
data = data || {};
var tr = document.createElement("tr");
tr.innerHTML =
'<td class="cpg-line-no"></td>' +
'<td><input class="erp-input cpg-code" type="text" autocomplete="off" placeholder="제품코드" /></td>' +
'<td><input class="erp-input cpg-name" type="text" placeholder="제품명" /></td>' +
'<td><input class="erp-input cpg-qty" type="number" min="1" style="max-width:90px" /></td>' +
'<td><input class="erp-input cpg-upb" type="number" min="1" style="max-width:90px" placeholder="입수량" /></td>' +
'<td><span class="cpg-line-calc">—</span></td>' +
'<td><input class="erp-input cpg-memo" type="text" /></td>' +
'<td><button type="button" class="erp-btn erp-btn-outline cpg-line-del">✕</button></td>';
tr.querySelector(".cpg-code").value = data.product_code || "";
tr.querySelector(".cpg-name").value = data.product_name_snapshot || data.product_name || "";
tr.querySelector(".cpg-qty").value = data.quantity || "";
var upb = data.units_per_box;
if (upb == null && data.product_code && ruleMap[data.product_code] != null) upb = ruleMap[data.product_code];
tr.querySelector(".cpg-upb").value = (upb != null ? upb : "");
tr.querySelector(".cpg-line-del").addEventListener("click", function () {
tr.remove(); renumber();
});
tr.querySelector(".cpg-qty").addEventListener("input", function () { recalc(tr); });
tr.querySelector(".cpg-upb").addEventListener("input", function () { recalc(tr); });
var codeInput = tr.querySelector(".cpg-code");
codeInput.addEventListener("input", function () {
// 입수량 규칙 자동 채움 (수동값 없을 때)
var code = codeInput.value.trim();
if (code && ruleMap[code] != null && !tr.querySelector(".cpg-upb").value) {
tr.querySelector(".cpg-upb").value = ruleMap[code];
}
if (CFG.searchEnabled) scheduleSearch(codeInput, tr);
});
codeInput.addEventListener("blur", function () { setTimeout(hidePop, 150); });
body.appendChild(tr);
recalc(tr);
renumber();
return tr;
}
function renumber() {
Array.prototype.forEach.call(body.querySelectorAll("tr"), function (tr, i) {
tr.querySelector(".cpg-line-no").textContent = i + 1;
});
}
// ── 상품 검색 드롭다운 ──
var pop = document.getElementById("cpg-search-pop");
var searchTimer = null;
function hidePop() { if (pop) { pop.hidden = true; pop.innerHTML = ""; } }
function scheduleSearch(input, tr) {
if (!pop) return;
clearTimeout(searchTimer);
var q = input.value.trim();
if (q.length < 1) { hidePop(); return; }
searchTimer = setTimeout(function () { doSearch(q, input, tr); }, 220);
}
function doSearch(q, input, tr) {
fetch(CFG.searchUrl + "?q=" + encodeURIComponent(q))
.then(function (r) { return r.json(); })
.then(function (data) {
var results = (data && data.results) || [];
if (!results.length) { hidePop(); return; }
pop.innerHTML = "";
results.forEach(function (item) {
var div = document.createElement("div");
div.className = "cpg-search-item";
div.innerHTML = '<span class="cpg-sc"></span><span class="cpg-sn"></span>';
div.querySelector(".cpg-sc").textContent = item.code;
div.querySelector(".cpg-sn").textContent = item.name + (item.type ? " · " + item.type : "");
div.addEventListener("mousedown", function (e) {
e.preventDefault();
input.value = item.code;
tr.querySelector(".cpg-name").value = item.name || "";
var upb = item.units_per_box != null ? item.units_per_box
: (ruleMap[item.code] != null ? ruleMap[item.code] : "");
if (upb !== "" && !tr.querySelector(".cpg-upb").value) tr.querySelector(".cpg-upb").value = upb;
recalc(tr);
hidePop();
});
pop.appendChild(div);
});
var rect = input.getBoundingClientRect();
pop.style.left = (window.scrollX + rect.left) + "px";
pop.style.top = (window.scrollY + rect.bottom + 2) + "px";
pop.hidden = false;
})
.catch(hidePop);
}
// ── 초기 라인 ──
if (initLines.length) { initLines.forEach(makeRow); } else { makeRow(); }
document.getElementById("cpg-add-line").addEventListener("click", function () { makeRow(); });
// ── 제출: 라인 직렬화 ──
form.addEventListener("submit", function (e) {
var lines = [];
Array.prototype.forEach.call(body.querySelectorAll("tr"), function (tr) {
var code = tr.querySelector(".cpg-code").value.trim();
var qty = parseInt(tr.querySelector(".cpg-qty").value, 10) || 0;
if (!code || qty <= 0) return; // 빈 라인 스킵 (서버도 스킵)
var upb = parseInt(tr.querySelector(".cpg-upb").value, 10);
lines.push({
product_code: code,
product_name_snapshot: tr.querySelector(".cpg-name").value.trim(),
quantity: qty,
units_per_box: (upb > 0 ? upb : null),
memo: tr.querySelector(".cpg-memo").value.trim()
});
});
if (!lines.length) {
e.preventDefault();
alert("품목 라인을 최소 1개 입력하세요 (제품코드 + 수량).");
return;
}
document.getElementById("cpg-lines-json").value = JSON.stringify(lines);
});
// ── 입고센터 select → 스냅샷 hidden 동기화 ──
var centerSel = document.getElementById("cpg-center-select");
var centerName = document.getElementById("cpg-center-name");
if (centerSel && centerName) {
function syncCenter() {
var opt = centerSel.options[centerSel.selectedIndex];
if (opt && opt.value) centerName.value = opt.getAttribute("data-name") || opt.text;
}
centerSel.addEventListener("change", syncCenter);
// 신규일 때만 자동 동기화 (수정 시 기존 스냅샷 보존)
if (!centerName.value) syncCenter();
}
})();
+1
View File
@@ -26,6 +26,7 @@ MODULE_KEYS: tuple[str, ...] = (
"order", "order",
"expense", "expense",
"vacation", "vacation",
"cupang",
"expense_approver", "expense_approver",
"vacation_approver", "vacation_approver",
) )
+36
View File
@@ -8,6 +8,7 @@
| `orderlist_db` | 주문 수집·분석·관리 (구 `orderlist_app`) | | `orderlist_db` | 주문 수집·분석·관리 (구 `orderlist_app`) |
| `return_db` | 반품·교환·CS 데이터 | | `return_db` | 반품·교환·CS 데이터 |
| `expense_db` | 개인경비 / 법인카드 사용내역 / 정산 | | `expense_db` | 개인경비 / 법인카드 사용내역 / 정산 |
| `cupang_db` | 쿠팡 밀크런 출고 묶음 / 출고 라인 / 입고센터 / 박스 입수량 규칙 |
--- ---
@@ -186,6 +187,41 @@ docker exec -e EXPENSE_DB_URL="$EXPENSE_DB_URL" -it dbx-main \
--- ---
## cupang_db 스키마 / 초기화
DDL: `scripts/sql/cupang_db_init.sql` (멱등). DB·역할(`cupang_app`)·테이블·인덱스·트리거·센터 seed 를 한 번에 생성. **JSON 폴백 없음**`CUPANG_DB_URL` 미설정 시 모듈이 "설정 필요" 안내만 표시.
테이블:
| 테이블 | 용도 |
| --- | --- |
| `cupang_centers` | 입고센터. `active=false` 로 비활성화(사용 중이면 hard delete 금지) |
| `cupang_box_rules` | 제품코드별 박스당 입수량(`units_per_box`). `product_code` UNIQUE |
| `cupang_shipments` | 출고 묶음 헤더 (작성일/출고일/센터입고일/센터/출고방식/상태/작업자/메모) |
| `cupang_shipment_lines` | 출고 라인. `shipment_id` FK ON DELETE CASCADE. `UNIQUE(shipment_id, line_no)` |
`status` 허용값: `작성중`, `출고준비`, `출고완료`, `센터입고완료`, `취소`. 삭제는 기본 soft delete(`status='취소'`).
박스 계산은 서버(`store.compute_boxes`)에서 재계산: `required_boxes = ceil(quantity / units_per_box)`. 클라이언트 계산은 미리보기용.
상품은 `cupang_db` 에 복제 저장하지 않는다. 라인에는 `product_code` + `product_name_snapshot` 만 보존(과거 명칭 보존). 상품 검색은 `itemcode_db` **읽기 전용**(`ITEMCODE_DB_URL`, 미설정 시 수동 입력).
### 운영 서버 초기화 (1회, 사용자 승인 후)
```bash
read -s -p "cupang_app password: " APP_PWD; echo
docker exec -i postgres-db psql -U postgres \
-v app_password="$APP_PWD" \
< scripts/sql/cupang_db_init.sql
# main-app .env 에 추가:
# CUPANG_DB_URL=postgresql://cupang_app:<APP_PWD>@postgres-db:5432/cupang_db
cd /opt/www/main && docker compose up -d --build
```
> 멱등 스크립트. 기존 DB 가 있으면 DROP 하지 않음. itemcode_db 는 건드리지 않음.
---
## 백업 / 복구 (안전 절차) ## 백업 / 복구 (안전 절차)
### 백업 ### 백업
+2
View File
@@ -116,6 +116,8 @@ docker compose down # 컨테이너 제거 (볼륨 유지)
| `CUSTOMER_ORDER_LIST_URL` | 고객 주문리스트 프로그램 버튼 이동 주소 | | `CUSTOMER_ORDER_LIST_URL` | 고객 주문리스트 프로그램 버튼 이동 주소 |
| `*_DB_HOST`, `*_DB_USER`, `*_DB_PASSWORD`, `*_DB_NAME` | 각 PostgreSQL DB 접속 정보 | | `*_DB_HOST`, `*_DB_USER`, `*_DB_PASSWORD`, `*_DB_NAME` | 각 PostgreSQL DB 접속 정보 |
| `EXPENSE_DB_URL` | 개인경비 DB DSN (예: `postgresql://expense_app:<pwd>@postgres-db:5432/expense_db`). 미설정 시 JSON 폴백 | | `EXPENSE_DB_URL` | 개인경비 DB DSN (예: `postgresql://expense_app:<pwd>@postgres-db:5432/expense_db`). 미설정 시 JSON 폴백 |
| `CUPANG_DB_URL` | 쿠팡 밀크런 DB DSN (예: `postgresql://cupang_app:<pwd>@postgres-db:5432/cupang_db`). **필수** — 미설정 시 모듈 비활성(설정 필요 안내) |
| `ITEMCODE_DB_URL` | 상품 검색용 itemcode_db 읽기 전용 DSN. 미설정 시 검색 비활성(수동 입력). 테이블/컬럼은 `ITEMCODE_TABLE`/`ITEMCODE_CODE_COL`/`ITEMCODE_NAME_COL`/`ITEMCODE_TYPE_COL` 또는 `ITEMCODE_SEARCH_SQL` 로 지정 |
| `DATA_DIR` | 영구 데이터 경로 (Docker 볼륨 마운트). 첨부파일은 `$DATA_DIR/uploads/expense/{item_id}/` 에 저장 | | `DATA_DIR` | 영구 데이터 경로 (Docker 볼륨 마운트). 첨부파일은 `$DATA_DIR/uploads/expense/{item_id}/` 에 저장 |
--- ---
+3 -1
View File
@@ -18,6 +18,7 @@
| 반품관리 | 반품/교환 접수, 처리, 환불 연계 | | 반품관리 | 반품/교환 접수, 처리, 환불 연계 |
| 외부 연동 | 쇼핑몰·통합관리·택배사·문자 API 연동 | | 외부 연동 | 쇼핑몰·통합관리·택배사·문자 API 연동 |
| 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) — 결재 워크플로 / 첨부(영수증·기타) / 월별 집계 / 엑셀 내보내기 | | 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) — 결재 워크플로 / 첨부(영수증·기타) / 월별 집계 / 엑셀 내보내기 |
| 쿠팡 밀크런 | 쿠팡 출고 일정 관리 (`app/modules/cupang/`) — 월간 달력 / 출고 묶음(헤더+라인) / 박스 입수량 자동계산 / 입고센터 관리 / 엑셀 내보내기. 상품은 `itemcode_db` 읽기 전용 참조 |
| 휴가 (준비중) | 연차/반차/특별휴가 신청·잔여일수 관리 | | 휴가 (준비중) | 연차/반차/특별휴가 신청·잔여일수 관리 |
### 권한 키 (`MODULE_KEYS`) ### 권한 키 (`MODULE_KEYS`)
@@ -25,7 +26,7 @@
| 키 | 종류 | 설명 | | 키 | 종류 | 설명 |
| --- | --- | --- | | --- | --- | --- |
| `corm` / `order` | 접근 | 외부 모듈 진입 | | `corm` / `order` | 접근 | 외부 모듈 진입 |
| `expense` / `vacation` | 접근 | 내부 모듈 진입 | | `expense` / `vacation` / `cupang` | 접근 | 내부 모듈 진입 |
| `expense_approver` | 결재 | 개인경비 승인/반려/정산 | | `expense_approver` | 결재 | 개인경비 승인/반려/정산 |
| `vacation_approver` | 결재 | 휴가 승인/반려 (모듈 미개발) | | `vacation_approver` | 결재 | 휴가 승인/반려 (모듈 미개발) |
@@ -86,6 +87,7 @@ app/modules/<name>/
| `orderlist_db` | 주문 수집·분석·관리 (구 `orderlist_app`) | | `orderlist_db` | 주문 수집·분석·관리 (구 `orderlist_app`) |
| `return_db` | 반품·교환·CS 데이터 | | `return_db` | 반품·교환·CS 데이터 |
| `expense_db` | 개인경비 / 법인카드 / 정산 (`EXPENSE_DB_URL` 미설정 시 JSON 폴백) | | `expense_db` | 개인경비 / 법인카드 / 정산 (`EXPENSE_DB_URL` 미설정 시 JSON 폴백) |
| `cupang_db` | 쿠팡 밀크런 출고/입고센터/박스규칙 (`CUPANG_DB_URL` 필수, JSON 폴백 없음) |
### DB 후보 ### DB 후보
+180
View File
@@ -0,0 +1,180 @@
-- =====================================================================
-- cupang_db 초기화 스크립트 (PostgreSQL) — 쿠팡 밀크런 모듈
-- =====================================================================
-- 멱등(idempotent): 여러 번 실행해도 안전. 기존 데이터를 삭제하지 않는다.
--
-- ⚠️ 실행 전 사용자 승인 + 백업 확인 필수. DROP/TRUNCATE 없음.
--
-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db):
--
-- 1) DB / 역할 / 스키마 생성 (superuser 로 1회)
-- read -s -p "cupang_app password: " APP_PWD; echo
-- docker exec -i postgres-db psql -U postgres \
-- -v app_password="$APP_PWD" \
-- < scripts/sql/cupang_db_init.sql
--
-- 2) main-app .env 에 연결 정보 등록
-- CUPANG_DB_URL=postgresql://cupang_app:<APP_PWD>@postgres-db:5432/cupang_db
--
-- 3) main-app 재기동
-- cd /opt/www/main && docker compose up -d --build
--
-- 주의:
-- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만).
-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달.
-- - itemcode_db 는 이 스크립트가 건드리지 않는다(상품은 읽기 전용 참조).
-- =====================================================================
\set ON_ERROR_STOP on
-- DB 가 없을 때만 생성
SELECT 'CREATE DATABASE cupang_db ENCODING ''UTF8'' TEMPLATE template0'
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'cupang_db')
\gexec
-- 앱 전용 로그인 역할 (expense_app 패턴과 동일)
SELECT 'CREATE ROLE cupang_app LOGIN PASSWORD ' || quote_literal(:'app_password')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'cupang_app')
\gexec
-- 항상 최신 비밀번호로 동기화
SELECT 'ALTER ROLE cupang_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password')
\gexec
GRANT CONNECT ON DATABASE cupang_db TO cupang_app;
-- cupang_db 컨텍스트로 전환
\connect cupang_db
-- ── updated_at 자동 갱신 트리거 함수 (멱등: CREATE OR REPLACE) ──
CREATE OR REPLACE FUNCTION cupang_set_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ════════════════════════════════════════════════════════════
-- 1) 입고센터
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cupang_centers (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
active BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_cupang_centers_updated ON cupang_centers;
CREATE TRIGGER trg_cupang_centers_updated
BEFORE UPDATE ON cupang_centers
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 2) 박스 입수량 규칙
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cupang_box_rules (
id BIGSERIAL PRIMARY KEY,
product_code TEXT NOT NULL UNIQUE,
product_name_snapshot TEXT,
box_name TEXT NOT NULL DEFAULT '쿠팡박스',
units_per_box INTEGER NOT NULL CHECK (units_per_box > 0),
active BOOLEAN NOT NULL DEFAULT TRUE,
memo TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_cupang_box_rules_code ON cupang_box_rules (product_code);
DROP TRIGGER IF EXISTS trg_cupang_box_rules_updated ON cupang_box_rules;
CREATE TRIGGER trg_cupang_box_rules_updated
BEFORE UPDATE ON cupang_box_rules
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 3) 출고 묶음 (헤더)
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cupang_shipments (
id BIGSERIAL PRIMARY KEY,
document_no TEXT UNIQUE,
created_by TEXT NOT NULL,
document_date DATE NOT NULL,
ship_date DATE NOT NULL,
center_arrival_date DATE NOT NULL,
center_id BIGINT REFERENCES cupang_centers(id),
center_name_snapshot TEXT NOT NULL DEFAULT '',
ship_method TEXT NOT NULL DEFAULT '택배',
outbound_summary TEXT NOT NULL DEFAULT '',
worker TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '작성중',
memo TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_cupang_ship_doc_date ON cupang_shipments (document_date);
CREATE INDEX IF NOT EXISTS idx_cupang_ship_ship_date ON cupang_shipments (ship_date);
CREATE INDEX IF NOT EXISTS idx_cupang_ship_arr_date ON cupang_shipments (center_arrival_date);
CREATE INDEX IF NOT EXISTS idx_cupang_ship_status ON cupang_shipments (status);
CREATE INDEX IF NOT EXISTS idx_cupang_ship_center ON cupang_shipments (center_id);
DROP TRIGGER IF EXISTS trg_cupang_shipments_updated ON cupang_shipments;
CREATE TRIGGER trg_cupang_shipments_updated
BEFORE UPDATE ON cupang_shipments
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 4) 출고 라인
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cupang_shipment_lines (
id BIGSERIAL PRIMARY KEY,
shipment_id BIGINT NOT NULL REFERENCES cupang_shipments(id) ON DELETE CASCADE,
line_no INTEGER NOT NULL,
product_code TEXT NOT NULL,
product_name_snapshot TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
box_rule_id BIGINT REFERENCES cupang_box_rules(id),
units_per_box INTEGER,
calculated_boxes INTEGER,
remainder_units INTEGER,
manual_box_text TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (shipment_id, line_no)
);
CREATE INDEX IF NOT EXISTS idx_cupang_lines_shipment ON cupang_shipment_lines (shipment_id);
CREATE INDEX IF NOT EXISTS idx_cupang_lines_code ON cupang_shipment_lines (product_code);
DROP TRIGGER IF EXISTS trg_cupang_lines_updated ON cupang_shipment_lines;
CREATE TRIGGER trg_cupang_lines_updated
BEFORE UPDATE ON cupang_shipment_lines
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 5) 초기 입고센터 seed (멱등: ON CONFLICT DO NOTHING)
-- sort_order 는 목록 순서대로 부여.
-- ════════════════════════════════════════════════════════════
INSERT INTO cupang_centers (name, sort_order) VALUES
('대구3', 1), ('인천32', 2), ('이천1', 3), ('인천42', 4), ('인천26', 5),
('인천16', 6), ('인천28', 7), ('안성8', 8), ('천안8(RC)', 9), ('시흥2', 10),
('인천36', 11), ('MGMH5', 12), ('XRC10(RC)', 13), ('인천14', 14), ('경기광주5', 15),
('경기광주3', 16), ('XRC06(RC)', 17), ('용인1', 18), ('인천30', 19), ('마장1', 20),
('안성4', 21), ('대구6', 22), ('전라광주2', 23), ('창원1', 24), ('고양1', 25),
('동탄1', 26), ('이천4', 27), ('XRC09(RC)', 28)
ON CONFLICT (name) DO NOTHING;
-- ════════════════════════════════════════════════════════════
-- 6) 권한 (cupang_app: CRUD only)
-- ════════════════════════════════════════════════════════════
GRANT USAGE ON SCHEMA public TO cupang_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON
cupang_centers, cupang_box_rules, cupang_shipments, cupang_shipment_lines
TO cupang_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO cupang_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO cupang_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO cupang_app;
SELECT 'cupang_db ready' AS status;