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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 20:47:32 +09:00
parent 6918426264
commit 9e707be5ac
21 changed files with 2586 additions and 1 deletions
+140
View File
@@ -0,0 +1,140 @@
r"""itemcode_db 읽기 전용 상품 검색.
cupang 모듈은 itemcode_db 의 상품(낱개코드/세트코드)을 **읽기만** 한다.
cupang_db 에 상품을 복제 저장하지 않는다. 출고 라인에는 product_code 와
product_name_snapshot 만 보존한다.
⚠️ itemcode_db 의 실제 테이블/컬럼명은 이 저장소(main-app)에 정의되어 있지 않다.
운영 서버에서 다음으로 먼저 구조를 확인한 뒤 환경변수를 설정해야 한다:
docker exec -it postgres-db psql -U postgres -d itemcode_db -c "\dt"
docker exec -it postgres-db psql -U postgres -d itemcode_db -c "\d <테이블명>"
환경변수 (모두 미설정 시 검색 비활성 → 폼에서 수동 입력으로 폴백):
ITEMCODE_DB_URL 읽기 전용 DSN. 예: postgresql://itemcode_ro:<pwd>@postgres-db:5432/itemcode_db
ITEMCODE_SEARCH_SQL (선택) 검색 SQL 직접 지정. 아래 자동 생성 대신 사용.
반드시 code, name, type 컬럼을 별칭으로 반환하고,
%(q)s 파라미터를 LIKE 패턴으로 받는다.
자동 생성용 (ITEMCODE_SEARCH_SQL 미설정 시):
ITEMCODE_TABLE 검색 대상 테이블/뷰 (예: products 또는 item_master)
ITEMCODE_CODE_COL 코드 컬럼명 (기본: product_code)
ITEMCODE_NAME_COL 상품명 컬럼명 (기본: product_name)
ITEMCODE_TYPE_COL (선택) 단품/세트 구분 컬럼명. 없으면 type 은 빈 문자열.
낱개코드와 세트코드가 별도 테이블이면 ITEMCODE_SEARCH_SQL 에 UNION 으로 직접 작성한다.
"""
from __future__ import annotations
import os
import re
from typing import Any
# 안전한 SQL 식별자(테이블/컬럼)만 허용 — 인젝션 방지.
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*$")
def _ident(value: str, *, what: str) -> str:
v = (value or "").strip()
if not _IDENT_RE.match(v):
raise ValueError(f"안전하지 않은 {what} 식별자: {value!r}")
return v
class ItemcodeReader:
"""itemcode_db 읽기 전용 커넥션 풀 + 상품 검색.
설정이 없거나 불완전하면 `enabled=False` 로 두고, search()는 빈 리스트를 반환한다.
앱 부팅이나 cupang 모듈 진입을 막지 않는다.
"""
def __init__(self) -> None:
self._pool: Any = None
self._sql: str | None = None
self.enabled = False
self.reason = ""
self._configure()
def _configure(self) -> None:
dsn = os.getenv("ITEMCODE_DB_URL", "").strip()
if not dsn:
self.reason = "ITEMCODE_DB_URL 미설정 — 상품 검색 비활성(수동 입력 사용)."
return
custom_sql = os.getenv("ITEMCODE_SEARCH_SQL", "").strip()
if custom_sql:
self._sql = custom_sql
else:
table = os.getenv("ITEMCODE_TABLE", "").strip()
if not table:
self.reason = (
"ITEMCODE_TABLE(또는 ITEMCODE_SEARCH_SQL) 미설정 — "
"상품 검색 비활성(수동 입력 사용)."
)
return
try:
table_id = _ident(table, what="테이블")
code_col = _ident(os.getenv("ITEMCODE_CODE_COL", "product_code"), what="코드 컬럼")
name_col = _ident(os.getenv("ITEMCODE_NAME_COL", "product_name"), what="상품명 컬럼")
type_col_raw = os.getenv("ITEMCODE_TYPE_COL", "").strip()
type_expr = _ident(type_col_raw, what="구분 컬럼") if type_col_raw else "''"
except ValueError as exc:
self.reason = f"itemcode 검색 설정 오류: {exc}"
return
self._sql = (
f"SELECT {code_col} AS code, {name_col} AS name, {type_expr} AS type "
f"FROM {table_id} "
f"WHERE {code_col} ILIKE %(q)s OR {name_col} ILIKE %(q)s "
f"ORDER BY {code_col} ASC LIMIT %(limit)s"
)
# 풀은 lazy open — 부팅 시 itemcode_db 가 잠시 끊겨도 죽지 않게.
try:
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
self._pool = ConnectionPool(
conninfo=dsn,
min_size=1,
max_size=3,
kwargs={"row_factory": dict_row, "autocommit": True},
open=False,
)
self._pool.open(wait=False)
self.enabled = True
self.reason = ""
except Exception as exc: # noqa: BLE001 — 설정/드라이버 문제로 모듈을 죽이지 않음
self.reason = f"itemcode_db 연결 풀 생성 실패: {type(exc).__name__}"
def search(self, query: str, *, limit: int = 20) -> list[dict[str, Any]]:
"""code/name 부분 일치 검색. 반환: [{"code","name","type"}].
비활성 상태이거나 조회 실패 시 빈 리스트(예외 비전파 — UI 는 수동 입력 폴백).
"""
q = (query or "").strip()
if not self.enabled or not self._pool or not self._sql or not q:
return []
like = f"%{q}%"
try:
with self._pool.connection() as conn:
rows = conn.execute(
self._sql, {"q": like, "limit": int(limit)}
).fetchall()
except Exception: # noqa: BLE001 — 비밀값 노출 없이 빈 결과. 상세는 서버 로그.
return []
out: list[dict[str, Any]] = []
for r in rows:
out.append(
{
"code": str(r.get("code") or "").strip(),
"name": str(r.get("name") or "").strip(),
"type": str(r.get("type") or "").strip(),
}
)
return out
def close(self) -> None:
if self._pool is not None:
self._pool.close()