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:
@@ -31,7 +31,7 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아
|
|||||||
- 반품관리
|
- 반품관리
|
||||||
- 외부 쇼핑몰 API 연동 (카페24, 네이버 스마트스토어, 사방넷 등)
|
- 외부 쇼핑몰 API 연동 (카페24, 네이버 스마트스토어, 사방넷 등)
|
||||||
- 개인경비 (`app/modules/expense/`, `expense_db`)
|
- 개인경비 (`app/modules/expense/`, `expense_db`)
|
||||||
- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/상자 계산, 상품은 `itemcode_db` 읽기 전용. 출고 묶음은 **상자 계산 화면의 [분배 확정] 으로만** 만든다(신규 등록 폼 없음, `/cupang/new` 는 상자 계산으로 리다이렉트). 확정 조건: 미배분 상자 0 + 담긴 센터마다 출고방식(택배/파렛트) 선택. 확정 시 센터마다 출고 묶음 1건(`status=출고준비`, 센터입고일=출고일+1일, 상자 구성은 `cupang_shipments.box_plan` JSONB 에 스냅샷 — 혼합 상자 내용물·상자 종류 표시용) + 출고리스트 엑셀 자동 다운로드(`GET /cupang/export.xlsx?date=`, 시트명 YYYYMMDD). 양식 생성은 `app/modules/cupang/export.py` 한 곳에서 만들어 xlsx·구글시트가 공유. `CUPANG_SHEET_ID` + 인증(사용자 OAuth `GOOGLE_SHEETS_OAUTH_REFRESH_TOKEN` 또는 서비스 계정 `GOOGLE_SHEETS_CREDENTIALS[_JSON]`) 설정 시 확정과 동시에 Google 스프레드시트에 출고일 시트를 생성/덮어쓰기(`app/integrations/google_sheets.py`, 미설정이면 조용히 skip). 출고 삭제(건별/날짜 전체) 시에도 같은 날짜 시트를 동기화 — 남은 출고가 있으면 다시 쓰고, 없으면 시트 삭제(문서에 시트가 하나뿐이면 내용만 비움). 작업 중 상태는 `cupang_box_calc_drafts` 에 이름 붙여 임시 저장/불러오기. 쿠팡 발주 엑셀(xlsx) 다중 업로드 지원(`POST /cupang/api/box-calc/upload`, openpyxl) — F13 입고예정일의 하루 전 = 출고일, 22행부터 B=쿠팡상품코드·F=센터명·G=수량을 읽어 `cupang_products.coupang_item_code` 로 제품 매칭 후 센터별 합산 → 센터 단위로 상자 계산·자동 배분
|
- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/상자 계산, 상품은 `itemcode_db` 읽기 전용. 출고 묶음은 **상자 계산 화면의 [분배 확정] 으로만** 만든다(신규 등록 폼 없음, `/cupang/new` 는 상자 계산으로 리다이렉트). 확정 조건: 미배분 상자 0 + 담긴 센터마다 출고방식(택배/파렛트) 선택. 확정 시 센터마다 출고 묶음 1건(`status=출고준비`, 센터입고일=출고일+1일, 상자 구성은 `cupang_shipments.box_plan` JSONB 에 스냅샷 — 혼합 상자 내용물·상자 종류 표시용) + 출고리스트 엑셀 자동 다운로드(`GET /cupang/export.xlsx?date=`, 시트명 YYYYMMDD). 양식 생성은 `app/modules/cupang/export.py` 한 곳에서 만들어 xlsx·구글시트가 공유. `CUPANG_SHEET_ID` + 인증(사용자 OAuth `GOOGLE_SHEETS_OAUTH_REFRESH_TOKEN` 또는 서비스 계정 `GOOGLE_SHEETS_CREDENTIALS[_JSON]`) 설정 시 확정과 동시에 Google 스프레드시트에 출고일 시트를 생성/덮어쓰기(`app/integrations/google_sheets.py`, 미설정이면 조용히 skip). 출고 삭제(건별/날짜 전체) 시에도 같은 날짜 시트를 동기화 — 남은 출고가 있으면 다시 쓰고, 없으면 시트 삭제(문서에 시트가 하나뿐이면 내용만 비움). 작업 중 상태는 `cupang_box_calc_drafts` 에 이름 붙여 임시 저장/불러오기. 쿠팡 로켓 매출(`/cupang/sales`, `cupang_sales`·`cupang_sales_weekly`) — 발주 라인별 공급가·원가·물류비·마진 표. 기간/센터/유형/검색 조회, 행 CRUD, 구글 시트 양식 xlsx·csv 업로드(같은 발주번호+SKU+출고일+센터+수량은 덮어씀), 엑셀 다운로드. 광고비·할인 프로모션·장려금은 주차 단위(`cupang_sales_weekly`)로 보관하고 순마진은 화면에서 계산. 초기 데이터는 `scripts/sql/cupang_sales_seed.sql`. 쿠팡 발주 엑셀(xlsx) 다중 업로드 지원(`POST /cupang/api/box-calc/upload`, openpyxl) — F13 입고예정일의 하루 전 = 출고일, 22행부터 B=쿠팡상품코드·F=센터명·G=수량을 읽어 `cupang_products.coupang_item_code` 로 제품 매칭 후 센터별 합산 → 센터 단위로 상자 계산·자동 배분
|
||||||
- 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver`
|
- 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver`
|
||||||
- 말레이시아 창고 재고관리 (`app/modules/malaysia/`, `malaysia_stock_db`) — 낱개(MT/MX/MZ) 입출고·조정, 세트(MY) BOM, 일일 재고조사(세트→낱개 자동 분해), 현재고 현황. 뚜껑(MD-)은 재고 집계 제외 — 단, 창고 랙에는 위치 확인용으로 배치 가능(`store.LID_ITEMS`). 상품은 `itemcode_db` 읽기 전용. 권한키 `malaysia`
|
- 말레이시아 창고 재고관리 (`app/modules/malaysia/`, `malaysia_stock_db`) — 낱개(MT/MX/MZ) 입출고·조정, 세트(MY) BOM, 일일 재고조사(세트→낱개 자동 분해), 현재고 현황. 뚜껑(MD-)은 재고 집계 제외 — 단, 창고 랙에는 위치 확인용으로 배치 가능(`store.LID_ITEMS`). 상품은 `itemcode_db` 읽기 전용. 권한키 `malaysia`
|
||||||
- 말레이시아 배송 (`app/modules/dispatch/`, `dispatch_db`) — TikTok·Shopee 출고관리. 플랫폼별 데이터 엑셀 업로드(TikTok=03_TikTok_Order_Export.xlsx, Shopee=Packing List.Doorstep Delivery.xlsx) → 1상자=1카드 출고 작업 리스트·SKU 피킹 요약·Kagayaku 전달표 자동 생성. 1상자 묶음 기준 Package ID > Tracking ID > Order ID, 같은 상자 같은 SKU 합산. 작업 상태 토글(`dispatch_logs` 기록). 받는 사람 이름/전화/주소는 상자 단위로 저장(작업 카드 표시 + 출고 엑셀 생성용 — 개인정보). 배치 다운로드 zip 에 업로드 원본 + 취합 출고 엑셀(`YYYY.MM.DD(Ddd)_tictoc|shopee.xlsx`) 포함. 엑셀은 openpyxl 파싱/생성. 권한키 `dispatch`. 상세는 `docs/DISPATCH_MODULE.md`
|
- 말레이시아 배송 (`app/modules/dispatch/`, `dispatch_db`) — TikTok·Shopee 출고관리. 플랫폼별 데이터 엑셀 업로드(TikTok=03_TikTok_Order_Export.xlsx, Shopee=Packing List.Doorstep Delivery.xlsx) → 1상자=1카드 출고 작업 리스트·SKU 피킹 요약·Kagayaku 전달표 자동 생성. 1상자 묶음 기준 Package ID > Tracking ID > Order ID, 같은 상자 같은 SKU 합산. 작업 상태 토글(`dispatch_logs` 기록). 받는 사람 이름/전화/주소는 상자 단위로 저장(작업 카드 표시 + 출고 엑셀 생성용 — 개인정보). 배치 다운로드 zip 에 업로드 원본 + 취합 출고 엑셀(`YYYY.MM.DD(Ddd)_tictoc|shopee.xlsx`) 포함. 엑셀은 openpyxl 파싱/생성. 권한키 `dispatch`. 상세는 `docs/DISPATCH_MODULE.md`
|
||||||
|
|||||||
@@ -658,6 +658,288 @@ class CupangDBStore:
|
|||||||
_accumulate(arr, "arrival")
|
_accumulate(arr, "arrival")
|
||||||
return out
|
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
|
# 정규화 / 직렬화 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
|
ws.row_dimensions[1].height = 24
|
||||||
|
|
||||||
return wb
|
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" %}
|
{% 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 %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% 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 %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% 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 %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
<div class="cpg-actions-main">
|
<div class="cpg-actions-main">
|
||||||
<a class="erp-btn erp-btn-primary" href="/cupang/box-calc">+ 신규 등록</a>
|
<a class="erp-btn erp-btn-primary" href="/cupang/box-calc">+ 신규 등록</a>
|
||||||
<span class="cpg-settings-btns">
|
<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/products">제품명 설정</a>
|
||||||
<a class="erp-btn erp-btn-outline" href="/cupang/box-rules">상자 입수량 설정</a>
|
<a class="erp-btn erp-btn-outline" href="/cupang/box-rules">상자 입수량 설정</a>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% 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 %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<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" %}
|
{% 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 %}
|
{% block content %}
|
||||||
{# 출고 묶음 보기 — 상자 계산 화면과 같은 3열 구성. 읽기 전용(수정 없음). #}
|
{# 출고 묶음 보기 — 상자 계산 화면과 같은 3열 구성. 읽기 전용(수정 없음). #}
|
||||||
|
|||||||
@@ -1497,3 +1497,54 @@ a.cpg-boxno-dl:hover { background: #1a5c38; border-color: #1a5c38; font-weight:
|
|||||||
}
|
}
|
||||||
.cpg-calc3 > .cpg-dist-card::-webkit-scrollbar-thumb:hover { background: var(--color-midtone-gray); }
|
.cpg-calc3 > .cpg-dist-card::-webkit-scrollbar-thumb:hover { background: var(--color-midtone-gray); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════
|
||||||
|
쿠팡 로켓 매출 (/cupang/sales)
|
||||||
|
══════════════════════════════════════════════════════════════ */
|
||||||
|
.cpg-sales-link { border-color: #217346; color: #217346; font-weight: 600; }
|
||||||
|
.cpg-sales-link:hover { background: #217346; color: #fff; }
|
||||||
|
|
||||||
|
.cpg-sales { gap: 12px; }
|
||||||
|
.cpg-sales-pick { cursor: pointer; }
|
||||||
|
.cpg-sales .cpg-po-file { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||||
|
|
||||||
|
.cpg-sales-filter {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.cpg-sales-filter .erp-field { margin: 0; }
|
||||||
|
.cpg-sales-filter .erp-field > span { font-size: 11px; color: var(--color-midtone-gray); }
|
||||||
|
.cpg-sales-q { flex: 1 1 240px; min-width: 0; }
|
||||||
|
.cpg-sales-q .erp-input { width: 100%; box-sizing: border-box; }
|
||||||
|
.cpg-sales-filter-act { display: flex; gap: 6px; margin-left: auto; }
|
||||||
|
|
||||||
|
.cpg-sales-kpis { flex-shrink: 0; }
|
||||||
|
.cpg-sales-card { padding: 0; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
||||||
|
.cpg-sales-wrap { overflow: auto; max-height: calc(100vh - 330px); }
|
||||||
|
|
||||||
|
.cpg-sales-table { table-layout: auto; width: 100%; white-space: nowrap; }
|
||||||
|
.cpg-sales-table th, .cpg-sales-table td {
|
||||||
|
padding: 4px 8px; font-size: 12px;
|
||||||
|
border-bottom: 1px solid var(--color-cloud-gray);
|
||||||
|
}
|
||||||
|
.cpg-sales-table thead th {
|
||||||
|
position: sticky; top: 0; z-index: 2;
|
||||||
|
background: var(--color-ghost-gray);
|
||||||
|
font-weight: 700; text-align: center; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cpg-sales-table tbody tr:hover td { background: var(--color-ghost-gray); }
|
||||||
|
.cpg-sales-mono { font-family: var(--font-geist-mono); color: var(--color-midtone-gray); }
|
||||||
|
.cpg-sales-week { max-width: 150px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.cpg-sales-act { text-align: center; white-space: nowrap; }
|
||||||
|
|
||||||
|
.cpg-sales-box { width: min(680px, calc(100vw - 32px)); }
|
||||||
|
.cpg-sales-form {
|
||||||
|
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 12px;
|
||||||
|
max-height: 60vh; overflow-y: auto; padding-right: 4px;
|
||||||
|
}
|
||||||
|
.cpg-sales-form .erp-field { margin: 0; }
|
||||||
|
.cpg-sales-form .erp-field > span { font-size: 11px; color: var(--color-midtone-gray); }
|
||||||
|
.cpg-sales-form .erp-input { width: 100%; box-sizing: border-box; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.cpg-sales-form { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
| 반품관리 | 반품/교환 접수, 처리, 환불 연계 |
|
| 반품관리 | 반품/교환 접수, 처리, 환불 연계 |
|
||||||
| 외부 연동 | 쇼핑몰·통합관리·택배사·문자 API 연동 |
|
| 외부 연동 | 쇼핑몰·통합관리·택배사·문자 API 연동 |
|
||||||
| 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) — 결재 워크플로 / 첨부(영수증·기타) / 월별 집계 / 엑셀 내보내기 |
|
| 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) — 결재 워크플로 / 첨부(영수증·기타) / 월별 집계 / 엑셀 내보내기 |
|
||||||
| 쿠팡 밀크런 | 쿠팡 출고 일정 관리 (`app/modules/cupang/`) — 월간 달력 / 출고 묶음(헤더+라인) / 상자 입수량 자동계산 / 엑셀 내보내기. 상품은 `itemcode_db` 읽기 전용 참조 |
|
| 쿠팡 밀크런 | 쿠팡 출고 일정 관리 (`app/modules/cupang/`) — 월간 달력 / 출고 묶음(헤더+라인) / 상자 입수량 자동계산 / 로켓 매출 관리 / 엑셀 내보내기. 상품은 `itemcode_db` 읽기 전용 참조 |
|
||||||
| 휴가 (준비중) | 연차/반차/특별휴가 신청·잔여일수 관리 |
|
| 휴가 (준비중) | 연차/반차/특별휴가 신청·잔여일수 관리 |
|
||||||
| 말레이시아 재고 | 말레이시아 창고 재고관리 (`app/modules/malaysia/`) — 낱개 입출고/조정, 세트 BOM, 일일 재고조사(세트 분해 자동 반영), 현재고 현황. 상품명은 `itemcode_db` 읽기 전용 참조 |
|
| 말레이시아 재고 | 말레이시아 창고 재고관리 (`app/modules/malaysia/`) — 낱개 입출고/조정, 세트 BOM, 일일 재고조사(세트 분해 자동 반영), 현재고 현황. 상품명은 `itemcode_db` 읽기 전용 참조 |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- 쿠팡 로켓 매출 (cupang_sales / cupang_sales_weekly)
|
||||||
|
-- 구글 시트 "쿠팡 로켓 매출" 양식을 그대로 담는다.
|
||||||
|
-- · cupang_sales : 발주 라인 1건 = 1행
|
||||||
|
-- · cupang_sales_weekly : 주차 단위로만 있는 값(광고비/할인 프로모션/장려금)
|
||||||
|
-- 주차·월 소계는 저장하지 않고 화면에서 합산한다.
|
||||||
|
-- 멱등 — 여러 번 실행해도 안전.
|
||||||
|
-- 적용: docker exec -i postgres-db psql -U postgres -d cupang_db < scripts/sql/cupang_db_006_sales.sql
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cupang_sales (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
week_label TEXT NOT NULL DEFAULT '', -- 예: 8월2주차(8/11~8/17)
|
||||||
|
seq INTEGER, -- 시트의 순번
|
||||||
|
po_no TEXT NOT NULL DEFAULT '', -- 발주번호
|
||||||
|
po_type TEXT NOT NULL DEFAULT '일반',-- 발주유형(일반/벤더플렉스 등)
|
||||||
|
order_date DATE, -- 발주일
|
||||||
|
ship_date DATE, -- 핫프렌즈 출고일
|
||||||
|
center_arrival_date DATE, -- 쿠팡FC센터 입고일
|
||||||
|
sku_id TEXT NOT NULL DEFAULT '', -- SKUID
|
||||||
|
barcode TEXT NOT NULL DEFAULT '',
|
||||||
|
item_name TEXT NOT NULL DEFAULT '', -- 품목
|
||||||
|
quantity INTEGER NOT NULL DEFAULT 0,
|
||||||
|
center_name TEXT NOT NULL DEFAULT '', -- 입고센터
|
||||||
|
supply_unit_price NUMERIC(14,2) NOT NULL DEFAULT 0, -- 공급단가
|
||||||
|
supply_amount NUMERIC(16,2) NOT NULL DEFAULT 0, -- 공급가
|
||||||
|
cost_unit_price NUMERIC(14,2) NOT NULL DEFAULT 0, -- 원가(단가)
|
||||||
|
cost_amount NUMERIC(16,2) NOT NULL DEFAULT 0, -- 원가(발주량)
|
||||||
|
picking_unit_price NUMERIC(14,2) NOT NULL DEFAULT 0, -- 물류비 피킹비 단가
|
||||||
|
picking_amount NUMERIC(16,2) NOT NULL DEFAULT 0, -- 물류비 피킹비 합계
|
||||||
|
milkrun_amount NUMERIC(16,2) NOT NULL DEFAULT 0, -- 밀크런/쉽먼트
|
||||||
|
logistics_total NUMERIC(16,2) NOT NULL DEFAULT 0, -- 물류비 합계
|
||||||
|
logistics_ratio NUMERIC(9,4) NOT NULL DEFAULT 0, -- 물류비중(%)
|
||||||
|
margin NUMERIC(16,2) NOT NULL DEFAULT 0, -- 마진
|
||||||
|
margin_rate NUMERIC(9,4) NOT NULL DEFAULT 0, -- 마진율(%)
|
||||||
|
stock_deduct_memo TEXT NOT NULL DEFAULT '', -- 사방넷 재고차감일(메모형)
|
||||||
|
memo TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cupang_sales_ship_date ON cupang_sales (ship_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cupang_sales_order_date ON cupang_sales (order_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cupang_sales_po_no ON cupang_sales (po_no);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cupang_sales_center ON cupang_sales (center_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cupang_sales_sku ON cupang_sales (sku_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cupang_sales_week ON cupang_sales (week_label);
|
||||||
|
|
||||||
|
-- 같은 발주번호 + SKU + 출고일 조합은 한 번만 (업로드 재실행 시 중복 방지)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_cupang_sales_line
|
||||||
|
ON cupang_sales (po_no, sku_id, ship_date, center_name, quantity);
|
||||||
|
|
||||||
|
-- 주차 단위 비용 — 라인이 아니라 주차에만 붙는 값
|
||||||
|
CREATE TABLE IF NOT EXISTS cupang_sales_weekly (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
week_label TEXT NOT NULL UNIQUE, -- 예: 8월2주차(8/11~8/17)
|
||||||
|
week_from DATE,
|
||||||
|
week_to DATE,
|
||||||
|
ad_cost NUMERIC(16,2) NOT NULL DEFAULT 0, -- 광고비(CPC)
|
||||||
|
promo_discount NUMERIC(16,2) NOT NULL DEFAULT 0, -- 할인 프로모션
|
||||||
|
incentive NUMERIC(16,2) NOT NULL DEFAULT 0, -- 장려금
|
||||||
|
memo TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cupang_sales_weekly_from ON cupang_sales_weekly (week_from);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_cupang_sales_updated ON cupang_sales;
|
||||||
|
CREATE TRIGGER trg_cupang_sales_updated
|
||||||
|
BEFORE UPDATE ON cupang_sales
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_cupang_sales_weekly_updated ON cupang_sales_weekly;
|
||||||
|
CREATE TRIGGER trg_cupang_sales_weekly_updated
|
||||||
|
BEFORE UPDATE ON cupang_sales_weekly
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON cupang_sales, cupang_sales_weekly TO cupang_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO cupang_app;
|
||||||
|
|
||||||
|
SELECT 'cupang_sales ready' AS status;
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user