From 7a01c140b0a59ad26265f1d9257a2441fcf25f5b Mon Sep 17 00:00:00 2001 From: king Date: Mon, 31 Aug 2026 16:58:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(cupang):=20=EC=B6=9C=EA=B3=A0=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20=EB=B0=95=EC=8A=A4=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EC=9C=BC=EB=A1=9C=20=EC=9D=BC=EC=9B=90?= =?UTF-8?q?=ED=99=94,=20=EB=8B=AC=EB=A0=A5=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 출고 묶음을 만드는 길이 둘(신규 등록 폼 / 박스 계산 분배 확정)이라 헷갈렸다. 폼 경로를 없애고 [+ 신규 등록] 이 박스 계산을 열게 한다. 중복이던 오른쪽 [박스 계산] 버튼은 제거. /cupang/new 는 북마크가 깨지지 않게 박스 계산으로 리다이렉트하고 POST /new(생성)는 삭제했다. 기존 묶음 수정은 그대로 동작한다. 달력 칸은 작성/출고/입고 세 배지를 늘어놓아 읽기 어려웠다. 출고 기준으로 "출고 N건 · 센터 M곳"만 남긴다. 오른쪽 상세도 같은 기준(출고일)으로 맞추고 센터별 출고방식·총 박스/개수와 상품별 수량·박스를 카드 안에 펼친다. 품목이 바로 보이므로 hover 툴팁은 제거. 임시 저장 기본 제목은 "2026.08.31(월) 오후 04시 50분" 형식으로. --- CLAUDE.md | 2 +- app/modules/cupang/router.py | 99 ++++++------------- .../cupang/templates/cupang/box_calc.html | 13 ++- .../cupang/templates/cupang/box_rules.html | 2 +- .../cupang/templates/cupang/centers.html | 2 +- .../cupang/templates/cupang/detail.html | 2 +- app/modules/cupang/templates/cupang/form.html | 2 +- .../cupang/templates/cupang/index.html | 82 ++++----------- .../cupang/templates/cupang/products.html | 2 +- app/static/cupang.css | 27 +++++ 10 files changed, 95 insertions(+), 138 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c315884..2c2ce86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아 - 반품관리 - 외부 쇼핑몰 API 연동 (카페24, 네이버 스마트스토어, 사방넷 등) - 개인경비 (`app/modules/expense/`, `expense_db`) -- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 입수량 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용 +- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용. 출고 묶음은 **박스 계산 화면의 [분배 확정] 으로만** 만든다(신규 등록 폼 없음, `/cupang/new` 는 박스 계산으로 리다이렉트). 확정 조건: 미배분 박스 0 + 담긴 센터마다 출고방식(택배/파렛트) 선택. 확정 시 센터마다 출고 묶음 1건(`status=출고준비`, 센터입고일=출고일). 작업 중 상태는 `cupang_box_calc_drafts` 에 이름 붙여 임시 저장/불러오기 - 휴가 관리 (`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/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` diff --git a/app/modules/cupang/router.py b/app/modules/cupang/router.py index 92fee16..8ae27ae 100644 --- a/app/modules/cupang/router.py +++ b/app/modules/cupang/router.py @@ -129,28 +129,43 @@ async def index(request: Request) -> HTMLResponse: store, user = guard year, month = _ym(request) - counts = store.calendar_counts(year=year, month=month) shipments = store.list_shipments(year=year, month=month) + # 달력 칸에는 출고 건수와 센터 수만 보여준다. + counts: dict[str, dict[str, Any]] = {} + for s in shipments: + d = s.get("ship_date") + if not d: + continue + cell = counts.setdefault(str(d), {"ship": 0, "centers": set()}) + cell["ship"] += 1 + cell["centers"].add(s.get("center_id") or s.get("center_name_snapshot") or "") + for cell in counts.values(): + cell["centers"] = len(cell["centers"]) + # 선택 날짜 (기본: 오늘이 이번 달이면 오늘, 아니면 1일) sel = request.query_params.get("date") or "" today = today_kst() if not sel: sel = today.isoformat() if (today.year == year and today.month == month) else f"{year:04d}-{month:02d}-01" - # 선택일에 걸친 묶음(출고일 기준 우선, 작성/입고 포함) - sel_shipments = [ - s for s in shipments - if sel in (s.get("ship_date"), s.get("document_date"), s.get("center_arrival_date")) - ] - # 각 묶음에 품목 요약(제품명/수량) 첨부 — hover 툴팁용 + # 선택일의 묶음 — 달력과 같은 기준(출고일) + sel_shipments = [s for s in shipments if s.get("ship_date") == sel] + + # 오른쪽 상세: 센터별 상품 목록·수량·박스 수·출고방식 for s in sel_shipments: full = store.get_shipment(shipment_id=s["id"]) - s["tip_items"] = [ - {"name": ln.get("product_name_snapshot") or ln.get("product_code"), - "qty": ln.get("quantity", 0)} - for ln in (full.get("lines") if full else []) + lines = (full.get("lines") if full else []) or [] + s["items"] = [ + { + "name": ln.get("product_name_snapshot") or ln.get("product_code"), + "qty": int(ln.get("quantity") or 0), + "boxes": int(ln.get("calculated_boxes") or 0), + } + for ln in lines ] + s["total_qty"] = sum(it["qty"] for it in s["items"]) + s["total_boxes"] = sum(it["boxes"] for it in s["items"]) cal = _calendar.Calendar(firstweekday=6) # 일요일 시작 weeks = cal.monthdatescalendar(year, month) @@ -216,63 +231,13 @@ def _form_context(request: Request, store: Any, user: dict[str, Any]) -> dict[st } -@router.get("/new", response_class=HTMLResponse) -async def new_form(request: Request) -> HTMLResponse: - from app.main import render_template # noqa: WPS433 +@router.get("/new") +async def new_form(request: Request) -> RedirectResponse: + """신규 등록 폼은 없앴다. 출고 묶음은 박스 계산의 [분배 확정] 으로만 만든다. - guard = _guard(request) - if not isinstance(guard, tuple): - return guard - store, user = guard - ctx = _form_context(request, store, user) - ctx.update( - { - "page_title": "쿠팡 밀크런 — 신규 등록", - "page_subtitle": "공통 헤더 1개 + 품목 라인", - "mode": "new", - "shipment": None, - "default_date": today_kst().isoformat(), - } - ) - return render_template(request, "cupang/form.html", ctx) - - -@router.post("/new") -async def create( - request: Request, - lines_json: str = Form("[]"), - document_date: str = Form(...), - ship_date: str = Form(...), - center_arrival_date: str = Form(...), - center_id: str = Form(""), - center_name_snapshot: str = Form(""), - ship_method: str = Form("택배"), - outbound_summary: str = Form(""), - worker: str = Form(""), - memo: str = Form(""), - user: dict[str, Any] = Depends(_require_user), -) -> RedirectResponse: - store = _store(request) - if store is None: - raise HTTPException(status_code=503, detail="cupang_db 미설정") - header = { - "document_date": document_date, - "ship_date": ship_date, - "center_arrival_date": center_arrival_date, - "center_id": center_id, - "center_name_snapshot": center_name_snapshot, - "ship_method": ship_method, - "outbound_summary": outbound_summary, - "worker": worker, - "memo": memo, - } - try: - ship = store.create_shipment( - created_by=user["email"], header=header, lines=_parse_lines(lines_json) - ) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) - return RedirectResponse(url=f"/cupang/{ship['id']}", status_code=303) + 예전 링크/북마크가 404 나지 않도록 박스 계산으로 보낸다. + """ + return RedirectResponse(url="/cupang/box-calc", status_code=303) @router.get("/{shipment_id:int}", response_class=HTMLResponse) diff --git a/app/modules/cupang/templates/cupang/box_calc.html b/app/modules/cupang/templates/cupang/box_calc.html index 84ae214..cd220a3 100644 --- a/app/modules/cupang/templates/cupang/box_calc.html +++ b/app/modules/cupang/templates/cupang/box_calc.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
@@ -163,7 +163,7 @@ + placeholder="예: 2026.08.31(월) 오후 04시 50분" autocomplete="off" />
@@ -1054,10 +1054,15 @@ return skipped; } + var DOW_KO = ["일", "월", "화", "수", "목", "금", "토"]; + function defaultTitle() { var d = new Date(); - function pad(n) { return (n < 10 ? "0" : "") + n; } - return (d.getMonth() + 1) + "/" + d.getDate() + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()); + var h = d.getHours(); + var ampm = h < 12 ? "오전" : "오후"; + var h12 = h % 12 || 12; + return d.getFullYear() + "." + pad2(d.getMonth() + 1) + "." + pad2(d.getDate()) + + "(" + DOW_KO[d.getDay()] + ") " + ampm + " " + pad2(h12) + "시 " + pad2(d.getMinutes()) + "분"; } function closeDraft() { draftDlg.hidden = true; } diff --git a/app/modules/cupang/templates/cupang/box_rules.html b/app/modules/cupang/templates/cupang/box_rules.html index 564105c..db9d195 100644 --- a/app/modules/cupang/templates/cupang/box_rules.html +++ b/app/modules/cupang/templates/cupang/box_rules.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/modules/cupang/templates/cupang/centers.html b/app/modules/cupang/templates/cupang/centers.html index 7302526..2fbba99 100644 --- a/app/modules/cupang/templates/cupang/centers.html +++ b/app/modules/cupang/templates/cupang/centers.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/modules/cupang/templates/cupang/detail.html b/app/modules/cupang/templates/cupang/detail.html index 9e6c001..c40a34e 100644 --- a/app/modules/cupang/templates/cupang/detail.html +++ b/app/modules/cupang/templates/cupang/detail.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/modules/cupang/templates/cupang/form.html b/app/modules/cupang/templates/cupang/form.html index e173ccf..dd607b5 100644 --- a/app/modules/cupang/templates/cupang/form.html +++ b/app/modules/cupang/templates/cupang/form.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/modules/cupang/templates/cupang/index.html b/app/modules/cupang/templates/cupang/index.html index 9cf7dde..b67d753 100644 --- a/app/modules/cupang/templates/cupang/index.html +++ b/app/modules/cupang/templates/cupang/index.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
@@ -8,12 +8,11 @@
@@ -45,9 +44,10 @@ href="/cupang/?year={{ year }}&month={{ month }}&date={{ cell.date }}"> {{ cell.day }} - {% if cell.counts.document %}작성 {{ cell.counts.document }}{% endif %} - {% if cell.counts.ship %}출고 {{ cell.counts.ship }}{% endif %} - {% if cell.counts.arrival %}입고 {{ cell.counts.arrival }}{% endif %} + {% if cell.counts.ship %} + 출고 {{ cell.counts.ship }}건 + 센터 {{ cell.counts.centers }}곳 + {% endif %} {% endfor %} @@ -66,75 +66,35 @@ {% else %}

선택한 날짜의 출고 묶음이 없습니다. - 신규 등록

+ 박스 계산에서 등록

{% endif %}
- - -
{% endblock %} -{% block scripts %} - -{% endblock %} diff --git a/app/modules/cupang/templates/cupang/products.html b/app/modules/cupang/templates/cupang/products.html index 1123c14..1f1b343 100644 --- a/app/modules/cupang/templates/cupang/products.html +++ b/app/modules/cupang/templates/cupang/products.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/static/cupang.css b/app/static/cupang.css index 7318700..eef83cd 100644 --- a/app/static/cupang.css +++ b/app/static/cupang.css @@ -809,6 +809,33 @@ body.cpg-dragging { cursor: grabbing; user-select: none; } .cpg-dist-card.is-drop-ready { outline: 2px dashed var(--color-rich-black); outline-offset: 2px; } .cpg-dist-card.is-drop-ready .cpg-dist-center { border-color: var(--color-midtone-gray); } +/* 달력 오른쪽 — 센터별 출고 카드 */ +.cpg-ship-card { + display: block; + padding: 10px 12px; + border: 1px solid var(--color-subtle-ash); + border-radius: 10px; + text-decoration: none; + color: inherit; +} +.cpg-ship-card:hover { border-color: var(--color-rich-black); background: var(--color-ghost-gray); } +.cpg-ship-head { display: flex; align-items: center; gap: 6px; } +.cpg-ship-head > strong { flex: 1 1 auto; min-width: 0; font-size: 14px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cpg-ship-meta { margin-top: 2px; font-size: 12px; font-family: var(--font-geist-mono); } +.cpg-ship-items { + list-style: none; margin: 8px 0 0; padding: 6px 0 0; + border-top: 1px solid var(--color-ghost-gray); +} +.cpg-ship-items > li { display: flex; align-items: baseline; gap: 6px; padding: 2px 0; font-size: 13px; } +.cpg-ship-iname { flex: 1 1 auto; min-width: 0; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cpg-ship-iqty, .cpg-ship-ibox { + flex: 0 0 auto; font-family: var(--font-geist-mono); font-size: 12px; +} +.cpg-ship-iqty { color: var(--color-midtone-gray); } +.cpg-ship-ibox { font-weight: 600; } + /* ③ 헤더의 분배 확정 버튼 */ .cpg-dist-head-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .cpg-dist-head-bar > .erp-muted { flex: 1 1 auto; min-width: 0; }