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
+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