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