change(cupang): 화면 문구 "박스" → "상자" 통일

템플릿·라우터 메시지·페이지 제목과 seed/DDL 기본값(쿠팡박스→쿠팡상자)까지
표기를 맞췄다. URL(/box-calc)과 코드 식별자(box_name 등)는 그대로.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 16:00:09 +09:00
parent 1c1ca69feb
commit 4a260cb161
14 changed files with 140 additions and 140 deletions
+2 -2
View File
@@ -31,10 +31,10 @@ 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` 에 이름 붙여 임시 저장/불러오기. 쿠팡 발주 엑셀(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`
- 카페24 상품관리 (`app/modules/cafe24/`, `cafe24_db`) — 카페24 관리자에 들어가지 않고 상품 상세페이지(description HTML) 조회·편집·즉시적용·예약적용·자동복원·버전 롤백·일괄수정. 카페24 OAuth/API 클라이언트는 향후 주문관리와 공유하기 위해 **공통 계층 `app/integrations/cafe24/`** 에 둔다 — 라우터에서 `httpx`/`requests` 직접 호출 금지. 토큰은 Fernet 암호화 저장(`CAFE24_TOKEN_SECRET`), 로그/화면에 토큰·시크릿 절대 미출력. 쓰기 직전 항상 카페24 현재 HTML 을 다시 읽어 `BACKUP` revision 생성(로컬 값을 현재값으로 가정 금지). 예약은 DB 저장 + 별도 worker(`app/modules/cafe24/worker.py`, compose 서비스 `dbx-cafe24-worker`)가 처리 — 웹 프로세스에서 대기하지 않는다. 권한키 `cafe24`(연결/해제는 admin 전용). 상세는 `docs/CAFE24_MODULE.md` - 카페24 상품관리 (`app/modules/cafe24/`, `cafe24_db`) — 카페24 관리자에 들어가지 않고 상품 상세페이지(description HTML) 조회·편집·즉시적용·예약적용·자동복원·버전 롤백·일괄수정. 카페24 OAuth/API 클라이언트는 향후 주문관리와 공유하기 위해 **공통 계층 `app/integrations/cafe24/`** 에 둔다 — 라우터에서 `httpx`/`requests` 직접 호출 금지. 토큰은 Fernet 암호화 저장(`CAFE24_TOKEN_SECRET`), 로그/화면에 토큰·시크릿 절대 미출력. 쓰기 직전 항상 카페24 현재 HTML 을 다시 읽어 `BACKUP` revision 생성(로컬 값을 현재값으로 가정 금지). 예약은 DB 저장 + 별도 worker(`app/modules/cafe24/worker.py`, compose 서비스 `dbx-cafe24-worker`)가 처리 — 웹 프로세스에서 대기하지 않는다. 권한키 `cafe24`(연결/해제는 admin 전용). 상세는 `docs/CAFE24_MODULE.md`
- 프로젝트 관리 (`app/modules/project/`, `project_db`) — 아사나식. 프로젝트/서브프로젝트(self-FK `parent_id`, CASCADE)·업무(`tasks`: 담당자·우선순위·시작/마감)·진행단계(`project_stages` 칸반, 생성시 기본 4단계 seed)·멤버 배정(`project_members`)·활동이력(`project_activity`). 메인 뷰 달력(FullCalendar)/타임라인(vis-timeline) 버튼 토글 + 보드(드래그로 단계 이동)/리스트. 진입 권한키 `project`(관리자 페이지 토글로 직원별 부여, admin 자동). 프로젝트 생성/삭제·사용자 배정은 `is_admin` 만, 배정 멤버(또는 owner)는 서브프로젝트/업무/단계 CRUD. 멤버 배정 후보는 `project` 권한 보유 등록 사용자에서 자동 목록(`GET /project/api/assignable-users`). 업무 배정·완료 시 관리자에게 메일(`app/mail.py` stdlib smtplib, `SMTP_*`+`PROJECT_NOTIFY_EMAIL` env, 미설정 시 조용히 skip, `BackgroundTasks` 비동기). 상세는 `docs/PROJECT_MODULE.md` - 프로젝트 관리 (`app/modules/project/`, `project_db`) — 아사나식. 프로젝트/서브프로젝트(self-FK `parent_id`, CASCADE)·업무(`tasks`: 담당자·우선순위·시작/마감)·진행단계(`project_stages` 칸반, 생성시 기본 4단계 seed)·멤버 배정(`project_members`)·활동이력(`project_activity`). 메인 뷰 달력(FullCalendar)/타임라인(vis-timeline) 버튼 토글 + 보드(드래그로 단계 이동)/리스트. 진입 권한키 `project`(관리자 페이지 토글로 직원별 부여, admin 자동). 프로젝트 생성/삭제·사용자 배정은 `is_admin` 만, 배정 멤버(또는 owner)는 서브프로젝트/업무/단계 CRUD. 멤버 배정 후보는 `project` 권한 보유 등록 사용자에서 자동 목록(`GET /project/api/assignable-users`). 업무 배정·완료 시 관리자에게 메일(`app/mail.py` stdlib smtplib, `SMTP_*`+`PROJECT_NOTIFY_EMAIL` env, 미설정 시 조용히 skip, `BackgroundTasks` 비동기). 상세는 `docs/PROJECT_MODULE.md`
+10 -10
View File
@@ -8,8 +8,8 @@
앱 계정(cupang_app)은 SELECT/INSERT/UPDATE/DELETE 권한만 받는다. 앱 계정(cupang_app)은 SELECT/INSERT/UPDATE/DELETE 권한만 받는다.
- 연결 풀은 lazy open — 부팅 시 DB 가 잠시 끊겨도 컨테이너가 죽지 않게. - 연결 풀은 lazy open — 부팅 시 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
박스 수 계산은 서버에서 `store.compute_boxes` 로 재계산하여 저장한다. 상자 수 계산은 서버에서 `store.compute_boxes` 로 재계산하여 저장한다.
클라이언트가 보낸 박스 수는 신뢰하지 않는다. 클라이언트가 보낸 상자 수는 신뢰하지 않는다.
""" """
from __future__ import annotations from __future__ import annotations
@@ -136,7 +136,7 @@ class CupangDBStore:
return {"deleted": True, "deactivated": False} return {"deleted": True, "deactivated": False}
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# 박스 입수량 규칙 (cupang_box_rules) # 상자 입수량 규칙 (cupang_box_rules)
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
def list_box_rules(self, *, include_inactive: bool = False) -> list[dict[str, Any]]: def list_box_rules(self, *, include_inactive: bool = False) -> list[dict[str, Any]]:
where = "" if include_inactive else "WHERE active = TRUE" where = "" if include_inactive else "WHERE active = TRUE"
@@ -153,7 +153,7 @@ class CupangDBStore:
product_code: str, product_code: str,
units_per_box: int, units_per_box: int,
product_name_snapshot: str = "", product_name_snapshot: str = "",
box_name: str = "쿠팡박스", box_name: str = "쿠팡상자",
memo: str = "", memo: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
code = (product_code or "").strip() code = (product_code or "").strip()
@@ -161,7 +161,7 @@ class CupangDBStore:
raise ValueError("제품코드 필수") raise ValueError("제품코드 필수")
upb = int(units_per_box) upb = int(units_per_box)
if upb <= 0: if upb <= 0:
raise ValueError("박스당 입수량은 1 이상이어야 합니다.") raise ValueError("상자당 입수량은 1 이상이어야 합니다.")
with self._pool.connection() as conn: with self._pool.connection() as conn:
row = conn.execute( row = conn.execute(
""" """
@@ -176,7 +176,7 @@ class CupangDBStore:
active = TRUE active = TRUE
RETURNING * RETURNING *
""", """,
(code, product_name_snapshot.strip(), (box_name or "쿠팡박스").strip(), upb, memo.strip()), (code, product_name_snapshot.strip(), (box_name or "쿠팡상자").strip(), upb, memo.strip()),
).fetchone() ).fetchone()
return self._rule_serialize(row) return self._rule_serialize(row)
@@ -196,8 +196,8 @@ class CupangDBStore:
raise KeyError(rule_id) raise KeyError(rule_id)
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# 박스 계산 임시 저장 (cupang_box_calc_drafts) # 상자 계산 임시 저장 (cupang_box_calc_drafts)
# payload 는 화면 상태 스냅샷(JSONB). 박스 수 계산은 불러온 뒤 # payload 는 화면 상태 스냅샷(JSONB). 상자 수 계산은 불러온 뒤
# 서버에서 다시 하므로 여기 값은 신뢰 대상이 아니다. # 서버에서 다시 하므로 여기 값은 신뢰 대상이 아니다.
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
def list_box_calc_drafts(self, *, limit: int = 50) -> list[dict[str, Any]]: def list_box_calc_drafts(self, *, limit: int = 50) -> list[dict[str, Any]]:
@@ -348,7 +348,7 @@ class CupangDBStore:
) -> dict[str, Any]: ) -> dict[str, Any]:
"""등록된 제품 수정(제품명/제품코드/쿠팡상품코드). """등록된 제품 수정(제품명/제품코드/쿠팡상품코드).
제품코드가 바뀌면 같은 코드로 연결돼 있던 박스 규칙(cupang_box_rules)의 제품코드가 바뀌면 같은 코드로 연결돼 있던 상자 규칙(cupang_box_rules)의
product_code 도 함께 옮긴다(출고 라인은 과거 기록이라 스냅샷 유지). product_code 도 함께 옮긴다(출고 라인은 과거 기록이라 스냅샷 유지).
""" """
name = (product_name or "").strip() name = (product_name or "").strip()
@@ -694,7 +694,7 @@ class CupangDBStore:
"worker": str(header.get("worker") or "").strip(), "worker": str(header.get("worker") or "").strip(),
"status": status, "status": status,
"memo": str(header.get("memo") or "").strip(), "memo": str(header.get("memo") or "").strip(),
# 확정 당시 박스 구성(제품별/혼합). 화면 표시 전용이라 형태만 검사한다. # 확정 당시 상자 구성(제품별/혼합). 화면 표시 전용이라 형태만 검사한다.
"box_plan": header.get("box_plan") if isinstance(header.get("box_plan"), list) else [], "box_plan": header.get("box_plan") if isinstance(header.get("box_plan"), list) else [],
} }
+29 -29
View File
@@ -154,7 +154,7 @@ async def index(request: Request) -> HTMLResponse:
# 선택일의 묶음 — 달력과 같은 기준(출고일) # 선택일의 묶음 — 달력과 같은 기준(출고일)
sel_shipments = [s for s in shipments if s.get("ship_date") == sel] sel_shipments = [s for s in shipments if s.get("ship_date") == sel]
# 오른쪽 상세: 센터별 상품 목록·수량·박스 수·출고방식 # 오른쪽 상세: 센터별 상품 목록·수량·상자 수·출고방식
for s in sel_shipments: for s in sel_shipments:
full = store.get_shipment(shipment_id=s["id"]) full = store.get_shipment(shipment_id=s["id"])
lines = (full.get("lines") if full else []) or [] lines = (full.get("lines") if full else []) or []
@@ -334,16 +334,16 @@ async def export_shipments_xlsx(request: Request, date: str = "") -> Any:
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@router.get("/new") @router.get("/new")
async def new_form(request: Request) -> RedirectResponse: async def new_form(request: Request) -> RedirectResponse:
"""신규 등록 폼은 없앴다. 출고 묶음은 박스 계산의 [분배 확정] 으로만 만든다. """신규 등록 폼은 없앴다. 출고 묶음은 상자 계산의 [분배 확정] 으로만 만든다.
예전 링크/북마크가 404 나지 않도록 박스 계산으로 보낸다. 예전 링크/북마크가 404 나지 않도록 상자 계산으로 보낸다.
""" """
return RedirectResponse(url="/cupang/box-calc", status_code=303) return RedirectResponse(url="/cupang/box-calc", status_code=303)
@router.get("/{shipment_id:int}", response_class=HTMLResponse) @router.get("/{shipment_id:int}", response_class=HTMLResponse)
async def detail(request: Request, shipment_id: int) -> HTMLResponse: async def detail(request: Request, shipment_id: int) -> HTMLResponse:
"""출고 묶음 보기 — 박스 계산 화면과 같은 3열 구성(읽기 전용). """출고 묶음 보기 — 상자 계산 화면과 같은 3열 구성(읽기 전용).
수정 기능은 없앴다. 잘못 만들었으면 달력에서 삭제하고 다시 확정한다. 수정 기능은 없앴다. 잘못 만들었으면 달력에서 삭제하고 다시 확정한다.
""" """
@@ -611,7 +611,7 @@ async def center_delete(
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# 박스 입수량 관리 # 상자 입수량 관리
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@router.get("/box-rules", response_class=HTMLResponse) @router.get("/box-rules", response_class=HTMLResponse)
async def box_rules_page(request: Request) -> HTMLResponse: async def box_rules_page(request: Request) -> HTMLResponse:
@@ -629,8 +629,8 @@ async def box_rules_page(request: Request) -> HTMLResponse:
"user": user, "user": user,
"is_admin": is_admin(user), "is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"), "nav_items": build_erp_nav(user, active="cupang"),
"page_title": "쿠팡 밀크런 — 박스 입수량", "page_title": "쿠팡 밀크런 — 상자 입수량",
"page_subtitle": "제품코드별 쿠팡박스 1박스당 입수량 설정", "page_subtitle": "제품코드별 쿠팡상자 1상자당 입수량 설정",
"box_rules": store.list_box_rules(include_inactive=True), "box_rules": store.list_box_rules(include_inactive=True),
"products": store.list_products(), "products": store.list_products(),
"search_enabled": bool((_itemcode(request)) and _itemcode(request).enabled), "search_enabled": bool((_itemcode(request)) and _itemcode(request).enabled),
@@ -644,7 +644,7 @@ async def box_rule_upsert(
product_code: str = Form(...), product_code: str = Form(...),
units_per_box: int = Form(...), units_per_box: int = Form(...),
product_name_snapshot: str = Form(""), product_name_snapshot: str = Form(""),
box_name: str = Form("쿠팡박스"), box_name: str = Form("쿠팡상자"),
memo: str = Form(""), memo: str = Form(""),
user: dict[str, Any] = Depends(_require_user), user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse: ) -> RedirectResponse:
@@ -681,7 +681,7 @@ async def box_rule_delete(
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# 박스 계산기 — 제품명 + 수량 → 박스 수 / 남은 낱개 # 상자 계산기 — 제품명 + 수량 → 상자 수 / 남은 낱개
# 저장하지 않는 계산 전용 화면. 규칙은 cupang_box_rules 를 그대로 사용한다. # 저장하지 않는 계산 전용 화면. 규칙은 cupang_box_rules 를 그대로 사용한다.
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@router.get("/box-calc", response_class=HTMLResponse) @router.get("/box-calc", response_class=HTMLResponse)
@@ -701,7 +701,7 @@ async def box_calc_page(request: Request) -> HTMLResponse:
"is_admin": is_admin(user), "is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"), "nav_items": build_erp_nav(user, active="cupang"),
"page_title": "쿠팡 밀크런 — 쿠팡 발주 업로드", "page_title": "쿠팡 밀크런 — 쿠팡 발주 업로드",
"page_subtitle": "쿠팡 발주 엑셀을 올리면 센터별 수량을 합산해 박스를 계산합니다.", "page_subtitle": "쿠팡 발주 엑셀을 올리면 센터별 수량을 합산해 상자를 계산합니다.",
"box_rules": store.list_box_rules(), "box_rules": store.list_box_rules(),
# 센터 선택 드롭다운은 가나다순 (한글 음절은 코드포인트 순 = 가나다순) # 센터 선택 드롭다운은 가나다순 (한글 음절은 코드포인트 순 = 가나다순)
"centers": sorted(store.list_centers(), key=lambda c: (c.get("name") or "")), "centers": sorted(store.list_centers(), key=lambda c: (c.get("name") or "")),
@@ -866,7 +866,7 @@ async def box_calc_upload(
if unknown_centers: if unknown_centers:
warnings.append("등록되지 않은 센터: " + ", ".join(sorted(unknown_centers))) warnings.append("등록되지 않은 센터: " + ", ".join(sorted(unknown_centers)))
if no_rule: if no_rule:
warnings.append("박스 입수량 미설정: " + ", ".join(sorted(no_rule))) warnings.append("상자 입수량 미설정: " + ", ".join(sorted(no_rule)))
uniq_dates = sorted(set(ship_dates)) uniq_dates = sorted(set(ship_dates))
@@ -927,7 +927,7 @@ async def box_calc_api(
payload: dict[str, Any] = Body(...), payload: dict[str, Any] = Body(...),
user: dict[str, Any] = Depends(_require_user), user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse: ) -> JSONResponse:
"""[{product_code, quantity}] → 제품별 박스 계산 + 박스명별 합계. """[{product_code, quantity}] → 제품별 상자 계산 + 상자명별 합계.
클라이언트 계산을 신뢰하지 않고 store.compute_boxes 로 서버에서 계산한다. 클라이언트 계산을 신뢰하지 않고 store.compute_boxes 로 서버에서 계산한다.
""" """
@@ -943,9 +943,9 @@ async def box_calc_api(
def _compute_boxes(store: Any, raw_items: list[Any]) -> dict[str, Any]: def _compute_boxes(store: Any, raw_items: list[Any]) -> dict[str, Any]:
"""[{product_code, quantity}] → 제품별 박스 + 자투리 혼합 박스 + 합계. """[{product_code, quantity}] → 제품별 상자 + 자투리 혼합 상자 + 합계.
박스 계산 화면(API)과 출고 상세 보기가 같은 결과를 쓰도록 한 곳에 둔다. 상자 계산 화면(API)과 출고 상세 보기가 같은 결과를 쓰도록 한 곳에 둔다.
""" """
from .store import compute_boxes # noqa: WPS433 from .store import compute_boxes # noqa: WPS433
@@ -1003,12 +1003,12 @@ def _compute_boxes(store: Any, raw_items: list[Any]) -> dict[str, Any]:
def _pack_leftovers(results: list[dict[str, Any]]) -> list[dict[str, Any]]: def _pack_leftovers(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""제품별 자투리(remainder_units)를 같은 박스명끼리 모아 혼합 박스에 담는다. """제품별 자투리(remainder_units)를 같은 상자명끼리 모아 혼합 상자에 담는다.
- 한 박스의 용량을 1 로 두고, 제품 1개가 차지하는 부피를 1/units_per_box 로 본다. - 한 상자의 용량을 1 로 두고, 제품 1개가 차지하는 부피를 1/units_per_box 로 본다.
(예: 쿠팡 2호 = 8개들이 → 1개 = 1/8 박스) (예: 쿠팡 2호 = 8개들이 → 1개 = 1/8 상자)
- 같은 박스명끼리만 섞는다. 서로 다른 입수량이 섞여도 부피 합으로 정확히 계산된다. - 같은 상자명끼리만 섞는다. 서로 다른 입수량이 섞여도 부피 합으로 정확히 계산된다.
- 한 제품의 자투리가 두 박스에 나뉘어 담기는 것은 허용(그래야 박스 수가 최소). - 한 제품의 자투리가 두 상자에 나뉘어 담기는 것은 허용(그래야 상자 수가 최소).
- 오차 없이 계산하려고 float 대신 Fraction 을 쓴다. - 오차 없이 계산하려고 float 대신 Fraction 을 쓴다.
""" """
groups: dict[str, list[dict[str, Any]]] = {} groups: dict[str, list[dict[str, Any]]] = {}
@@ -1019,7 +1019,7 @@ def _pack_leftovers(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
for box_name in sorted(groups): for box_name in sorted(groups):
# 자투리가 많은 제품부터 담아 박스 안 품목 수를 줄인다. # 자투리가 많은 제품부터 담아 상자 안 품목 수를 줄인다.
items = sorted(groups[box_name], key=lambda r: -r["remainder_units"]) items = sorted(groups[box_name], key=lambda r: -r["remainder_units"])
boxes: list[dict[str, Any]] = [] boxes: list[dict[str, Any]] = []
cur: list[dict[str, Any]] = [] cur: list[dict[str, Any]] = []
@@ -1030,7 +1030,7 @@ def _pack_leftovers(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
left = int(r["remainder_units"]) left = int(r["remainder_units"])
while left > 0: while left > 0:
take = min(left, int(free / unit)) take = min(left, int(free / unit))
if take == 0: # 남은 자리 없음 → 새 박스 if take == 0: # 남은 자리 없음 → 새 상자
boxes.append({"items": cur, "fill_percent": float(round((1 - free) * 100, 1))}) boxes.append({"items": cur, "fill_percent": float(round((1 - free) * 100, 1))})
cur, free = [], Fraction(1) cur, free = [], Fraction(1)
continue continue
@@ -1245,9 +1245,9 @@ async def product_delete(
# 분배 확정 — 센터별 출고 묶음 생성 # 분배 확정 — 센터별 출고 묶음 생성
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
def _clean_box_plan(raw: Any) -> list[dict[str, Any]]: def _clean_box_plan(raw: Any) -> list[dict[str, Any]]:
"""화면이 보낸 박스 구성을 표시용으로만 정리해서 저장한다. """화면이 보낸 상자 구성을 표시용으로만 정리해서 저장한다.
수량·박스 수는 라인(제품 합계)이 정답이고, 이 값은 "어느 상자에 무엇이 수량·상자 수는 라인(제품 합계)이 정답이고, 이 값은 "어느 상자에 무엇이
담겼는지"를 보여주기 위한 스냅샷이다. 담겼는지"를 보여주기 위한 스냅샷이다.
""" """
if not isinstance(raw, list): if not isinstance(raw, list):
@@ -1305,7 +1305,7 @@ async def box_calc_confirm(
) -> JSONResponse: ) -> JSONResponse:
"""{ship_date, centers:[{center_id, ship_method, boxes, items:[...]}]} → 센터마다 출고 묶음 1건 생성. """{ship_date, centers:[{center_id, ship_method, boxes, items:[...]}]} → 센터마다 출고 묶음 1건 생성.
화면에서 보낸 박스 수는 요약 표시용이고, 라인의 박스 수는 저장 시 화면에서 보낸 상자 수는 요약 표시용이고, 라인의 상자 수는 저장 시
서버(store.compute_boxes)가 수량과 입수량으로 다시 계산한다. 서버(store.compute_boxes)가 수량과 입수량으로 다시 계산한다.
""" """
from .store import SHIP_METHODS as _METHODS # noqa: WPS433 from .store import SHIP_METHODS as _METHODS # noqa: WPS433
@@ -1345,7 +1345,7 @@ async def box_calc_confirm(
status_code=400, detail=f"{center['name']} 의 출고방식을 선택하세요." status_code=400, detail=f"{center['name']} 의 출고방식을 선택하세요."
) )
# 같은 제품이 여러 박스로 나뉘어 담겼을 수 있으므로 제품코드로 합친다. # 같은 제품이 여러 상자로 나뉘어 담겼을 수 있으므로 제품코드로 합친다.
merged: dict[str, int] = {} merged: dict[str, int] = {}
for it in raw.get("items") or []: for it in raw.get("items") or []:
if not isinstance(it, dict): if not isinstance(it, dict):
@@ -1382,10 +1382,10 @@ async def box_calc_confirm(
except (TypeError, ValueError): except (TypeError, ValueError):
boxes = 0 boxes = 0
pieces = sum(merged.values()) pieces = sum(merged.values())
# 화면에서 고른 상자 종류별 박스 수(예: "쿠팡상자41"). 출고리스트 엑셀의 "출고" 칸. # 화면에서 고른 상자 종류별 상자 수(예: "쿠팡상자41"). 출고리스트 엑셀의 "출고" 칸.
summary = str(raw.get("box_summary") or "").strip() summary = str(raw.get("box_summary") or "").strip()
if not summary: if not summary:
summary = f"{boxes}박스 · {pieces}" if boxes else f"{pieces}" summary = f"{boxes}상자 · {pieces}" if boxes else f"{pieces}"
plans.append( plans.append(
{ {
@@ -1417,7 +1417,7 @@ async def box_calc_confirm(
"outbound_summary": plan["summary"], "outbound_summary": plan["summary"],
"worker": worker, "worker": worker,
"status": "출고준비", "status": "출고준비",
"memo": "박스 계산에서 분배 확정", "memo": "상자 계산에서 분배 확정",
"box_plan": plan["box_plan"], "box_plan": plan["box_plan"],
}, },
lines=plan["lines"], lines=plan["lines"],
@@ -1438,7 +1438,7 @@ async def box_calc_confirm(
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# 박스 계산 임시 저장 (화면 상태 스냅샷) # 상자 계산 임시 저장 (화면 상태 스냅샷)
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@router.get("/api/box-calc/drafts") @router.get("/api/box-calc/drafts")
async def box_calc_drafts_list( async def box_calc_drafts_list(
+1 -1
View File
@@ -58,7 +58,7 @@ DEFAULT_CENTERS: tuple[str, ...] = (
def compute_boxes(quantity: int, units_per_box: int | None) -> dict[str, Any]: def compute_boxes(quantity: int, units_per_box: int | None) -> dict[str, Any]:
"""수량 + 박스당 입수량으로 필요한 박스 수를 계산한다. """수량 + 상자당 입수량으로 필요한 상자 수를 계산한다.
클라이언트 계산을 신뢰하지 않고 서버에서 이 함수로 재계산한다. 클라이언트 계산을 신뢰하지 않고 서버에서 이 함수로 재계산한다.
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901o" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
@@ -16,8 +16,8 @@
{% if not box_rules %} {% if not box_rules %}
<div class="erp-card cpg-form-card"> <div class="erp-card cpg-form-card">
<p class="erp-muted"> <p class="erp-muted">
등록된 박스 입수량 규칙이 없습니다. 등록된 상자 입수량 규칙이 없습니다.
<a href="/cupang/box-rules">박스 입수량 설정</a>에서 먼저 제품별 입수량을 등록하세요. <a href="/cupang/box-rules">상자 입수량 설정</a>에서 먼저 제품별 입수량을 등록하세요.
</p> </p>
</div> </div>
{% else %} {% else %}
@@ -30,7 +30,7 @@
<div class="cpg-calc3"> <div class="cpg-calc3">
<!--박스 계산 — 제품명 + 수량만 입력 --> <!--상자 계산 — 제품명 + 수량만 입력 -->
<div class="erp-card cpg-form-card cpg-calc-card"> <div class="erp-card cpg-form-card cpg-calc-card">
<div class="cpg-card-head"> <div class="cpg-card-head">
<h2>① 쿠팡 발주 업로드</h2> <h2>① 쿠팡 발주 업로드</h2>
@@ -75,10 +75,10 @@
</div> </div>
</div> </div>
<!--박스 요약 — 센터에 배분하고 남은 수량이 실시간 반영 --> <!--상자 요약 — 센터에 배분하고 남은 수량이 실시간 반영 -->
<div class="erp-card cpg-form-card cpg-calc-sum"> <div class="erp-card cpg-form-card cpg-calc-sum">
<div class="cpg-card-head"> <div class="cpg-card-head">
<h2>박스 요약</h2> <h2>상자 요약</h2>
<span class="erp-muted">카드를 ③ 센터로 끌어다 놓거나, 카드를 클릭해 담습니다.</span> <span class="erp-muted">카드를 ③ 센터로 끌어다 놓거나, 카드를 클릭해 담습니다.</span>
</div> </div>
@@ -88,10 +88,10 @@
<div class="cpg-sum-kpis" id="cpg-sum-kpis"></div> <div class="cpg-sum-kpis" id="cpg-sum-kpis"></div>
<div class="cpg-sum-scroll"> <div class="cpg-sum-scroll">
<h3 class="cpg-sum-h3">제품별 박스 <span class="erp-muted">잔여 기준</span></h3> <h3 class="cpg-sum-h3">제품별 상자 <span class="erp-muted">잔여 기준</span></h3>
<div class="cpg-sum-prods" id="cpg-sum-prods"></div> <div class="cpg-sum-prods" id="cpg-sum-prods"></div>
<h3 class="cpg-sum-h3">자투리 혼합 박스 <span class="erp-muted" id="cpg-sum-mix-note"></span></h3> <h3 class="cpg-sum-h3">자투리 혼합 상자 <span class="erp-muted" id="cpg-sum-mix-note"></span></h3>
<div class="cpg-sum-boxes" id="cpg-sum-boxes"></div> <div class="cpg-sum-boxes" id="cpg-sum-boxes"></div>
</div> </div>
</div> </div>
@@ -103,7 +103,7 @@
<h2>③ 센터 분배</h2> <h2>③ 센터 분배</h2>
<span class="erp-muted">담을 센터를 골라 추가하세요.</span> <span class="erp-muted">담을 센터를 골라 추가하세요.</span>
<button type="button" class="erp-btn erp-btn-primary cpg-confirm-btn" id="cpg-confirm" disabled <button type="button" class="erp-btn erp-btn-primary cpg-confirm-btn" id="cpg-confirm" disabled
title="모든 박스를 배분하고 센터마다 출고방식을 고르면 활성화됩니다.">분배 확정</button> title="모든 상자를 배분하고 센터마다 출고방식을 고르면 활성화됩니다.">분배 확정</button>
</div> </div>
{% if not centers %} {% if not centers %}
@@ -118,7 +118,7 @@
</div><!-- /cpg-calc3 --> </div><!-- /cpg-calc3 -->
<!-- 담을 박스 수량 입력 대화상자 --> <!-- 담을 상자 수량 입력 대화상자 -->
<div class="cpg-modal" id="cpg-dlg" hidden> <div class="cpg-modal" id="cpg-dlg" hidden>
<div class="cpg-modal-back" data-dlg-close></div> <div class="cpg-modal-back" data-dlg-close></div>
<div class="cpg-modal-box" role="dialog" aria-modal="true" aria-labelledby="cpg-dlg-title"> <div class="cpg-modal-box" role="dialog" aria-modal="true" aria-labelledby="cpg-dlg-title">
@@ -126,7 +126,7 @@
<p class="cpg-dlg-item" id="cpg-dlg-item"></p> <p class="cpg-dlg-item" id="cpg-dlg-item"></p>
<div class="erp-field"><span>센터</span> <div class="erp-field"><span>센터</span>
<div class="cpg-dlg-centers" id="cpg-dlg-centers" role="group" aria-label="담을 센터"></div></div> <div class="cpg-dlg-centers" id="cpg-dlg-centers" role="group" aria-label="담을 센터"></div></div>
<label class="erp-field"><span>박스 수량</span> <label class="erp-field"><span>상자 수량</span>
<input class="erp-input" type="number" id="cpg-dlg-qty" min="1" step="1" /></label> <input class="erp-input" type="number" id="cpg-dlg-qty" min="1" step="1" /></label>
<p class="erp-muted" id="cpg-dlg-hint"></p> <p class="erp-muted" id="cpg-dlg-hint"></p>
<div class="erp-page-actions cpg-dlg-actions"> <div class="erp-page-actions cpg-dlg-actions">
@@ -195,7 +195,7 @@
<script type="application/json" id="cpg-calc-rules">{{ box_rules | tojson }}</script> <script type="application/json" id="cpg-calc-rules">{{ box_rules | tojson }}</script>
<script type="application/json" id="cpg-centers">{{ centers | tojson }}</script> <script type="application/json" id="cpg-centers">{{ centers | tojson }}</script>
<script> <script>
// 박스 계산 3열 화면. // 상자 계산 3열 화면.
// ① 제품명·수량 입력 → 서버(POST /cupang/api/box-calc)에서 계산 // ① 제품명·수량 입력 → 서버(POST /cupang/api/box-calc)에서 계산
// ② 계산 결과 요약 — 드래그(또는 클릭) 소스 // ② 계산 결과 요약 — 드래그(또는 클릭) 소스
// ③ 선택한 센터에만 분배. 담은 수량만큼 ② 의 잔여가 즉시 줄어든다. // ③ 선택한 센터에만 분배. 담은 수량만큼 ② 의 잔여가 즉시 줄어든다.
@@ -243,8 +243,8 @@
// ── 상태 ───────────────────────────────────────── // ── 상태 ─────────────────────────────────────────
var calc = { results: [], mixes: [] }; // 서버 계산 결과 var calc = { results: [], mixes: [] }; // 서버 계산 결과
var units = {}; // key -> 박스 1개당 상품 수 var units = {}; // key -> 상자 1개당 상품 수
var contents = {}; // key -> 혼합 박스 내용물 [{product_name, quantity}] var contents = {}; // key -> 혼합 상자 내용물 [{product_name, quantity}]
var labels = {}; // key -> 표시 이름 var labels = {}; // key -> 표시 이름
var openCenters = []; // ③ 에 추가한 센터 id (문자열) var openCenters = []; // ③ 에 추가한 센터 id (문자열)
var alloc = {}; // centerId -> [{id, key, count}] var alloc = {}; // centerId -> [{id, key, count}]
@@ -255,7 +255,7 @@
function prodKey(code) { return "p:" + code; } function prodKey(code) { return "p:" + code; }
function mixKey(boxName, idx) { return "m:" + boxName + "#" + idx; } function mixKey(boxName, idx) { return "m:" + boxName + "#" + idx; }
// 자투리 혼합 박스에 쓸 상자 종류 — 요약 카드에서 고른다(표시용). // 자투리 혼합 상자에 쓸 상자 종류 — 요약 카드에서 고른다(표시용).
var MIX_TYPES = ["쿠팡상자", "3호상자", "6호상자"]; var MIX_TYPES = ["쿠팡상자", "3호상자", "6호상자"];
var mixType = {}; // mixKey -> 고른 상자 종류 var mixType = {}; // mixKey -> 고른 상자 종류
var mixOwner = {}; // mixKey -> 센터명(업로드로 만들어진 경우) var mixOwner = {}; // mixKey -> 센터명(업로드로 만들어진 경우)
@@ -360,8 +360,8 @@
} }
// ── 쿠팡 발주 엑셀 업로드 ───────────────────────── // ── 쿠팡 발주 엑셀 업로드 ─────────────────────────
// 업로드 → 센터별 수량 합산(서버) → 센터마다 박스 계산 → ②③ 자동 채움. // 업로드 → 센터별 수량 합산(서버) → 센터마다 상자 계산 → ②③ 자동 채움.
// 박스는 센터별로 포장하므로 계산도 센터 단위로 한다(자투리 혼합 박스 포함). // 상자는 센터별로 포장하므로 계산도 센터 단위로 한다(자투리 혼합 상자 포함).
var poFile = document.getElementById("cpg-po-file"); var poFile = document.getElementById("cpg-po-file");
var poMsg = document.getElementById("cpg-po-msg"); var poMsg = document.getElementById("cpg-po-msg");
var poResult = document.getElementById("cpg-po-result"); var poResult = document.getElementById("cpg-po-result");
@@ -412,7 +412,7 @@
return '<button type="button" class="cpg-date-tab' + (d === activeDate ? " is-active" : "") + '"' + return '<button type="button" class="cpg-date-tab' + (d === activeDate ? " is-active" : "") + '"' +
' data-date="' + esc(d) + '">' + ' data-date="' + esc(d) + '">' +
'<strong>' + esc(d || "날짜 미확인") + "</strong>" + '<strong>' + esc(d || "날짜 미확인") + "</strong>" +
'<span>센터 ' + cn + "곳 · " + bx + "박스</span>" + '<span>센터 ' + cn + "곳 · " + bx + "상자</span>" +
"</button>"; "</button>";
}).join(""); }).join("");
} }
@@ -464,8 +464,8 @@
}); });
} }
// 센터별 계산 결과를 하나의 ② 요약으로 합치고, 각 박스를 그 센터에 자동 배분한다. // 센터별 계산 결과를 하나의 ② 요약으로 합치고, 각 상자를 그 센터에 자동 배분한다.
// (한 출고일 안에서만 합친다 — 박스는 센터 단위로 포장) // (한 출고일 안에서만 합친다 — 상자는 센터 단위로 포장)
function buildDataset(groups, perCenter) { function buildDataset(groups, perCenter) {
var mergedProds = {}; // code -> 합산 결과 var mergedProds = {}; // code -> 합산 결과
var mergedMixes = {}; // box_name -> {box_name, boxes:[]} var mergedMixes = {}; // box_name -> {box_name, boxes:[]}
@@ -603,7 +603,7 @@
return; return;
} }
// 날짜 × 센터 단위로 박스 계산 (박스는 센터별로 포장되므로) // 날짜 × 센터 단위로 상자 계산 (상자는 센터별로 포장되므로)
var jobs = []; var jobs = [];
dateGroups.forEach(function (g) { dateGroups.forEach(function (g) {
g.centers.forEach(function (c) { g.centers.forEach(function (c) {
@@ -663,7 +663,7 @@
var hit = calc.results.filter(function (r) { return r.product_code === code; })[0]; var hit = calc.results.filter(function (r) { return r.product_code === code; })[0];
return hit ? hit.full_boxes : 0; return hit ? hit.full_boxes : 0;
} }
return 1; // 혼합 박스는 1장 = 1박스 return 1; // 혼합 상자는 1장 = 1상자
} }
function remainFor(key) { return Math.max(0, totalFor(key) - allocatedFor(key)); } function remainFor(key) { return Math.max(0, totalFor(key) - allocatedFor(key)); }
@@ -736,7 +736,7 @@
}); });
var assigned = (totalProd + totalMix) - (remainProd + remainMix); var assigned = (totalProd + totalMix) - (remainProd + remainMix);
// 모든 박스를 센터에 배분하면 "센터 배분" 칸에 전체 완료 도장을 찍는다. // 모든 상자를 센터에 배분하면 "센터 배분" 칸에 전체 완료 도장을 찍는다.
var totalBoxes = totalProd + totalMix; var totalBoxes = totalProd + totalMix;
var allDone = totalBoxes > 0 && (remainProd + remainMix) === 0; var allDone = totalBoxes > 0 && (remainProd + remainMix) === 0;
var allStamp = ""; var allStamp = "";
@@ -747,9 +747,9 @@
allStamped = allDone; allStamped = allDone;
sumKpis.innerHTML = sumKpis.innerHTML =
kpi("총 박스", totalBoxes + "박스", "제품별 " + totalProd + " + 혼합 " + totalMix) + kpi("총 상자", totalBoxes + "상자", "제품별 " + totalProd + " + 혼합 " + totalMix) +
kpi("센터 배분", assigned + "박스", "③ 에 담긴 수량", allStamp) + kpi("센터 배분", assigned + "상자", "③ 에 담긴 수량", allStamp) +
kpi("미배분 잔여", (remainProd + remainMix) + "박스", "자투리 " + leftoverUnits + "개 포함"); kpi("미배분 잔여", (remainProd + remainMix) + "상자", "자투리 " + leftoverUnits + "개 포함");
var ph = ""; var ph = "";
calc.results.forEach(function (r) { calc.results.forEach(function (r) {
@@ -764,8 +764,8 @@
(remain ? "" : stampHtml(key)) + (remain ? "" : stampHtml(key)) +
"</div>" + "</div>" +
'<div class="cpg-sum-prod-nums">' + '<div class="cpg-sum-prod-nums">' +
"<strong>" + remain + "박스</strong>" + "<strong>" + remain + "상자</strong>" +
'<span class="erp-muted">' + (remain * r.units_per_box) + "개 · 총 " + r.full_boxes + "박스" + '<span class="erp-muted">' + (remain * r.units_per_box) + "개 · 총 " + r.full_boxes + "상자" +
(used ? " · 배분 " + used : "") + "</span>" + (used ? " · 배분 " + used : "") + "</span>" +
(r.remainder_units (r.remainder_units
? '<span class="cpg-sum-left">자투리 ' + r.remainder_units + "개</span>" ? '<span class="cpg-sum-left">자투리 ' + r.remainder_units + "개</span>"
@@ -779,7 +779,7 @@
sumMixNote.textContent = "— 자투리 없음"; sumMixNote.textContent = "— 자투리 없음";
sumBoxes.innerHTML = '<p class="erp-muted">남는 낱개가 없습니다.</p>'; sumBoxes.innerHTML = '<p class="erp-muted">남는 낱개가 없습니다.</p>';
} else { } else {
sumMixNote.textContent = "— 1장 = 1박스"; sumMixNote.textContent = "— 1장 = 1상자";
var bh = "", n = 0; var bh = "", n = 0;
calc.mixes.forEach(function (m) { calc.mixes.forEach(function (m) {
m.boxes.forEach(function (b, i) { m.boxes.forEach(function (b, i) {
@@ -809,14 +809,14 @@
} }
} }
// 혼합 박스는 안에 무엇이 몇 개 들어가는지 트리로 펼쳐 보여준다. // 혼합 상자는 안에 무엇이 몇 개 들어가는지 트리로 펼쳐 보여준다.
function treeHtml(entry) { function treeHtml(entry) {
var list = contents[entry.key]; var list = contents[entry.key];
if (!list || !list.length) return ""; if (!list || !list.length) return "";
return '<ul class="cpg-dist-tree">' + list.map(function (it) { return '<ul class="cpg-dist-tree">' + list.map(function (it) {
return '<li><span class="cpg-tree-name">' + esc(it.product_name) + "</span>" + return '<li><span class="cpg-tree-name">' + esc(it.product_name) + "</span>" +
'<span class="cpg-tree-qty">' + (it.quantity * entry.count) + "개</span>" + '<span class="cpg-tree-qty">' + (it.quantity * entry.count) + "개</span>" +
(entry.count > 1 ? '<span class="cpg-tree-per">(' + it.quantity + "개 × " + entry.count + "박스)</span>" : "") + (entry.count > 1 ? '<span class="cpg-tree-per">(' + it.quantity + "개 × " + entry.count + "상자)</span>" : "") +
"</li>"; "</li>";
}).join("") + "</ul>"; }).join("") + "</ul>";
} }
@@ -838,7 +838,7 @@
'<div class="cpg-dist-line">' + '<div class="cpg-dist-line">' +
'<span class="cpg-dist-name">' + esc(labels[a.key] || a.key) + "</span>" + '<span class="cpg-dist-name">' + esc(labels[a.key] || a.key) + "</span>" +
'<input class="erp-input cpg-dist-qty" type="number" min="1" max="999" step="1" inputmode="numeric" value="' + a.count + '" />' + '<input class="erp-input cpg-dist-qty" type="number" min="1" max="999" step="1" inputmode="numeric" value="' + a.count + '" />' +
'<span class="cpg-dist-unit">박스 · ' + (a.count * per) + "개</span>" + '<span class="cpg-dist-unit">상자 · ' + (a.count * per) + "개</span>" +
'<button type="button" class="erp-btn erp-btn-danger cpg-dist-del" title="빼기" aria-label="빼기">✕</button>' + '<button type="button" class="erp-btn erp-btn-danger cpg-dist-del" title="빼기" aria-label="빼기">✕</button>' +
"</div>" + "</div>" +
treeHtml(a) + treeHtml(a) +
@@ -855,14 +855,14 @@
'<option value="파렛트"' + (method === "파렛트" ? " selected" : "") + '>파렛트</option>' + '<option value="파렛트"' + (method === "파렛트" ? " selected" : "") + '>파렛트</option>' +
"</select>" + "</select>" +
'<span class="cpg-dist-sum">' + '<span class="cpg-dist-sum">' +
'<span class="erp-badge erp-badge-neutral">' + boxes + "박스</span>" + '<span class="erp-badge erp-badge-neutral">' + boxes + "상자</span>" +
'<span class="erp-badge erp-badge-neutral">' + pieces + "개</span>" + '<span class="erp-badge erp-badge-neutral">' + pieces + "개</span>" +
'<button type="button" class="erp-btn erp-btn-outline cpg-dist-close" title="센터 빼기" aria-label="센터 빼기">✕</button>' + '<button type="button" class="erp-btn erp-btn-outline cpg-dist-close" title="센터 빼기" aria-label="센터 빼기">✕</button>' +
"</span>" + "</span>" +
"</div>" + "</div>" +
(rows.length (rows.length
? '<ul class="cpg-dist-items">' + items + "</ul>" ? '<ul class="cpg-dist-items">' + items + "</ul>"
: '<p class="cpg-dist-hint">여기로 박스를 끌어다 놓기</p>') + : '<p class="cpg-dist-hint">여기로 상자를 끌어다 놓기</p>') +
"</div>"; "</div>";
}); });
distList.innerHTML = html; distList.innerHTML = html;
@@ -875,15 +875,15 @@
function openDialog(key, centerId) { function openDialog(key, centerId) {
if (!openCenters.length) { msg.textContent = "먼저 ③ 에서 센터를 추가하세요."; return; } if (!openCenters.length) { msg.textContent = "먼저 ③ 에서 센터를 추가하세요."; return; }
var remain = remainFor(key); var remain = remainFor(key);
if (remain <= 0) { msg.textContent = "남은 박스가 없습니다."; return; } if (remain <= 0) { msg.textContent = "남은 상자가 없습니다."; return; }
pending = { key: key }; pending = { key: key };
dlgItem.textContent = (labels[key] || key) + " — 잔여 " + remain + "박스 (" + (remain * (units[key] || 0)) + "개)"; dlgItem.textContent = (labels[key] || key) + " — 잔여 " + remain + "상자 (" + (remain * (units[key] || 0)) + "개)";
dlgCenterId = String(centerId != null ? centerId : openCenters[0]); dlgCenterId = String(centerId != null ? centerId : openCenters[0]);
renderDialogCenters(); renderDialogCenters();
dlgQty.max = remain; dlgQty.max = remain;
dlgQty.value = key.indexOf("m:") === 0 ? 1 : remain; dlgQty.value = key.indexOf("m:") === 0 ? 1 : remain;
dlgHint.textContent = "1박스 = " + (units[key] || 0) + "개 · 최대 " + remain + "박스"; dlgHint.textContent = "1상자 = " + (units[key] || 0) + "개 · 최대 " + remain + "상자";
dlg.hidden = false; dlg.hidden = false;
dlgQty.focus(); dlgQty.focus();
dlgQty.select(); dlgQty.select();
@@ -916,7 +916,7 @@
closeDialog(); closeDialog();
render(); render();
msg.textContent = got msg.textContent = got
? centerName(cid) + " ← " + (labels[key] || key) + " " + got + "박스" ? centerName(cid) + " ← " + (labels[key] || key) + " " + got + "상자"
: "담을 수량이 없습니다."; : "담을 수량이 없습니다.";
} }
@@ -1069,7 +1069,7 @@
var max = entry.count + remainFor(entry.key); var max = entry.count + remainFor(entry.key);
if (want > max) { want = max; e.target.value = max; } if (want > max) { want = max; e.target.value = max; }
entry.count = want; entry.count = want;
li.querySelector(".cpg-dist-unit").textContent = "박스 · " + (want * (units[entry.key] || 0)) + "개"; li.querySelector(".cpg-dist-unit").textContent = "상자 · " + (want * (units[entry.key] || 0)) + "개";
var tree = li.querySelector(".cpg-dist-tree"); var tree = li.querySelector(".cpg-dist-tree");
if (tree) tree.outerHTML = treeHtml(entry); if (tree) tree.outerHTML = treeHtml(entry);
renderSummary(); renderSummary();
@@ -1105,7 +1105,7 @@
pieces += a.count * (units[a.key] || 0); pieces += a.count * (units[a.key] || 0);
}); });
var badges = zone.querySelectorAll(".cpg-dist-sum .erp-badge"); var badges = zone.querySelectorAll(".cpg-dist-sum .erp-badge");
if (badges[0]) badges[0].textContent = boxes + "박스"; if (badges[0]) badges[0].textContent = boxes + "상자";
if (badges[1]) badges[1].textContent = pieces + "개"; if (badges[1]) badges[1].textContent = pieces + "개";
} }
@@ -1203,9 +1203,9 @@
function confirmBlockReason() { function confirmBlockReason() {
var t = boxTotals(); var t = boxTotals();
if (!t.total) return "먼저 ① 에서 계산하세요."; if (!t.total) return "먼저 ① 에서 계산하세요.";
if (t.remain) return "아직 " + t.remain + "박스가 남았습니다."; if (t.remain) return "아직 " + t.remain + "상자가 남았습니다.";
var cids = filledCenters(); var cids = filledCenters();
if (!cids.length) return "센터에 담긴 박스가 없습니다."; if (!cids.length) return "센터에 담긴 상자가 없습니다.";
var missing = cids.filter(function (cid) { return !methods[String(cid)]; }); var missing = cids.filter(function (cid) { return !methods[String(cid)]; });
if (missing.length) return "출고방식 미선택: " + missing.map(centerName).join(", "); if (missing.length) return "출고방식 미선택: " + missing.map(centerName).join(", ");
return ""; return "";
@@ -1218,7 +1218,7 @@
confirmBtn.title = why || "출고일자를 골라 출고 묶음을 만듭니다."; confirmBtn.title = why || "출고일자를 골라 출고 묶음을 만듭니다.";
} }
// 센터별 전송 payload — 혼합 박스는 내용물 제품으로 풀어서 담는다. // 센터별 전송 payload — 혼합 상자는 내용물 제품으로 풀어서 담는다.
function buildPlans() { function buildPlans() {
return filledCenters().map(function (cid) { return filledCenters().map(function (cid) {
var rows = alloc[cid] || []; var rows = alloc[cid] || [];
@@ -1234,7 +1234,7 @@
}); });
} }
}); });
// 출고 엑셀 "출고" 칸 — 상자 종류별 박스 수 (예: "쿠팡상자, 41개") // 출고 엑셀 "출고" 칸 — 상자 종류별 상자 수 (예: "쿠팡상자, 41개")
// 종류가 여럿이면 " / " 로 잇는다 (예: "쿠팡상자, 41개 / 3호상자, 2개") // 종류가 여럿이면 " / " 로 잇는다 (예: "쿠팡상자, 41개 / 3호상자, 2개")
var byType = {}; var byType = {};
var order = []; var order = [];
@@ -1329,7 +1329,7 @@
var pieces = pl.items.reduce(function (n, it) { return n + it.quantity; }, 0); var pieces = pl.items.reduce(function (n, it) { return n + it.quantity; }, 0);
return '<div class="cpg-cfm-row">' + return '<div class="cpg-cfm-row">' +
'<span class="cpg-cfm-cname">' + esc(centerName(pl.center_id)) + "</span>" + '<span class="cpg-cfm-cname">' + esc(centerName(pl.center_id)) + "</span>" +
'<span class="cpg-cfm-cmeta">' + esc(pl.ship_method) + " · " + pl.boxes + "박스 · " + pieces + "개</span>" + '<span class="cpg-cfm-cmeta">' + esc(pl.ship_method) + " · " + pl.boxes + "상자 · " + pieces + "개</span>" +
"</div>"; "</div>";
}).join(""); }).join("");
renderCal(); renderCal();
@@ -1565,7 +1565,7 @@
if (!items.length) addRow("", 0); if (!items.length) addRow("", 0);
draftName.value = d.title || ""; draftName.value = d.title || "";
closeDraft(); closeDraft();
// 박스 수는 서버에서 다시 계산한 뒤 분배 내역을 얹는다. // 상자 수는 서버에서 다시 계산한 뒤 분배 내역을 얹는다.
run(snap); run(snap);
}) })
.catch(function () { draftMsg.textContent = "불러오기 실패 — 다시 시도하세요."; }); .catch(function () { draftMsg.textContent = "불러오기 실패 — 다시 시도하세요."; });
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901o" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
@@ -14,7 +14,7 @@
<!-- 왼쪽: 추가/수정 (product_code UNIQUE → upsert) --> <!-- 왼쪽: 추가/수정 (product_code UNIQUE → upsert) -->
<div class="erp-card cpg-form-card cpg-brule-add"> <div class="erp-card cpg-form-card cpg-brule-add">
<div class="cpg-card-head"> <div class="cpg-card-head">
<h2>박스 입수량 추가 / 수정</h2> <h2>상자 입수량 추가 / 수정</h2>
<span class="erp-muted">같은 제품코드는 덮어씁니다.</span> <span class="erp-muted">같은 제품코드는 덮어씁니다.</span>
</div> </div>
<form method="post" action="/cupang/box-rules"> <form method="post" action="/cupang/box-rules">
@@ -32,9 +32,9 @@
<input class="erp-input cpg-brule-code" type="text" name="product_code" id="brule-code" required placeholder="자동" /></label> <input class="erp-input cpg-brule-code" type="text" name="product_code" id="brule-code" required placeholder="자동" /></label>
</div> </div>
<div class="cpg-brule-row"> <div class="cpg-brule-row">
<label class="erp-field"><span>박스이름</span> <label class="erp-field"><span>상자이름</span>
<input class="erp-input cpg-brule-box" type="text" name="box_name" value="쿠팡박스" /></label> <input class="erp-input cpg-brule-box" type="text" name="box_name" value="쿠팡상자" /></label>
<label class="erp-field"><span>박스당 입수량 *</span> <label class="erp-field"><span>상자당 입수량 *</span>
<span class="cpg-upb-wrap"> <span class="cpg-upb-wrap">
<input class="erp-input cpg-brule-upb" type="number" name="units_per_box" min="1" required /> <input class="erp-input cpg-brule-upb" type="number" name="units_per_box" min="1" required />
<span class="cpg-upb-unit"></span> <span class="cpg-upb-unit"></span>
@@ -74,7 +74,7 @@
<div class="erp-table-wrap"> <div class="erp-table-wrap">
<table class="erp-table"> <table class="erp-table">
<thead> <thead>
<tr><th>제품명</th><th>제품코드</th><th>박스</th><th>입수량</th><th>메모</th><th>동작</th></tr> <tr><th>제품명</th><th>제품코드</th><th>상자</th><th>입수량</th><th>메모</th><th>동작</th></tr>
</thead> </thead>
<tbody> <tbody>
{% for r in box_rules %} {% for r in box_rules %}
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901o" />{% 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=20260901n" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901o" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
@@ -12,7 +12,7 @@
<span class="cpg-settings-btns"> <span class="cpg-settings-btns">
<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/centers">입고센터 관리</a> <a class="erp-btn erp-btn-outline" href="/cupang/centers">입고센터 관리</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>
</div> </div>
<div class="cpg-actions-spacer"></div> <div class="cpg-actions-spacer"></div>
@@ -97,7 +97,7 @@
<a class="cpg-ship-name" href="/cupang/{{ s.id }}">{{ s.center_name_snapshot or '센터 미지정' }}</a> <a class="cpg-ship-name" href="/cupang/{{ s.id }}">{{ s.center_name_snapshot or '센터 미지정' }}</a>
<span class="erp-badge erp-badge-neutral">{{ s.ship_method }}</span> <span class="erp-badge erp-badge-neutral">{{ s.ship_method }}</span>
<span class="cpg-dist-sum"> <span class="cpg-dist-sum">
<span class="erp-badge erp-badge-neutral">{{ s.total_boxes }}박스</span> <span class="erp-badge erp-badge-neutral">{{ s.total_boxes }}상자</span>
<span class="erp-badge erp-badge-neutral">{{ s.total_qty }}개</span> <span class="erp-badge erp-badge-neutral">{{ s.total_qty }}개</span>
<button type="button" class="cpg-icon-btn is-danger" title="이 출고 삭제" <button type="button" class="cpg-icon-btn is-danger" title="이 출고 삭제"
data-del="{{ s.id }}" data-name="{{ s.center_name_snapshot }}">삭제</button> data-del="{{ s.id }}" data-name="{{ s.center_name_snapshot }}">삭제</button>
@@ -110,7 +110,7 @@
<li class="cpg-dist-item{% if e.kind == 'mix' %} is-mix{% endif %}"> <li class="cpg-dist-item{% if e.kind == 'mix' %} is-mix{% endif %}">
<div class="cpg-dist-line"> <div class="cpg-dist-line">
<span class="cpg-dist-name">{{ e.name }}</span> <span class="cpg-dist-name">{{ e.name }}</span>
<span class="cpg-dist-unit">{{ e.count }}박스 · {{ e.quantity }}개</span> <span class="cpg-dist-unit">{{ e.count }}상자 · {{ e.quantity }}개</span>
</div> </div>
{% if e.kind == 'mix' and e['items'] %} {% if e.kind == 'mix' and e['items'] %}
<ul class="cpg-dist-tree"> <ul class="cpg-dist-tree">
@@ -123,12 +123,12 @@
</li> </li>
{% endfor %} {% endfor %}
{% else %} {% else %}
{# 예전에 만든 출고(박스 구성 미저장) — 제품 합계만 표시 #} {# 예전에 만든 출고(상자 구성 미저장) — 제품 합계만 표시 #}
{% for it in s['items'] %} {% for it in s['items'] %}
<li class="cpg-dist-item"> <li class="cpg-dist-item">
<div class="cpg-dist-line"> <div class="cpg-dist-line">
<span class="cpg-dist-name">{{ it.name }}</span> <span class="cpg-dist-name">{{ it.name }}</span>
<span class="cpg-dist-unit">{{ it.boxes }}박스 · {{ it.qty }}개</span> <span class="cpg-dist-unit">{{ it.boxes }}상자 · {{ it.qty }}개</span>
</div> </div>
</li> </li>
{% endfor %} {% endfor %}
@@ -141,7 +141,7 @@
</ul> </ul>
{% else %} {% else %}
<p class="erp-muted cpg-empty">선택한 날짜의 출고 묶음이 없습니다. <p class="erp-muted cpg-empty">선택한 날짜의 출고 묶음이 없습니다.
<a href="/cupang/box-calc">박스 계산에서 등록</a></p> <a href="/cupang/box-calc">상자 계산에서 등록</a></p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901o" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
+13 -13
View File
@@ -1,9 +1,9 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901o" />{% endblock %}
{% block content %} {% block content %}
{# 출고 묶음 보기 — 박스 계산 화면과 같은 3열 구성. 읽기 전용(수정 없음). #} {# 출고 묶음 보기 — 상자 계산 화면과 같은 3열 구성. 읽기 전용(수정 없음). #}
<section class="cpg"> <section class="cpg">
<div class="erp-page-actions"> <div class="erp-page-actions">
@@ -44,18 +44,18 @@
</div> </div>
</div> </div>
<!--박스 요약 --> <!--상자 요약 -->
<div class="erp-card cpg-form-card cpg-calc-sum"> <div class="erp-card cpg-form-card cpg-calc-sum">
<div class="cpg-card-head"> <div class="cpg-card-head">
<h2>박스 요약</h2> <h2>상자 요약</h2>
<span class="erp-muted">확정 당시 수량 기준으로 다시 계산한 값</span> <span class="erp-muted">확정 당시 수량 기준으로 다시 계산한 값</span>
</div> </div>
<div id="cpg-sum-body"> <div id="cpg-sum-body">
<div class="cpg-sum-kpis"> <div class="cpg-sum-kpis">
<div class="cpg-kpi"> <div class="cpg-kpi">
<span class="cpg-kpi-label">박스</span> <span class="cpg-kpi-label">상자</span>
<strong class="cpg-kpi-value">{{ calc.grand_total_boxes }}박스</strong> <strong class="cpg-kpi-value">{{ calc.grand_total_boxes }}상자</strong>
<span class="cpg-kpi-hint">제품별 + 혼합</span> <span class="cpg-kpi-hint">제품별 + 혼합</span>
</div> </div>
<div class="cpg-kpi"> <div class="cpg-kpi">
@@ -71,7 +71,7 @@
</div> </div>
<div class="cpg-sum-scroll"> <div class="cpg-sum-scroll">
<h3 class="cpg-sum-h3">제품별 박스</h3> <h3 class="cpg-sum-h3">제품별 상자</h3>
<div class="cpg-sum-prods"> <div class="cpg-sum-prods">
{% for r in calc.results if r.configured %} {% for r in calc.results if r.configured %}
<div class="cpg-sum-prod is-done"> <div class="cpg-sum-prod is-done">
@@ -79,7 +79,7 @@
<span class="cpg-sum-prod-name">{{ r.product_name }}</span> <span class="cpg-sum-prod-name">{{ r.product_name }}</span>
</div> </div>
<div class="cpg-sum-prod-nums"> <div class="cpg-sum-prod-nums">
<strong>{{ r.full_boxes }}박스</strong> <strong>{{ r.full_boxes }}상자</strong>
<span class="erp-muted">{{ r.quantity }}개 · 입수량 {{ r.units_per_box }}</span> <span class="erp-muted">{{ r.quantity }}개 · 입수량 {{ r.units_per_box }}</span>
{% if r.remainder_units %} {% if r.remainder_units %}
<span class="cpg-sum-left">자투리 {{ r.remainder_units }}개</span> <span class="cpg-sum-left">자투리 {{ r.remainder_units }}개</span>
@@ -91,14 +91,14 @@
{% endfor %} {% endfor %}
{% set unconfigured = calc.results | rejectattr('configured') | list %} {% set unconfigured = calc.results | rejectattr('configured') | list %}
{% if unconfigured %} {% if unconfigured %}
<p class="erp-muted">박스 입수량 미설정: <p class="erp-muted">상자 입수량 미설정:
{% for r in unconfigured %}{{ r.product_name }}{% if not loop.last %}, {% endif %}{% endfor %} {% for r in unconfigured %}{{ r.product_name }}{% if not loop.last %}, {% endif %}{% endfor %}
</p> </p>
{% endif %} {% endif %}
</div> </div>
<h3 class="cpg-sum-h3">자투리 혼합 박스 <h3 class="cpg-sum-h3">자투리 혼합 상자
<span class="erp-muted">{% if not calc.mixes %}— 자투리 없음{% else %}— 1장 = 1박스{% endif %}</span> <span class="erp-muted">{% if not calc.mixes %}— 자투리 없음{% else %}— 1장 = 1상자{% endif %}</span>
</h3> </h3>
<div class="cpg-sum-boxes"> <div class="cpg-sum-boxes">
{% for m in calc.mixes %} {% for m in calc.mixes %}
@@ -135,7 +135,7 @@
<strong>{{ shipment.center_name_snapshot or '센터 미지정' }}</strong> <strong>{{ shipment.center_name_snapshot or '센터 미지정' }}</strong>
<span class="erp-badge erp-badge-neutral">{{ shipment.ship_method }}</span> <span class="erp-badge erp-badge-neutral">{{ shipment.ship_method }}</span>
<span class="cpg-dist-sum"> <span class="cpg-dist-sum">
<span class="erp-badge erp-badge-neutral">{{ calc.grand_total_boxes }}박스</span> <span class="erp-badge erp-badge-neutral">{{ calc.grand_total_boxes }}상자</span>
<span class="erp-badge erp-badge-neutral">{{ total_qty }}개</span> <span class="erp-badge erp-badge-neutral">{{ total_qty }}개</span>
</span> </span>
</div> </div>
@@ -145,7 +145,7 @@
<li class="cpg-dist-item{% if e.kind == 'mix' %} is-mix{% endif %}"> <li class="cpg-dist-item{% if e.kind == 'mix' %} is-mix{% endif %}">
<div class="cpg-dist-line"> <div class="cpg-dist-line">
<span class="cpg-dist-name">{{ e.name }}</span> <span class="cpg-dist-name">{{ e.name }}</span>
<span class="cpg-dist-unit">{{ e.count }}박스 · {{ e.quantity }}개</span> <span class="cpg-dist-unit">{{ e.count }}상자 · {{ e.quantity }}개</span>
</div> </div>
{% if e.kind == 'mix' and e['items'] %} {% if e.kind == 'mix' and e['items'] %}
<ul class="cpg-dist-tree"> <ul class="cpg-dist-tree">
+15 -15
View File
@@ -1,5 +1,5 @@
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
-- 쿠팡 밀크런 — 박스 입수량 규칙 초기 입력 (cupang_db) -- 쿠팡 밀크런 — 상자 입수량 규칙 초기 입력 (cupang_db)
-- --
-- 실행: -- 실행:
-- docker exec -i postgres-db psql -U postgres -d cupang_db \ -- docker exec -i postgres-db psql -U postgres -d cupang_db \
@@ -14,20 +14,20 @@ BEGIN;
INSERT INTO cupang_box_rules INSERT INTO cupang_box_rules
(product_code, product_name_snapshot, box_name, units_per_box, memo, active) (product_code, product_name_snapshot, box_name, units_per_box, memo, active)
VALUES VALUES
('MS-1001', '미라네 1호 세트', '쿠팡박스', 8, '피킹비 750원', TRUE), ('MS-1001', '미라네 1호 세트', '쿠팡상자', 8, '피킹비 750원', TRUE),
('MS-1002', '미라네 2호 세트', '쿠팡박스', 8, '피킹비 1100원', TRUE), ('MS-1002', '미라네 2호 세트', '쿠팡상자', 8, '피킹비 1100원', TRUE),
('MS-1003', '미라네 3호 세트', '쿠팡박스', 8, '피킹비 1200원', TRUE), ('MS-1003', '미라네 3호 세트', '쿠팡상자', 8, '피킹비 1200원', TRUE),
('MS-1004', '미라네 4호 세트', '쿠팡박스', 8, '피킹비 1000원', TRUE), ('MS-1004', '미라네 4호 세트', '쿠팡상자', 8, '피킹비 1000원', TRUE),
('MS-1005', '미라네 5호 세트', '쿠팡박스', 8, '피킹비 650원', TRUE), ('MS-1005', '미라네 5호 세트', '쿠팡상자', 8, '피킹비 650원', TRUE),
('MS-1006', '미라네 6호 세트', '쿠팡박스', 8, '피킹비 750원', TRUE), ('MS-1006', '미라네 6호 세트', '쿠팡상자', 8, '피킹비 750원', TRUE),
('MS-1007', '미라네 7호 세트', '쿠팡박스', 8, '피킹비 850원', TRUE), ('MS-1007', '미라네 7호 세트', '쿠팡상자', 8, '피킹비 850원', TRUE),
('MS-9812', '황토 김치통 1호세트', '쿠팡박스', 8, '피킹비 750원', TRUE), ('MS-9812', '황토 김치통 1호세트', '쿠팡상자', 8, '피킹비 750원', TRUE),
('MS-9813', '황토 김치통 2호세트', '쿠팡박스', 8, '피킹비 650원', TRUE), ('MS-9813', '황토 김치통 2호세트', '쿠팡상자', 8, '피킹비 650원', TRUE),
('MS-9814', '황토 김치통 3호세트', '쿠팡박스', 8, '피킹비 750원', TRUE), ('MS-9814', '황토 김치통 3호세트', '쿠팡상자', 8, '피킹비 750원', TRUE),
('MS-9815', '황토 김치통 4호세트', '쿠팡박스', 2, '피킹비 850원', TRUE), ('MS-9815', '황토 김치통 4호세트', '쿠팡상자', 2, '피킹비 850원', TRUE),
('MS-9816', '황토 김치통 5호세트', '쿠팡박스', 2, '피킹비 900원', TRUE), ('MS-9816', '황토 김치통 5호세트', '쿠팡상자', 2, '피킹비 900원', TRUE),
('MS-9817', '황토 김치통 6호세트', '쿠팡박스', 1, '피킹비 1000원', TRUE), ('MS-9817', '황토 김치통 6호세트', '쿠팡상자', 1, '피킹비 1000원', TRUE),
('MS-9818', '황토 김치통 7호세트', '쿠팡박스', 1, '피킹비 1100원', TRUE) ('MS-9818', '황토 김치통 7호세트', '쿠팡상자', 1, '피킹비 1100원', TRUE)
ON CONFLICT (product_code) DO UPDATE ON CONFLICT (product_code) DO UPDATE
SET product_name_snapshot = EXCLUDED.product_name_snapshot, SET product_name_snapshot = EXCLUDED.product_name_snapshot,
box_name = EXCLUDED.box_name, box_name = EXCLUDED.box_name,
+4 -4
View File
@@ -1,5 +1,5 @@
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
-- 쿠팡 밀크런 — 박스 입수량 규칙 2차 입력 (cupang_db) -- 쿠팡 밀크런 — 상자 입수량 규칙 2차 입력 (cupang_db)
-- 미라클통/미라클백/빠져락/멀티/점보 19건 -- 미라클통/미라클백/빠져락/멀티/점보 19건
-- --
-- 실행: -- 실행:
@@ -15,7 +15,7 @@
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
BEGIN; BEGIN;
-- box_no: 원자료의 박스 호수. 참고용 컬럼이며 memo 에는 넣지 않는다(피킹비만 기록). -- box_no: 원자료의 상자 호수. 참고용 컬럼이며 memo 에는 넣지 않는다(피킹비만 기록).
CREATE TEMP TABLE _seed2 ( CREATE TEMP TABLE _seed2 (
product_name TEXT, product_name TEXT,
picking_fee INTEGER, picking_fee INTEGER,
@@ -48,7 +48,7 @@ INSERT INTO cupang_box_rules
(product_code, product_name_snapshot, box_name, units_per_box, memo, active) (product_code, product_name_snapshot, box_name, units_per_box, memo, active)
SELECT p.product_code, SELECT p.product_code,
p.product_name, p.product_name,
'쿠팡박스', '쿠팡상자',
s.units_per_box, s.units_per_box,
'피킹비 ' || s.picking_fee || '', '피킹비 ' || s.picking_fee || '',
TRUE TRUE
@@ -66,7 +66,7 @@ SET product_name_snapshot = EXCLUDED.product_name_snapshot,
-- 카탈로그에 없어 입력되지 않은 제품 (수기 추가 후 재실행 대상) -- 카탈로그에 없어 입력되지 않은 제품 (수기 추가 후 재실행 대상)
SELECT s.product_name AS "카탈로그_미등록_제품명", SELECT s.product_name AS "카탈로그_미등록_제품명",
s.units_per_box AS "입수량", s.units_per_box AS "입수량",
s.box_no AS "박스호수" s.box_no AS "상자호수"
FROM _seed2 s FROM _seed2 s
WHERE NOT EXISTS ( WHERE NOT EXISTS (
SELECT 1 FROM cupang_products p SELECT 1 FROM cupang_products p
@@ -1,7 +1,7 @@
-- ===================================================================== -- =====================================================================
-- cupang_db 마이그레이션 005 — 출고 묶음에 박스 구성(box_plan) 보관 -- cupang_db 마이그레이션 005 — 출고 묶음에 상자 구성(box_plan) 보관
-- ===================================================================== -- =====================================================================
-- 사유: 확정 시점의 박스 구성(제품별 박스 / 자투리 혼합 박스의 내용물과 -- 사유: 확정 시점의 상자 구성(제품별 상자 / 자투리 혼합 상자의 내용물과
-- 고른 상자 종류)을 그대로 보여주기 위함. 라인(제품·수량)만으로는 -- 고른 상자 종류)을 그대로 보여주기 위함. 라인(제품·수량)만으로는
-- "어느 혼합 상자에 무엇이 몇 개" 들어갔는지 복원할 수 없다. -- "어느 혼합 상자에 무엇이 몇 개" 들어갔는지 복원할 수 없다.
-- --
+3 -3
View File
@@ -72,13 +72,13 @@ CREATE TRIGGER trg_cupang_centers_updated
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at(); FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
-- 2) 박스 입수량 규칙 -- 2) 상자 입수량 규칙
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cupang_box_rules ( CREATE TABLE IF NOT EXISTS cupang_box_rules (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
product_code TEXT NOT NULL UNIQUE, product_code TEXT NOT NULL UNIQUE,
product_name_snapshot TEXT, product_name_snapshot TEXT,
box_name TEXT NOT NULL DEFAULT '쿠팡박스', box_name TEXT NOT NULL DEFAULT '쿠팡상자',
units_per_box INTEGER NOT NULL CHECK (units_per_box > 0), units_per_box INTEGER NOT NULL CHECK (units_per_box > 0),
active BOOLEAN NOT NULL DEFAULT TRUE, active BOOLEAN NOT NULL DEFAULT TRUE,
memo TEXT NOT NULL DEFAULT '', memo TEXT NOT NULL DEFAULT '',
@@ -129,7 +129,7 @@ CREATE TABLE IF NOT EXISTS cupang_shipments (
worker TEXT NOT NULL DEFAULT '', worker TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '작성중', status TEXT NOT NULL DEFAULT '작성중',
memo TEXT NOT NULL DEFAULT '', memo TEXT NOT NULL DEFAULT '',
-- 확정 당시 박스 구성(제품별/혼합 상자와 내용물). 상세 화면 표시용. -- 확정 당시 상자 구성(제품별/혼합 상자와 내용물). 상세 화면 표시용.
box_plan JSONB NOT NULL DEFAULT '[]'::jsonb, box_plan JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()