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
+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"'},
)
# ════════════════════════════════════════════════════════════
# 상자 입수량 관리
# ════════════════════════════════════════════════════════════