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:
@@ -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()
|
||||
Reference in New Issue
Block a user