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:
+20
@@ -13,6 +13,8 @@ from jinja2 import ChoiceLoader, FileSystemLoader
|
||||
from pydantic import BaseModel
|
||||
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 router as expense_router
|
||||
from .store import (
|
||||
@@ -31,6 +33,7 @@ MODULE_LABELS: dict[str, str] = {
|
||||
"order": "Order",
|
||||
"expense": "개인경비",
|
||||
"vacation": "휴가",
|
||||
"cupang": "쿠팡 밀크런",
|
||||
"expense_approver": "개인경비",
|
||||
"vacation_approver": "휴가",
|
||||
}
|
||||
@@ -101,6 +104,7 @@ app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="stat
|
||||
# 신규 모듈 추가 시 아래 리스트에 `BASE_DIR / "modules" / "<name>" / "templates"` 만 추가.
|
||||
_MODULE_TEMPLATE_DIRS = [
|
||||
BASE_DIR / "modules" / "expense" / "templates",
|
||||
BASE_DIR / "modules" / "cupang" / "templates",
|
||||
]
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
templates.env.loader = ChoiceLoader(
|
||||
@@ -120,9 +124,14 @@ app.state.expense_store = build_expense_store(
|
||||
dsn=env("EXPENSE_DB_URL") or None,
|
||||
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(cupang_router)
|
||||
|
||||
|
||||
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",
|
||||
"category": "관리",
|
||||
},
|
||||
{
|
||||
"key": "cupang",
|
||||
"title": "쿠팡 밀크런",
|
||||
"subtitle": "Coupang Milk-run",
|
||||
"description": "쿠팡 밀크런 출고 일정·박스 계산·입고센터를 달력에서 관리합니다.",
|
||||
"url": "/cupang/",
|
||||
"health_url": "/cupang/health",
|
||||
"status": "ready",
|
||||
"category": "운영",
|
||||
},
|
||||
{
|
||||
"key": "vacation",
|
||||
"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"/>',
|
||||
"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"/>',
|
||||
"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"/>',
|
||||
}
|
||||
body = paths.get(name, paths["modules"])
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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 %}
|
||||
@@ -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); }
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -26,6 +26,7 @@ MODULE_KEYS: tuple[str, ...] = (
|
||||
"order",
|
||||
"expense",
|
||||
"vacation",
|
||||
"cupang",
|
||||
"expense_approver",
|
||||
"vacation_approver",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user