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:
@@ -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"'},
|
||||
)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 상자 입수량 관리
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user