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
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"'},
|
||||
)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 상자 입수량 관리
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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열 구성. 읽기 전용(수정 없음). #}
|
||||
|
||||
Reference in New Issue
Block a user