feat(cupang): 쿠팡 로켓 매출 화면 + cupang_sales 테이블

- scripts/sql/cupang_db_006_sales.sql: cupang_sales(발주 라인) / cupang_sales_weekly(광고비·할인·장려금) 생성
- scripts/sql/cupang_sales_seed.sql: 구글 시트 초기 데이터 2,218행 + 주차비용 100건
  (공급가 합계가 시트 2024/2025 총계와 일치)
- /cupang/sales: 기간·센터·발주유형·검색 조회, 합계 KPI(공급가/원가/물류비/마진/순마진),
  행 추가·수정·삭제, 시트(xlsx·csv) 업로드 일괄 등록, 조회 조건 그대로 엑셀 다운로드
- 달력 상단에 "쿠팡 로켓 매출" 버튼 추가
- sales_import.py: 병합 머리글 3행 건너뛰고 라인/주차 소계 분리, 월계·총계는 저장하지 않음

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 16:03:08 +09:00
parent 15de38cb74
commit 0979a89ed9
15 changed files with 3551 additions and 7 deletions
+282
View File
@@ -658,6 +658,288 @@ class CupangDBStore:
_accumulate(arr, "arrival")
return out
# ════════════════════════════════════════════════════════════
# 쿠팡 로켓 매출 (cupang_sales / cupang_sales_weekly)
# 구글 시트 양식을 그대로 담는다. 주차·월 소계는 저장하지 않고 화면에서 합산.
# ════════════════════════════════════════════════════════════
SALES_FIELDS = (
"week_label", "seq", "po_no", "po_type", "order_date", "ship_date",
"center_arrival_date", "sku_id", "barcode", "item_name", "quantity",
"center_name", "supply_unit_price", "supply_amount", "cost_unit_price",
"cost_amount", "picking_unit_price", "picking_amount", "milkrun_amount",
"logistics_total", "logistics_ratio", "margin", "margin_rate",
"stock_deduct_memo", "memo",
)
def list_sales(
self,
*,
date_from: str = "",
date_to: str = "",
center_name: str = "",
keyword: str = "",
po_type: str = "",
limit: int = 5000,
) -> list[dict[str, Any]]:
"""출고일 기준 조회. 기간·센터·발주유형·검색어(품목/SKU/발주번호/바코드)."""
where: list[str] = []
params: list[Any] = []
if date_from:
where.append("ship_date >= %s")
params.append(date_from)
if date_to:
where.append("ship_date <= %s")
params.append(date_to)
if center_name:
where.append("center_name = %s")
params.append(center_name)
if po_type:
where.append("po_type = %s")
params.append(po_type)
if keyword:
where.append(
"(item_name ILIKE %s OR sku_id ILIKE %s OR po_no ILIKE %s OR barcode ILIKE %s)"
)
like = f"%{keyword}%"
params.extend([like, like, like, like])
clause = ("WHERE " + " AND ".join(where)) if where else ""
params.append(max(1, min(int(limit or 5000), 20000)))
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM cupang_sales {clause} "
"ORDER BY ship_date DESC NULLS LAST, seq ASC, id ASC LIMIT %s",
tuple(params),
).fetchall()
return [self._sales_serialize(r) for r in rows]
def get_sale(self, *, sale_id: int) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM cupang_sales WHERE id = %s", (sale_id,)
).fetchone()
return self._sales_serialize(row) if row else None
def sales_centers(self) -> list[str]:
"""필터용 — 매출 자료에 실제로 등장하는 입고센터 목록."""
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT DISTINCT center_name FROM cupang_sales "
"WHERE center_name <> '' ORDER BY center_name"
).fetchall()
return [r["center_name"] for r in rows]
def create_sale(self, *, data: dict[str, Any]) -> dict[str, Any]:
row = self._normalize_sale(data)
cols = ", ".join(self.SALES_FIELDS)
marks = ", ".join(f"%({f})s" for f in self.SALES_FIELDS)
with self._pool.connection() as conn:
created = conn.execute(
f"INSERT INTO cupang_sales ({cols}) VALUES ({marks}) RETURNING *", row
).fetchone()
return self._sales_serialize(created)
def update_sale(self, *, sale_id: int, data: dict[str, Any]) -> dict[str, Any]:
row = self._normalize_sale(data)
row["id"] = sale_id
sets = ", ".join(f"{f} = %({f})s" for f in self.SALES_FIELDS)
with self._pool.connection() as conn:
updated = conn.execute(
f"UPDATE cupang_sales SET {sets} WHERE id = %(id)s RETURNING *", row
).fetchone()
if not updated:
raise KeyError(sale_id)
return self._sales_serialize(updated)
def delete_sale(self, *, sale_id: int) -> None:
with self._pool.connection() as conn:
cur = conn.execute("DELETE FROM cupang_sales WHERE id = %s", (sale_id,))
if cur.rowcount == 0:
raise KeyError(sale_id)
def upsert_sales_bulk(self, rows: list[dict[str, Any]]) -> dict[str, int]:
"""엑셀 업로드용 — 같은 라인(발주번호+SKU+출고일+센터+수량)은 덮어쓴다."""
cols = ", ".join(self.SALES_FIELDS)
marks = ", ".join(f"%({f})s" for f in self.SALES_FIELDS)
updates = ", ".join(
f"{f} = EXCLUDED.{f}"
for f in self.SALES_FIELDS
if f not in ("po_no", "sku_id", "ship_date", "center_name", "quantity")
)
saved = 0
with self._pool.connection() as conn:
for raw in rows:
data = self._normalize_sale(raw)
conn.execute(
f"INSERT INTO cupang_sales ({cols}) VALUES ({marks}) "
"ON CONFLICT (po_no, sku_id, ship_date, center_name, quantity) "
f"DO UPDATE SET {updates}",
data,
)
saved += 1
return {"saved": saved}
# ── 주차 단위 비용 ──────────────────────────────────────
def list_sales_weekly(self) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM cupang_sales_weekly ORDER BY week_from ASC NULLS LAST, id ASC"
).fetchall()
return [self._weekly_serialize(r) for r in rows]
def upsert_sales_weekly(self, *, data: dict[str, Any]) -> dict[str, Any]:
row = {
"week_label": str(data.get("week_label") or "").strip(),
"week_from": self._date_or_none(data.get("week_from")),
"week_to": self._date_or_none(data.get("week_to")),
"ad_cost": self._num(data.get("ad_cost")),
"promo_discount": self._num(data.get("promo_discount")),
"incentive": self._num(data.get("incentive")),
"memo": str(data.get("memo") or "").strip(),
}
if not row["week_label"]:
raise ValueError("주차 이름이 필요합니다.")
with self._pool.connection() as conn:
saved = conn.execute(
"""
INSERT INTO cupang_sales_weekly
(week_label, week_from, week_to, ad_cost, promo_discount, incentive, memo)
VALUES
(%(week_label)s, %(week_from)s, %(week_to)s, %(ad_cost)s,
%(promo_discount)s, %(incentive)s, %(memo)s)
ON CONFLICT (week_label) DO UPDATE
SET week_from = EXCLUDED.week_from,
week_to = EXCLUDED.week_to,
ad_cost = EXCLUDED.ad_cost,
promo_discount = EXCLUDED.promo_discount,
incentive = EXCLUDED.incentive,
memo = EXCLUDED.memo
RETURNING *
""",
row,
).fetchone()
return self._weekly_serialize(saved)
def delete_sales_weekly(self, *, weekly_id: int) -> None:
with self._pool.connection() as conn:
cur = conn.execute("DELETE FROM cupang_sales_weekly WHERE id = %s", (weekly_id,))
if cur.rowcount == 0:
raise KeyError(weekly_id)
# ── 매출 정규화 / 직렬화 ────────────────────────────────
@staticmethod
def _num(value: Any) -> float:
"""숫자 칸 파싱 — 콤마/%/빈칸/"-"/#DIV/0! 을 모두 0 또는 숫자로."""
if value is None:
return 0.0
if isinstance(value, bool):
return 0.0
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip().replace(",", "").replace("%", "").replace("\u00a0", "")
if text in ("", "-", "", "#DIV/0!", "#N/A", "#VALUE!", "#REF!"):
return 0.0
try:
return float(text)
except ValueError:
return 0.0
@staticmethod
def _date_or_none(value: Any) -> str | None:
""""2024. 8. 12" / "2024-08-12" / date → "YYYY-MM-DD". 못 읽으면 None."""
if value in (None, "", "-"):
return None
if isinstance(value, datetime):
return value.date().isoformat()
if isinstance(value, date):
return value.isoformat()
text = str(value).strip().replace(".", "-").replace("/", "-").replace(" ", "")
text = text.strip("-")
parts = [p for p in text.split("-") if p]
if len(parts) != 3:
return None
try:
return date(int(parts[0]), int(parts[1]), int(parts[2])).isoformat()
except (TypeError, ValueError):
return None
def _normalize_sale(self, data: dict[str, Any]) -> dict[str, Any]:
def _t(key: str, limit: int = 200) -> str:
return str(data.get(key) or "").strip()[:limit]
try:
seq = int(data.get("seq")) if str(data.get("seq") or "").strip() else None
except (TypeError, ValueError):
seq = None
return {
"week_label": _t("week_label", 60),
"seq": seq,
"po_no": _t("po_no", 40),
"po_type": _t("po_type", 30) or "일반",
"order_date": self._date_or_none(data.get("order_date")),
"ship_date": self._date_or_none(data.get("ship_date")),
"center_arrival_date": self._date_or_none(data.get("center_arrival_date")),
"sku_id": _t("sku_id", 40),
"barcode": _t("barcode", 40),
"item_name": _t("item_name", 120),
"quantity": int(self._num(data.get("quantity"))),
"center_name": _t("center_name", 40),
"supply_unit_price": self._num(data.get("supply_unit_price")),
"supply_amount": self._num(data.get("supply_amount")),
"cost_unit_price": self._num(data.get("cost_unit_price")),
"cost_amount": self._num(data.get("cost_amount")),
"picking_unit_price": self._num(data.get("picking_unit_price")),
"picking_amount": self._num(data.get("picking_amount")),
"milkrun_amount": self._num(data.get("milkrun_amount")),
"logistics_total": self._num(data.get("logistics_total")),
"logistics_ratio": self._num(data.get("logistics_ratio")),
"margin": self._num(data.get("margin")),
"margin_rate": self._num(data.get("margin_rate")),
"stock_deduct_memo": _t("stock_deduct_memo", 200),
"memo": _t("memo", 500),
}
@staticmethod
def _sales_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
for key in ("order_date", "ship_date", "center_arrival_date"):
v = out.get(key)
if isinstance(v, date):
out[key] = v.isoformat()
elif v is None:
out[key] = ""
for key in (
"supply_unit_price", "supply_amount", "cost_unit_price", "cost_amount",
"picking_unit_price", "picking_amount", "milkrun_amount",
"logistics_total", "logistics_ratio", "margin", "margin_rate",
):
if out.get(key) is not None:
out[key] = float(out[key])
for key in ("created_at", "updated_at"):
v = out.get(key)
if isinstance(v, datetime):
out[key] = v.astimezone(KST).isoformat()
return out
@staticmethod
def _weekly_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
for key in ("week_from", "week_to"):
v = out.get(key)
out[key] = v.isoformat() if isinstance(v, date) else ""
for key in ("ad_cost", "promo_discount", "incentive"):
if out.get(key) is not None:
out[key] = float(out[key])
for key in ("created_at", "updated_at"):
v = out.get(key)
if isinstance(v, datetime):
out[key] = v.astimezone(KST).isoformat()
return out
# ════════════════════════════════════════════════════════════
# 정규화 / 직렬화 helpers
# ════════════════════════════════════════════════════════════