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
# ════════════════════════════════════════════════════════════
+97
View File
@@ -251,3 +251,100 @@ def build_box_list_workbook(shipment: dict[str, Any], box_list: list[dict[str, A
ws.row_dimensions[1].height = 24
return wb
# ── 쿠팡 로켓 매출 ────────────────────────────────────────────
SALES_HEADERS = [
"주차", "순번", "발주번호", "발주유형", "발주일", "출고일", "센터입고일",
"SKUID", "바코드", "품목", "수량", "입고센터",
"공급단가", "공급가", "원가(단가)", "원가(발주량)",
"피킹비단가", "피킹비합계", "밀크런/쉽먼트", "물류비합계", "물류비중(%)",
"마진", "마진율(%)", "재고차감",
]
SALES_KEYS = [
"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",
]
SALES_WIDTHS_PX = {
1: 150, 2: 50, 3: 90, 4: 80, 5: 90, 6: 90, 7: 90, 8: 80, 9: 110,
10: 130, 11: 60, 12: 80, 13: 80, 14: 100, 15: 80, 16: 100,
17: 80, 18: 90, 19: 100, 20: 100, 21: 80, 22: 100, 23: 80, 24: 140,
}
MONEY_COLS = {13, 14, 15, 16, 17, 18, 19, 20, 22}
RATE_COLS = {21, 23}
def build_sales_workbook(rows: list[dict[str, Any]], totals: dict[str, Any]) -> Any:
"""매출 조회 결과 → xlsx. 마지막 행에 합계."""
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
wb = Workbook()
ws = wb.active
ws.title = "쿠팡 로켓 매출"
thin = Side(style="thin", color="000000")
box = Border(left=thin, right=thin, top=thin, bottom=thin)
center_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
left_align = Alignment(horizontal="left", vertical="center")
right_align = Alignment(horizontal="right", vertical="center")
header_fill = PatternFill("solid", fgColor=HEADER_BG)
for idx, name in enumerate(SALES_HEADERS, start=1):
cell = ws.cell(row=1, column=idx, value=name)
cell.font = Font(bold=True)
cell.alignment = center_align
cell.border = box
cell.fill = header_fill
for r_i, row in enumerate(rows, start=2):
for c_i, key in enumerate(SALES_KEYS, start=1):
value = row.get(key)
if key in ("quantity", "seq"):
value = int(value) if value not in (None, "") else None
elif c_i in MONEY_COLS or c_i in RATE_COLS:
value = float(value or 0)
cell = ws.cell(row=r_i, column=c_i, value=value)
cell.border = box
if c_i in MONEY_COLS:
cell.number_format = "#,##0"
cell.alignment = right_align
elif c_i in RATE_COLS:
cell.number_format = "0.00"
cell.alignment = right_align
elif c_i in (10, 1, 24):
cell.alignment = left_align
else:
cell.alignment = center_align
last = len(rows) + 2
total_fill = PatternFill("solid", fgColor="F2F2F2")
ws.cell(row=last, column=1, value=f"합계 {totals.get('count', 0)}")
for col, key in ((11, "quantity"), (14, "supply_amount"), (16, "cost_amount"),
(20, "logistics_total"), (22, "margin")):
ws.cell(row=last, column=col, value=float(totals.get(key) or 0))
ws.cell(row=last, column=21, value=float(totals.get("logistics_ratio") or 0))
ws.cell(row=last, column=23, value=float(totals.get("margin_rate") or 0))
for col in range(1, len(SALES_HEADERS) + 1):
cell = ws.cell(row=last, column=col)
cell.font = Font(bold=True)
cell.border = box
cell.fill = total_fill
if col in MONEY_COLS or col == 11:
cell.number_format = "#,##0"
cell.alignment = right_align
elif col in RATE_COLS:
cell.number_format = "0.00"
cell.alignment = right_align
for col, px in SALES_WIDTHS_PX.items():
ws.column_dimensions[get_column_letter(col)].width = round(px / PX_PER_CHAR, 2)
ws.freeze_panes = "A2"
ws.auto_filter.ref = f"A1:{get_column_letter(len(SALES_HEADERS))}{max(last - 1, 1)}"
return wb
+214
View File
@@ -557,6 +557,220 @@ async def day_delete(
)
# ════════════════════════════════════════════════════════════
# 쿠팡 로켓 매출 (cupang_sales)
# ════════════════════════════════════════════════════════════
def _sales_totals(rows: list[dict[str, Any]]) -> dict[str, Any]:
"""조회 결과 합계 — 화면 KPI 와 엑셀 하단에 같이 쓴다."""
total = {
"count": len(rows),
"quantity": 0,
"supply_amount": 0.0,
"cost_amount": 0.0,
"logistics_total": 0.0,
"margin": 0.0,
}
for r in rows:
total["quantity"] += int(r.get("quantity") or 0)
for key in ("supply_amount", "cost_amount", "logistics_total", "margin"):
total[key] += float(r.get(key) or 0)
supply = total["supply_amount"]
total["margin_rate"] = round(total["margin"] / supply * 100, 2) if supply else 0.0
total["logistics_ratio"] = (
round(total["logistics_total"] / supply * 100, 2) if supply else 0.0
)
return total
def _sales_filters(request: Request) -> dict[str, str]:
q = request.query_params
return {
"date_from": (q.get("from") or "").strip(),
"date_to": (q.get("to") or "").strip(),
"center_name": (q.get("center") or "").strip(),
"keyword": (q.get("q") or "").strip(),
"po_type": (q.get("type") or "").strip(),
}
@router.get("/sales", response_class=HTMLResponse)
async def sales_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
filters = _sales_filters(request)
rows = store.list_sales(**filters)
weekly = {w["week_label"]: w for w in store.list_sales_weekly()}
# 주차별 광고비/할인/장려금은 조회에 걸린 주차만 더한다.
weeks = {r.get("week_label") for r in rows if r.get("week_label")}
extra = {"ad_cost": 0.0, "promo_discount": 0.0, "incentive": 0.0}
for label in weeks:
w = weekly.get(label)
if not w:
continue
for key in extra:
extra[key] += float(w.get(key) or 0)
totals = _sales_totals(rows)
totals.update(extra)
totals["net_margin"] = (
totals["margin"] - extra["ad_cost"] - extra["promo_discount"] + extra["incentive"]
)
totals["net_margin_rate"] = (
round(totals["net_margin"] / totals["supply_amount"] * 100, 2)
if totals["supply_amount"] else 0.0
)
return render_template(
request,
"cupang/sales.html",
{
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"),
"page_title": "쿠팡 로켓 매출",
"page_subtitle": "발주 라인별 공급가·원가·물류비·마진",
"rows": rows,
"totals": totals,
"filters": filters,
"centers": store.sales_centers(),
"po_types": ["일반", "벤더플렉스"],
"weekly": sorted(weekly.values(), key=lambda w: w.get("week_from") or ""),
},
)
@router.post("/sales/api")
async def sales_create(
request: Request,
payload: dict[str, Any] = Body(...),
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
row = store.create_sale(data=payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return JSONResponse({"ok": True, "row": row})
@router.post("/sales/api/{sale_id:int}")
async def sales_update(
request: Request,
sale_id: int,
payload: dict[str, Any] = Body(...),
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
row = store.update_sale(sale_id=sale_id, data=payload)
except KeyError:
raise HTTPException(status_code=404, detail="매출 행을 찾을 수 없습니다.")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return JSONResponse({"ok": True, "row": row})
@router.post("/sales/api/{sale_id:int}/delete")
async def sales_delete(
request: Request,
sale_id: int,
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
store.delete_sale(sale_id=sale_id)
except KeyError:
raise HTTPException(status_code=404, detail="매출 행을 찾을 수 없습니다.")
return JSONResponse({"ok": True})
@router.post("/sales/upload")
async def sales_upload(
request: Request,
files: list[UploadFile] = File(...),
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
"""구글 시트 양식 그대로의 xlsx/csv 를 올려 일괄 등록(같은 라인은 덮어씀)."""
from .sales_import import parse_upload # noqa: WPS433
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
saved = 0
weekly_saved = 0
names: list[str] = []
for up in files:
data = await up.read()
if not data:
continue
try:
parsed = parse_upload(up.filename or "", data)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"{up.filename}: {exc}")
except Exception as exc: # noqa: BLE001 - 파일 형식 문제를 사용자에게 알린다
raise HTTPException(
status_code=400, detail=f"{up.filename}: 읽을 수 없습니다 ({type(exc).__name__})"
)
if parsed["lines"]:
saved += store.upsert_sales_bulk(parsed["lines"])["saved"]
for wk in parsed["weeklies"]:
try:
store.upsert_sales_weekly(data=wk)
weekly_saved += 1
except ValueError:
continue
names.append(up.filename or "")
if not saved and not weekly_saved:
raise HTTPException(status_code=400, detail="읽어들인 매출 행이 없습니다.")
return JSONResponse(
{"ok": True, "saved": saved, "weekly": weekly_saved, "files": names}
)
@router.get("/sales/export.xlsx")
async def sales_export_xlsx(request: Request) -> Any:
"""현재 조회 조건 그대로 엑셀 다운로드."""
from io import BytesIO # noqa: WPS433
from fastapi.responses import StreamingResponse # noqa: WPS433
from .export import build_sales_workbook # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
store, _user = guard
filters = _sales_filters(request)
rows = store.list_sales(**filters)
wb = build_sales_workbook(rows, _sales_totals(rows))
buf = BytesIO()
wb.save(buf)
buf.seek(0)
stamp = (filters["date_from"] or "all").replace("-", "")
return StreamingResponse(
buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="cupang_sales_{stamp}.xlsx"'},
)
# ════════════════════════════════════════════════════════════
# 상자 입수량 관리
# ════════════════════════════════════════════════════════════
+161
View File
@@ -0,0 +1,161 @@
"""쿠팡 로켓 매출 시트 파서 (xlsx / csv).
구글 시트 "쿠팡 로켓 매출" 양식을 그대로 읽는다.
1~3행 병합 머리글
4행~ 발주 라인 1건 = 1행
· 주차 소계 행: 광고비(CPC)/할인 프로모션/장려금이 여기에만 있다
· 월계/총계 행: 저장하지 않는다(화면에서 합산)
열 순서(0-based)
0 구분 1 순번 2 발주번호 3 발주유형 4 발주일 5 출고일 6 센터입고일
7 SKUID 8 바코드 9 품목 10 수량 11 입고센터
12 공급단가 13 공급가 14 원가(단가) 15 원가(발주량)
16 피킹비 단가 17 피킹비 합계 18 밀크런/쉽먼트 19 물류비 합계 20 물류비중(%)
21 마진 22 마진율 23 광고비(CPC) 24 할인 프로모션 25 장려금
26 순마진 27 순마진율 28 사방넷 재고차감일
"""
from __future__ import annotations
import csv
import io
import re
from datetime import date, datetime
from typing import Any
COL = {
"week": 0, "seq": 1, "po_no": 2, "po_type": 3, "order_date": 4,
"ship_date": 5, "center_arrival_date": 6, "sku_id": 7, "barcode": 8,
"item_name": 9, "quantity": 10, "center_name": 11,
"supply_unit_price": 12, "supply_amount": 13,
"cost_unit_price": 14, "cost_amount": 15,
"picking_unit_price": 16, "picking_amount": 17, "milkrun_amount": 18,
"logistics_total": 19, "logistics_ratio": 20,
"margin": 21, "margin_rate": 22,
"ad_cost": 23, "promo_discount": 24, "incentive": 25,
"net_margin": 26, "net_margin_rate": 27, "stock_deduct_memo": 28,
}
BAD = {"", "-", "", "#DIV/0!", "#N/A", "#VALUE!", "#REF!"}
HEADER_ROWS = 3
def _cell(row: list[Any], key: str) -> str:
idx = COL[key]
if idx >= len(row):
return ""
value = row[idx]
if value is None:
return ""
if isinstance(value, datetime):
return value.date().isoformat()
if isinstance(value, date):
return value.isoformat()
return str(value).strip()
def _flat(text: str) -> str:
"""여러 줄 라벨을 한 줄로 — "\\n8월2주차\\n(8/11~8/17)""8월2주차(8/11~8/17)"."""
return re.sub(r"\s+", "", (text or "").strip())
def parse_rows(rows: list[list[Any]]) -> dict[str, Any]:
"""표 전체 → {lines, weeklies, skipped}. 값 정규화는 DB 계층이 한 번 더 한다."""
lines: list[dict[str, Any]] = []
weeklies: list[dict[str, Any]] = []
week_label = ""
skipped = 0
for row in rows[HEADER_ROWS:]:
if not row or not any(str(c or "").strip() for c in row):
continue
week = _flat(_cell(row, "week"))
seq = _cell(row, "seq")
po_no = _cell(row, "po_no")
if seq.replace(".0", "").isdigit() and po_no and po_no not in BAD:
if week:
week_label = week
line = {"week_label": week_label, "seq": seq.replace(".0", "")}
for field in (
"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",
):
line[field] = _cell(row, field)
line["stock_deduct_memo"] = re.sub(
r"\s+", " ", _cell(row, "stock_deduct_memo")
).strip()
# SKU/바코드가 숫자로 읽히면 소수점이 붙는다 → 정수 표기로
for key in ("sku_id", "barcode", "po_no"):
if line[key].endswith(".0"):
line[key] = line[key][:-2]
lines.append(line)
continue
# 주차 소계 — 광고비/할인/장려금만 가져온다
if week and "주차" in week:
weeklies.append({
"week_label": week_label,
"ad_cost": _cell(row, "ad_cost"),
"promo_discount": _cell(row, "promo_discount"),
"incentive": _cell(row, "incentive"),
})
continue
skipped += 1
# 주차 기간은 그 주차 라인들의 출고일 최소/최대로 채운다
span: dict[str, tuple[str, str]] = {}
for line in lines:
d = str(line.get("ship_date") or "")
if not d or d in BAD:
continue
lo, hi = span.get(line["week_label"], (d, d))
span[line["week_label"]] = (min(lo, d), max(hi, d))
seen: set[str] = set()
weekly_rows: list[dict[str, Any]] = []
for wk in weeklies:
label = wk["week_label"]
if not label or label in seen:
continue
seen.add(label)
lo, hi = span.get(label, ("", ""))
wk["week_from"], wk["week_to"] = lo, hi
weekly_rows.append(wk)
return {"lines": lines, "weeklies": weekly_rows, "skipped": skipped}
def parse_xlsx(data: bytes) -> dict[str, Any]:
from openpyxl import load_workbook # noqa: WPS433
wb = load_workbook(io.BytesIO(data), data_only=True, read_only=True)
ws = wb[wb.sheetnames[0]]
rows = [list(r) for r in ws.iter_rows(values_only=True)]
wb.close()
return parse_rows(rows)
def parse_csv(data: bytes) -> dict[str, Any]:
for encoding in ("utf-8-sig", "utf-8", "cp949"):
try:
text = data.decode(encoding)
break
except UnicodeDecodeError:
continue
else:
raise ValueError("CSV 인코딩을 알 수 없습니다(UTF-8 또는 CP949).")
return parse_rows([row for row in csv.reader(io.StringIO(text))])
def parse_upload(filename: str, data: bytes) -> dict[str, Any]:
name = (filename or "").lower()
if name.endswith(".csv"):
return parse_csv(data)
if name.endswith((".xlsx", ".xlsm")):
return parse_xlsx(data)
raise ValueError("xlsx 또는 csv 파일만 올릴 수 있습니다.")
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903o" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260904a" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903o" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260904a" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903o" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260904a" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -10,6 +10,7 @@
<div class="cpg-actions-main">
<a class="erp-btn erp-btn-primary" href="/cupang/box-calc">+ 신규 등록</a>
<span class="cpg-settings-btns">
<a class="erp-btn erp-btn-outline cpg-sales-link" href="/cupang/sales">쿠팡 로켓 매출</a>
<a class="erp-btn erp-btn-outline" href="/cupang/products">제품명 설정</a>
<a class="erp-btn erp-btn-outline" href="/cupang/box-rules">상자 입수량 설정</a>
</span>
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903o" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260904a" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -0,0 +1,316 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260904a" />{% endblock %}
{% block content %}
{# 쿠팡 로켓 매출 — 발주 라인별 공급가·원가·물류비·마진 #}
<section class="cpg cpg-sales">
<div class="erp-page-actions">
<a class="erp-btn erp-btn-primary" href="/cupang/">◀◀ 달력</a>
<label class="erp-btn erp-btn-outline cpg-sales-pick" for="cpg-sales-file">시트 업로드</label>
<input class="cpg-po-file" type="file" id="cpg-sales-file"
accept=".xlsx,.xlsm,.csv" multiple aria-label="매출 시트 선택" />
<button type="button" class="erp-btn erp-btn-outline" id="cpg-sales-add">+ 행 추가</button>
<a class="erp-btn cpg-boxno-dl" id="cpg-sales-dl" href="/cupang/sales/export.xlsx">엑셀 다운로드</a>
<span class="erp-muted" id="cpg-sales-msg"></span>
</div>
<!-- 조회 조건 -->
<form class="erp-card cpg-form-card cpg-sales-filter" method="get" action="/cupang/sales">
<label class="erp-field"><span>출고일 from</span>
<input class="erp-input" type="date" name="from" value="{{ filters.date_from }}" /></label>
<label class="erp-field"><span>to</span>
<input class="erp-input" type="date" name="to" value="{{ filters.date_to }}" /></label>
<label class="erp-field"><span>입고센터</span>
<select class="erp-select" name="center">
<option value="">전체</option>
{% for c in centers %}
<option value="{{ c }}" {% if c == filters.center_name %}selected{% endif %}>{{ c }}</option>
{% endfor %}
</select></label>
<label class="erp-field"><span>발주유형</span>
<select class="erp-select" name="type">
<option value="">전체</option>
{% for t in po_types %}
<option value="{{ t }}" {% if t == filters.po_type %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select></label>
<label class="erp-field cpg-sales-q"><span>검색</span>
<input class="erp-input" type="search" name="q" value="{{ filters.keyword }}"
placeholder="품목 · SKUID · 발주번호 · 바코드" /></label>
<div class="cpg-sales-filter-act">
<button type="submit" class="erp-btn erp-btn-primary">조회</button>
<a class="erp-btn erp-btn-outline" href="/cupang/sales">초기화</a>
</div>
</form>
<!-- 합계 -->
<div class="cpg-sum-kpis cpg-sales-kpis">
<div class="cpg-kpi">
<span class="cpg-kpi-label">공급가</span>
<strong class="cpg-kpi-value">{{ "{:,.0f}".format(totals.supply_amount) }}</strong>
<span class="cpg-kpi-hint">{{ totals.count }}건 · {{ "{:,}".format(totals.quantity) }}개</span>
</div>
<div class="cpg-kpi">
<span class="cpg-kpi-label">원가</span>
<strong class="cpg-kpi-value">{{ "{:,.0f}".format(totals.cost_amount) }}</strong>
<span class="cpg-kpi-hint">발주량 기준</span>
</div>
<div class="cpg-kpi">
<span class="cpg-kpi-label">물류비</span>
<strong class="cpg-kpi-value">{{ "{:,.0f}".format(totals.logistics_total) }}</strong>
<span class="cpg-kpi-hint">물류비중 {{ totals.logistics_ratio }}%</span>
</div>
<div class="cpg-kpi">
<span class="cpg-kpi-label">마진</span>
<strong class="cpg-kpi-value">{{ "{:,.0f}".format(totals.margin) }}</strong>
<span class="cpg-kpi-hint">마진율 {{ totals.margin_rate }}%</span>
</div>
<div class="cpg-kpi">
<span class="cpg-kpi-label">순마진</span>
<strong class="cpg-kpi-value">{{ "{:,.0f}".format(totals.net_margin) }}</strong>
<span class="cpg-kpi-hint">
광고 {{ "{:,.0f}".format(totals.ad_cost) }} · 할인 {{ "{:,.0f}".format(totals.promo_discount) }}
· 장려 {{ "{:,.0f}".format(totals.incentive) }} → {{ totals.net_margin_rate }}%
</span>
</div>
</div>
<!---->
<div class="erp-card cpg-form-card cpg-sales-card">
<div class="erp-table-wrap cpg-sales-wrap">
<table class="erp-table cpg-sales-table">
<thead>
<tr>
<th>주차</th><th>순번</th><th>발주번호</th><th>유형</th>
<th>발주일</th><th>출고일</th><th>센터입고일</th>
<th>SKUID</th><th>바코드</th><th>품목</th><th>수량</th><th>입고센터</th>
<th>공급단가</th><th>공급가</th><th>원가단가</th><th>원가</th>
<th>피킹비</th><th>밀크런</th><th>물류비</th><th>물류비중</th>
<th>마진</th><th>마진율</th><th></th>
</tr>
</thead>
<tbody id="cpg-sales-rows">
{% for r in rows %}
<tr data-id="{{ r.id }}" data-row='{{ r | tojson }}'>
<td class="cpg-sales-week" title="{{ r.week_label }}">{{ r.week_label }}</td>
<td class="cpg-calc-num">{{ r.seq or '' }}</td>
<td class="cpg-sales-mono">{{ r.po_no }}</td>
<td>{{ r.po_type }}</td>
<td class="cpg-sales-mono">{{ r.order_date }}</td>
<td class="cpg-sales-mono">{{ r.ship_date }}</td>
<td class="cpg-sales-mono">{{ r.center_arrival_date }}</td>
<td class="cpg-sales-mono">{{ r.sku_id }}</td>
<td class="cpg-sales-mono">{{ r.barcode }}</td>
<td title="{{ r.item_name }}">{{ r.item_name }}</td>
<td class="cpg-calc-num">{{ "{:,}".format(r.quantity) }}</td>
<td>{{ r.center_name }}</td>
<td class="cpg-calc-num">{{ "{:,.0f}".format(r.supply_unit_price) }}</td>
<td class="cpg-calc-num">{{ "{:,.0f}".format(r.supply_amount) }}</td>
<td class="cpg-calc-num">{{ "{:,.0f}".format(r.cost_unit_price) }}</td>
<td class="cpg-calc-num">{{ "{:,.0f}".format(r.cost_amount) }}</td>
<td class="cpg-calc-num">{{ "{:,.0f}".format(r.picking_amount) }}</td>
<td class="cpg-calc-num">{{ "{:,.0f}".format(r.milkrun_amount) }}</td>
<td class="cpg-calc-num">{{ "{:,.0f}".format(r.logistics_total) }}</td>
<td class="cpg-calc-num">{{ r.logistics_ratio }}%</td>
<td class="cpg-calc-num">{{ "{:,.0f}".format(r.margin) }}</td>
<td class="cpg-calc-num">{{ r.margin_rate }}%</td>
<td class="cpg-sales-act">
<button type="button" class="cpg-icon-btn" data-edit="{{ r.id }}" title="수정"></button>
<button type="button" class="cpg-icon-btn is-danger" data-del="{{ r.id }}" title="삭제"></button>
</td>
</tr>
{% endfor %}
{% if not rows %}
<tr><td colspan="23" class="erp-muted">조회된 매출 자료가 없습니다. 시트를 업로드하거나 행을 추가하세요.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- 행 추가/수정 -->
<div class="cpg-modal" id="cpg-sales-dlg" hidden>
<div class="cpg-modal-back" data-sales-close></div>
<div class="cpg-modal-box cpg-sales-box" role="dialog" aria-modal="true" aria-labelledby="cpg-sales-title">
<h3 id="cpg-sales-title">매출 행</h3>
<div class="cpg-sales-form" id="cpg-sales-form"></div>
<div class="cpg-dlg-actions">
<span class="erp-muted" id="cpg-sales-dlgmsg"></span>
<button type="button" class="erp-btn erp-btn-primary" id="cpg-sales-save">저장</button>
<button type="button" class="erp-btn erp-btn-outline" data-sales-close>취소</button>
</div>
</div>
</div>
<!-- 저장 중 -->
<div class="cpg-modal cpg-saving" id="cpg-sales-saving" hidden>
<div class="cpg-modal-back"></div>
<div class="cpg-modal-box cpg-saving-box" role="alertdialog" aria-live="assertive">
<div class="cpg-spinner" aria-hidden="true"></div>
<strong>처리 중…</strong>
<span class="erp-muted" id="cpg-sales-savingnote"></span>
</div>
</div>
<script>
// 매출 표 — 행 추가/수정/삭제, 시트 업로드.
(function () {
var msg = document.getElementById("cpg-sales-msg");
var dlg = document.getElementById("cpg-sales-dlg");
var form = document.getElementById("cpg-sales-form");
var dlgMsg = document.getElementById("cpg-sales-dlgmsg");
var saving = document.getElementById("cpg-sales-saving");
var savingNote = document.getElementById("cpg-sales-savingnote");
var tbody = document.getElementById("cpg-sales-rows");
if (!tbody) return;
// 입력 칸 정의 — [키, 라벨, 형식]
var FIELDS = [
["week_label", "주차", "text"],
["seq", "순번", "number"],
["po_no", "발주번호", "text"],
["po_type", "발주유형", "text"],
["order_date", "발주일", "date"],
["ship_date", "출고일", "date"],
["center_arrival_date", "센터입고일", "date"],
["sku_id", "SKUID", "text"],
["barcode", "바코드", "text"],
["item_name", "품목", "text"],
["quantity", "수량", "number"],
["center_name", "입고센터", "text"],
["supply_unit_price", "공급단가", "number"],
["supply_amount", "공급가", "number"],
["cost_unit_price", "원가(단가)", "number"],
["cost_amount", "원가(발주량)", "number"],
["picking_unit_price", "피킹비 단가", "number"],
["picking_amount", "피킹비 합계", "number"],
["milkrun_amount", "밀크런/쉽먼트", "number"],
["logistics_total", "물류비 합계", "number"],
["logistics_ratio", "물류비중(%)", "number"],
["margin", "마진", "number"],
["margin_rate", "마진율(%)", "number"],
["stock_deduct_memo", "재고차감 메모", "text"],
["memo", "메모", "text"]
];
var editing = null; // 수정 중인 id (없으면 신규)
function esc(s) { var d = document.createElement("div"); d.textContent = s == null ? "" : s; return d.innerHTML; }
function openDialog(row) {
editing = row && row.id ? row.id : null;
document.getElementById("cpg-sales-title").textContent = editing ? "매출 행 수정" : "매출 행 추가";
dlgMsg.textContent = "";
form.innerHTML = FIELDS.map(function (f) {
var v = row ? (row[f[0]] == null ? "" : row[f[0]]) : "";
return '<label class="erp-field"><span>' + esc(f[1]) + "</span>" +
'<input class="erp-input" type="' + f[2] + '" data-key="' + f[0] + '"' +
(f[2] === "number" ? ' step="any"' : "") +
' value="' + esc(v) + '" /></label>';
}).join("");
dlg.hidden = false;
}
function closeDialog() { dlg.hidden = true; }
function collect() {
var out = {};
form.querySelectorAll("[data-key]").forEach(function (el) {
out[el.getAttribute("data-key")] = el.value;
});
return out;
}
function showSaving(text) { savingNote.textContent = text || ""; saving.hidden = false; }
function hideSaving() { saving.hidden = true; }
document.getElementById("cpg-sales-add").addEventListener("click", function () {
openDialog(null);
});
tbody.addEventListener("click", function (e) {
var ed = e.target.closest("[data-edit]");
if (ed) {
var tr = ed.closest("tr");
var data = {};
try { data = JSON.parse(tr.getAttribute("data-row") || "{}"); } catch (err) { data = {}; }
openDialog(data);
return;
}
var del = e.target.closest("[data-del]");
if (del) {
var id = del.getAttribute("data-del");
if (!window.confirm("이 행을 삭제할까요?")) return;
showSaving("삭제 중");
fetch("/cupang/sales/api/" + id + "/delete", { method: "POST" })
.then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
.then(function () { window.location.reload(); })
.catch(function () { hideSaving(); msg.textContent = "삭제 실패"; });
}
});
document.getElementById("cpg-sales-save").addEventListener("click", function () {
var payload = collect();
var url = editing ? "/cupang/sales/api/" + editing : "/cupang/sales/api";
dlgMsg.textContent = "저장 중…";
fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
})
.then(function (r) {
if (!r.ok) {
return r.json().catch(function () { return {}; }).then(function (e) {
throw new Error(e.detail || "http " + r.status);
});
}
return r.json();
})
.then(function () { window.location.reload(); })
.catch(function (err) { dlgMsg.textContent = (err && err.message) || "저장 실패"; });
});
Array.prototype.forEach.call(dlg.querySelectorAll("[data-sales-close]"), function (el) {
el.addEventListener("click", closeDialog);
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !dlg.hidden) closeDialog();
});
// 시트 업로드 — 파일을 고르면 바로 올린다.
var file = document.getElementById("cpg-sales-file");
if (file) {
file.addEventListener("change", function () {
if (!file.files || !file.files.length) return;
var fd = new FormData();
Array.prototype.forEach.call(file.files, function (f) { fd.append("files", f); });
showSaving(file.files.length + "개 파일 읽는 중");
fetch("/cupang/sales/upload", { method: "POST", body: fd })
.then(function (r) {
return r.json().catch(function () { return {}; }).then(function (d) {
if (!r.ok) throw new Error(d.detail || "업로드 실패 (http " + r.status + ")");
return d;
});
})
.then(function (d) {
showSaving("저장 " + d.saved + "건 · 주차 " + d.weekly + "건 — 새로고침");
window.location.reload();
})
.catch(function (err) {
hideSaving();
msg.textContent = (err && err.message) || "업로드 실패";
});
});
}
// 엑셀 다운로드는 현재 조회 조건 그대로
var dl = document.getElementById("cpg-sales-dl");
if (dl && window.location.search) {
dl.href = "/cupang/sales/export.xlsx" + window.location.search;
}
})();
</script>
</section>
{% endblock %}
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903o" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260904a" />{% endblock %}
{% block content %}
{# 출고 묶음 보기 — 상자 계산 화면과 같은 3열 구성. 읽기 전용(수정 없음). #}