From 9e707be5aca898d7ca5025b54a3a657c12ee893b Mon Sep 17 00:00:00 2001 From: king Date: Fri, 29 May 2026 20:47:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(cupang):=20=EC=BF=A0=ED=8C=A1=20=EB=B0=80?= =?UTF-8?q?=ED=81=AC=EB=9F=B0=20=EB=AA=A8=EB=93=88=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(=EC=B6=9C=EA=B3=A0=20=EB=8B=AC=EB=A0=A5/=EB=B0=95=EC=8A=A4?= =?UTF-8?q?=EA=B3=84=EC=82=B0/=EC=9E=85=EA=B3=A0=EC=84=BC=ED=84=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 17 + .gitignore | 3 + CLAUDE.md | 2 + app/main.py | 20 + app/modules/cupang/__init__.py | 46 ++ app/modules/cupang/db.py | 584 ++++++++++++++ app/modules/cupang/itemcode.py | 140 ++++ app/modules/cupang/router.py | 726 ++++++++++++++++++ app/modules/cupang/store.py | 111 +++ .../cupang/templates/cupang/box_rules.html | 66 ++ .../cupang/templates/cupang/centers.html | 74 ++ .../cupang/templates/cupang/detail.html | 81 ++ app/modules/cupang/templates/cupang/form.html | 122 +++ .../cupang/templates/cupang/index.html | 87 +++ app/static/cupang.css | 111 +++ app/static/cupang.js | 174 +++++ app/store.py | 1 + docs/DATABASES.md | 36 + docs/DEPLOYMENT.md | 2 + docs/PROJECT_OVERVIEW.md | 4 +- scripts/sql/cupang_db_init.sql | 180 +++++ 21 files changed, 2586 insertions(+), 1 deletion(-) create mode 100644 app/modules/cupang/__init__.py create mode 100644 app/modules/cupang/db.py create mode 100644 app/modules/cupang/itemcode.py create mode 100644 app/modules/cupang/router.py create mode 100644 app/modules/cupang/store.py create mode 100644 app/modules/cupang/templates/cupang/box_rules.html create mode 100644 app/modules/cupang/templates/cupang/centers.html create mode 100644 app/modules/cupang/templates/cupang/detail.html create mode 100644 app/modules/cupang/templates/cupang/form.html create mode 100644 app/modules/cupang/templates/cupang/index.html create mode 100644 app/static/cupang.css create mode 100644 app/static/cupang.js create mode 100644 scripts/sql/cupang_db_init.sql diff --git a/.env.example b/.env.example index dad6d3b..bfceef3 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,20 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/ # 설정하면 PostgreSQL 사용, 미설정 시 DATA_DIR/expense.json 사용. # DB/역할 생성: scripts/sql/expense_db_init.sql 참고. # 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 ` 로 확인 후 아래 값을 채운다. +# 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= diff --git a/.gitignore b/.gitignore index 2b1c845..85649c9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ .env .env.local +# 런타임 데이터 (사용자 이메일/경비 등 PII — 커밋 금지. 운영은 DATA_DIR 볼륨) +app/data/ + # 로컬 전용 스크립트 (자격증명 포함 가능) push-gitea.bat run-local.bat diff --git a/CLAUDE.md b/CLAUDE.md index 33fd800..5cdd23f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,8 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아 - CS관리 - 반품관리 - 외부 쇼핑몰 API 연동 (카페24, 네이버 스마트스토어, 사방넷 등) +- 개인경비 (`app/modules/expense/`, `expense_db`) +- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 입수량 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용 상세는 `docs/PROJECT_OVERVIEW.md`. diff --git a/app/main.py b/app/main.py index 9239f29..0d755d7 100644 --- a/app/main.py +++ b/app/main.py @@ -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" / "" / "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": '', "corm": '', "order": '', + "cupang": '', "modules": '', } body = paths.get(name, paths["modules"]) diff --git a/app/modules/cupang/__init__.py b/app/modules/cupang/__init__.py new file mode 100644 index 0000000..b047d18 --- /dev/null +++ b/app/modules/cupang/__init__.py @@ -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() diff --git a/app/modules/cupang/db.py b/app/modules/cupang/db.py new file mode 100644 index 0000000..c4fbaac --- /dev/null +++ b/app/modules/cupang/db.py @@ -0,0 +1,584 @@ +"""cupang_db PostgreSQL 저장소. + +- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — expense 모듈과 동일 패턴. +- 연결 정보: 환경변수 `CUPANG_DB_URL` + (예: postgresql://cupang_app:@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 diff --git a/app/modules/cupang/itemcode.py b/app/modules/cupang/itemcode.py new file mode 100644 index 0000000..a93bb3d --- /dev/null +++ b/app/modules/cupang/itemcode.py @@ -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:@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() diff --git a/app/modules/cupang/router.py b/app/modules/cupang/router.py new file mode 100644 index 0000000..65bad2f --- /dev/null +++ b/app/modules/cupang/router.py @@ -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"} diff --git a/app/modules/cupang/store.py b/app/modules/cupang/store.py new file mode 100644 index 0000000..92d6400 --- /dev/null +++ b/app/modules/cupang/store.py @@ -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), + } diff --git a/app/modules/cupang/templates/cupang/box_rules.html b/app/modules/cupang/templates/cupang/box_rules.html new file mode 100644 index 0000000..9469075 --- /dev/null +++ b/app/modules/cupang/templates/cupang/box_rules.html @@ -0,0 +1,66 @@ +{% extends "erp_base.html" %} + +{% block head_extra %}{% endblock %} + +{% block content %} +
+ + + + +
+
+

박스 입수량 추가 / 수정

+ 같은 제품코드는 덮어씁니다. 쿠팡박스 1박스당 들어가는 수량. +
+
+ + + + + +
+ +
+ + +
+

입수량 규칙 ({{ box_rules|length }})

+
+
+ + + + + {% for r in box_rules %} + + + + + + + + + + {% endfor %} + +
제품코드제품명박스명입수량상태메모
{{ r.product_code }}{{ r.product_name_snapshot or '—' }}{{ r.box_name }}{{ r.units_per_box }}{% if r.active %}활성{% else %}비활성{% endif %}{{ r.memo or '—' }} + {% if r.active %} +
+ +
+ {% else %}—{% endif %} +
+ + + + +{% endblock %} diff --git a/app/modules/cupang/templates/cupang/centers.html b/app/modules/cupang/templates/cupang/centers.html new file mode 100644 index 0000000..c2b6330 --- /dev/null +++ b/app/modules/cupang/templates/cupang/centers.html @@ -0,0 +1,74 @@ +{% extends "erp_base.html" %} + +{% block head_extra %}{% endblock %} + +{% block content %} +
+ + + + +
+

입고센터 추가

+
+ + + +
+
+ + +
+

센터 목록 ({{ centers|length }})

+
+ + + + + + {% for c in centers %} + + + + + + + + {% endfor %} + +
센터명정렬상태사용중동작
+
+ + + +
+
{{ c.sort_order }} + {% if c.active %}활성 + {% else %}비활성{% endif %} + {% if c.in_use %}사용중{% else %}—{% endif %} +
+ {% if c.active %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} +
+ +
+
+
+
+

※ 출고 데이터에서 사용 중인 센터는 삭제 시 hard delete 되지 않고 비활성화됩니다.

+
+ +
+{% endblock %} diff --git a/app/modules/cupang/templates/cupang/detail.html b/app/modules/cupang/templates/cupang/detail.html new file mode 100644 index 0000000..c1b6832 --- /dev/null +++ b/app/modules/cupang/templates/cupang/detail.html @@ -0,0 +1,81 @@ +{% extends "erp_base.html" %} + +{% block head_extra %}{% endblock %} + +{% block content %} +
+ +
+ 수정 + 엑셀 + 달력으로 + +
+ + +
+ +
+ +
+
+ + +
+
+

출고 #{{ 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 %} + {{ shipment.status }} +

+
+
+
작성일
{{ shipment.document_date }}
+
출고일
{{ shipment.ship_date }}
+
센터입고일
{{ shipment.center_arrival_date }}
+
입고센터
{{ shipment.center_name_snapshot or '—' }}
+
출고방식
{{ shipment.ship_method }}
+
작업자
{{ shipment.worker or '—' }}
+
문서번호
{{ shipment.document_no or '—' }}
+
출고/박스 요약
{{ shipment.outbound_summary or '—' }}
+
메모
{{ shipment.memo or '—' }}
+
+
+ + +
+

품목 ({{ shipment.lines|length }})

+
+ + + + + + + {% for ln in shipment.lines %} + + + + + + + + + + + + {% endfor %} + +
#제품코드제품명수량입수량필요박스잔량수동보정메모
{{ ln.line_no }}{{ ln.product_code }}{{ ln.product_name_snapshot }}{{ ln.quantity }}{{ ln.units_per_box if ln.units_per_box else '미설정' }}{{ ln.calculated_boxes if ln.calculated_boxes is not none else '—' }}{{ ln.remainder_units if ln.remainder_units is not none else '—' }}{{ ln.manual_box_text or '—' }}{{ ln.memo or '—' }}
+
+
+ +
+{% endblock %} diff --git a/app/modules/cupang/templates/cupang/form.html b/app/modules/cupang/templates/cupang/form.html new file mode 100644 index 0000000..fc9c43f --- /dev/null +++ b/app/modules/cupang/templates/cupang/form.html @@ -0,0 +1,122 @@ +{% extends "erp_base.html" %} + +{% block head_extra %}{% endblock %} + +{% block content %} +
+ + {% set action = '/cupang/new' if mode == 'new' else '/cupang/' ~ shipment.id ~ '/edit' %} +
+ + + +
+

공통 헤더

+
+ + + + + + + + + + + + + {% if mode == 'new' %} + + {% endif %} + + +
+ + + +
+ + +
+
+

품목 라인

+ + {% if search_enabled %}제품코드 검색 → 제품명 자동 채움.{% else %}상품 검색 비활성(수동 입력).{% endif %} + 수량 입력 시 박스 수 자동 계산. + +
+ +
+ + + + + + + + +
#제품코드제품명수량입수량박스 계산라인메모
+
+ +
+ +
+ + {% if mode == 'edit' %} + 취소 + {% else %} + 취소 + {% endif %} +
+
+ + + +
+ + + + +{% endblock %} + +{% block scripts %}{% endblock %} diff --git a/app/modules/cupang/templates/cupang/index.html b/app/modules/cupang/templates/cupang/index.html new file mode 100644 index 0000000..056a3ea --- /dev/null +++ b/app/modules/cupang/templates/cupang/index.html @@ -0,0 +1,87 @@ +{% extends "erp_base.html" %} + +{% block head_extra %}{% endblock %} + +{% block content %} +
+ + + + + + +
+{% endblock %} diff --git a/app/static/cupang.css b/app/static/cupang.css new file mode 100644 index 0000000..cf64f18 --- /dev/null +++ b/app/static/cupang.css @@ -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); } diff --git a/app/static/cupang.js b/app/static/cupang.js new file mode 100644 index 0000000..e256655 --- /dev/null +++ b/app/static/cupang.js @@ -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 = + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + 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 = ''; + 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(); + } +})(); diff --git a/app/store.py b/app/store.py index 87f29e5..8b3f2ae 100644 --- a/app/store.py +++ b/app/store.py @@ -26,6 +26,7 @@ MODULE_KEYS: tuple[str, ...] = ( "order", "expense", "vacation", + "cupang", "expense_approver", "vacation_approver", ) diff --git a/docs/DATABASES.md b/docs/DATABASES.md index ee7c312..fc6dcea 100644 --- a/docs/DATABASES.md +++ b/docs/DATABASES.md @@ -8,6 +8,7 @@ | `orderlist_db` | 주문 수집·분석·관리 (구 `orderlist_app`) | | `return_db` | 반품·교환·CS 데이터 | | `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:@postgres-db:5432/cupang_db +cd /opt/www/main && docker compose up -d --build +``` + +> 멱등 스크립트. 기존 DB 가 있으면 DROP 하지 않음. itemcode_db 는 건드리지 않음. + +--- + ## 백업 / 복구 (안전 절차) ### 백업 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 4536bad..2156dbb 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -116,6 +116,8 @@ docker compose down # 컨테이너 제거 (볼륨 유지) | `CUSTOMER_ORDER_LIST_URL` | 고객 주문리스트 프로그램 버튼 이동 주소 | | `*_DB_HOST`, `*_DB_USER`, `*_DB_PASSWORD`, `*_DB_NAME` | 각 PostgreSQL DB 접속 정보 | | `EXPENSE_DB_URL` | 개인경비 DB DSN (예: `postgresql://expense_app:@postgres-db:5432/expense_db`). 미설정 시 JSON 폴백 | +| `CUPANG_DB_URL` | 쿠팡 밀크런 DB DSN (예: `postgresql://cupang_app:@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}/` 에 저장 | --- diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md index 2c72ab5..5fbc87c 100644 --- a/docs/PROJECT_OVERVIEW.md +++ b/docs/PROJECT_OVERVIEW.md @@ -18,6 +18,7 @@ | 반품관리 | 반품/교환 접수, 처리, 환불 연계 | | 외부 연동 | 쇼핑몰·통합관리·택배사·문자 API 연동 | | 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) — 결재 워크플로 / 첨부(영수증·기타) / 월별 집계 / 엑셀 내보내기 | +| 쿠팡 밀크런 | 쿠팡 출고 일정 관리 (`app/modules/cupang/`) — 월간 달력 / 출고 묶음(헤더+라인) / 박스 입수량 자동계산 / 입고센터 관리 / 엑셀 내보내기. 상품은 `itemcode_db` 읽기 전용 참조 | | 휴가 (준비중) | 연차/반차/특별휴가 신청·잔여일수 관리 | ### 권한 키 (`MODULE_KEYS`) @@ -25,7 +26,7 @@ | 키 | 종류 | 설명 | | --- | --- | --- | | `corm` / `order` | 접근 | 외부 모듈 진입 | -| `expense` / `vacation` | 접근 | 내부 모듈 진입 | +| `expense` / `vacation` / `cupang` | 접근 | 내부 모듈 진입 | | `expense_approver` | 결재 | 개인경비 승인/반려/정산 | | `vacation_approver` | 결재 | 휴가 승인/반려 (모듈 미개발) | @@ -86,6 +87,7 @@ app/modules// | `orderlist_db` | 주문 수집·분석·관리 (구 `orderlist_app`) | | `return_db` | 반품·교환·CS 데이터 | | `expense_db` | 개인경비 / 법인카드 / 정산 (`EXPENSE_DB_URL` 미설정 시 JSON 폴백) | +| `cupang_db` | 쿠팡 밀크런 출고/입고센터/박스규칙 (`CUPANG_DB_URL` 필수, JSON 폴백 없음) | ### DB 후보 diff --git a/scripts/sql/cupang_db_init.sql b/scripts/sql/cupang_db_init.sql new file mode 100644 index 0000000..5168c8f --- /dev/null +++ b/scripts/sql/cupang_db_init.sql @@ -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:@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;