feat(cupang): 출고 박스 구성(box_plan) 저장 + 달력 상세를 센터 분배 형식으로
- 마이그레이션 005: cupang_shipments.box_plan JSONB (확정 당시 박스 구성) 확정 payload 에서 제품별/혼합 상자와 내용물·상자 종류를 받아 저장 - 달력 오른쪽 목록을 ③ 센터 분배 카드 형식으로 변경 — 혼합 상자는 내용물 트리까지 표시. box_plan 이 없는 예전 출고는 기존 합계 표시로 폴백 - 출고 보기(view.html) ③ 목록도 동일하게 box_plan 기준 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일) + 출고리스트 엑셀 자동 다운로드(`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`
|
||||||
|
|||||||
@@ -496,8 +496,9 @@ class CupangDBStore:
|
|||||||
INSERT INTO cupang_shipments
|
INSERT INTO cupang_shipments
|
||||||
(created_by, document_date, ship_date,
|
(created_by, document_date, ship_date,
|
||||||
center_arrival_date, center_id, center_name_snapshot,
|
center_arrival_date, center_id, center_name_snapshot,
|
||||||
ship_method, outbound_summary, worker, status, memo)
|
ship_method, outbound_summary, worker, status, memo,
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
box_plan)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
@@ -512,6 +513,7 @@ class CupangDBStore:
|
|||||||
h["worker"],
|
h["worker"],
|
||||||
h["status"],
|
h["status"],
|
||||||
h["memo"],
|
h["memo"],
|
||||||
|
Jsonb(h["box_plan"]),
|
||||||
),
|
),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
shipment_id = row["id"]
|
shipment_id = row["id"]
|
||||||
@@ -533,7 +535,8 @@ class CupangDBStore:
|
|||||||
SET document_date = %s, ship_date = %s,
|
SET document_date = %s, ship_date = %s,
|
||||||
center_arrival_date = %s, center_id = %s,
|
center_arrival_date = %s, center_id = %s,
|
||||||
center_name_snapshot = %s, ship_method = %s,
|
center_name_snapshot = %s, ship_method = %s,
|
||||||
outbound_summary = %s, worker = %s, memo = %s
|
outbound_summary = %s, worker = %s, memo = %s,
|
||||||
|
box_plan = %s
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
@@ -547,6 +550,7 @@ class CupangDBStore:
|
|||||||
h["outbound_summary"],
|
h["outbound_summary"],
|
||||||
h["worker"],
|
h["worker"],
|
||||||
h["memo"],
|
h["memo"],
|
||||||
|
Jsonb(h["box_plan"]),
|
||||||
shipment_id,
|
shipment_id,
|
||||||
),
|
),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
@@ -690,6 +694,8 @@ 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 [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -793,6 +799,8 @@ class CupangDBStore:
|
|||||||
out = dict(row)
|
out = dict(row)
|
||||||
out["id"] = int(out["id"])
|
out["id"] = int(out["id"])
|
||||||
out["center_id"] = int(out["center_id"]) if out.get("center_id") is not None else None
|
out["center_id"] = int(out["center_id"]) if out.get("center_id") is not None else None
|
||||||
|
if not isinstance(out.get("box_plan"), list):
|
||||||
|
out["box_plan"] = []
|
||||||
for k in ("document_date", "ship_date", "center_arrival_date"):
|
for k in ("document_date", "ship_date", "center_arrival_date"):
|
||||||
v = out.get(k)
|
v = out.get(k)
|
||||||
if isinstance(v, date):
|
if isinstance(v, date):
|
||||||
|
|||||||
@@ -1244,6 +1244,59 @@ async def product_delete(
|
|||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
# 분배 확정 — 센터별 출고 묶음 생성
|
# 분배 확정 — 센터별 출고 묶음 생성
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def _clean_box_plan(raw: Any) -> list[dict[str, Any]]:
|
||||||
|
"""화면이 보낸 박스 구성을 표시용으로만 정리해서 저장한다.
|
||||||
|
|
||||||
|
수량·박스 수는 라인(제품 합계)이 정답이고, 이 값은 "어느 상자에 무엇이
|
||||||
|
담겼는지"를 보여주기 위한 스냅샷이다.
|
||||||
|
"""
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for entry in raw[:200]:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
kind = "mix" if str(entry.get("kind")) == "mix" else "product"
|
||||||
|
try:
|
||||||
|
count = max(int(entry.get("count") or 0), 0)
|
||||||
|
units = max(int(entry.get("units") or 0), 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if count <= 0:
|
||||||
|
continue
|
||||||
|
item: dict[str, Any] = {
|
||||||
|
"kind": kind,
|
||||||
|
"name": str(entry.get("name") or "")[:120],
|
||||||
|
"count": count,
|
||||||
|
"units": units,
|
||||||
|
"quantity": count * units,
|
||||||
|
}
|
||||||
|
if kind == "product":
|
||||||
|
item["product_code"] = str(entry.get("product_code") or "")[:60]
|
||||||
|
else:
|
||||||
|
item["box_type"] = str(entry.get("box_type") or "")[:40]
|
||||||
|
contents = []
|
||||||
|
for it in (entry.get("items") or [])[:50]:
|
||||||
|
if not isinstance(it, dict):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
qty = max(int(it.get("quantity") or 0), 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if qty <= 0:
|
||||||
|
continue
|
||||||
|
contents.append(
|
||||||
|
{
|
||||||
|
"product_name": str(it.get("product_name") or "")[:120],
|
||||||
|
"product_code": str(it.get("product_code") or "")[:60],
|
||||||
|
"quantity": qty,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
item["items"] = contents
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/box-calc/confirm")
|
@router.post("/api/box-calc/confirm")
|
||||||
async def box_calc_confirm(
|
async def box_calc_confirm(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -1334,7 +1387,15 @@ async def box_calc_confirm(
|
|||||||
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({"center": center, "method": method, "lines": lines, "summary": summary})
|
plans.append(
|
||||||
|
{
|
||||||
|
"center": center,
|
||||||
|
"method": method,
|
||||||
|
"lines": lines,
|
||||||
|
"summary": summary,
|
||||||
|
"box_plan": _clean_box_plan(raw.get("box_plan")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if not plans:
|
if not plans:
|
||||||
raise HTTPException(status_code=400, detail="담긴 품목이 없습니다.")
|
raise HTTPException(status_code=400, detail="담긴 품목이 없습니다.")
|
||||||
@@ -1357,6 +1418,7 @@ async def box_calc_confirm(
|
|||||||
"worker": worker,
|
"worker": worker,
|
||||||
"status": "출고준비",
|
"status": "출고준비",
|
||||||
"memo": "박스 계산에서 분배 확정",
|
"memo": "박스 계산에서 분배 확정",
|
||||||
|
"box_plan": plan["box_plan"],
|
||||||
},
|
},
|
||||||
lines=plan["lines"],
|
lines=plan["lines"],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901m" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
@@ -1254,11 +1254,39 @@
|
|||||||
return t + ", " + byType[t] + "개";
|
return t + ", " + byType[t] + "개";
|
||||||
}).join(" / ");
|
}).join(" / ");
|
||||||
|
|
||||||
|
// 확정 후 상세 화면에서 "어느 상자에 무엇이" 를 보여주기 위한 스냅샷
|
||||||
|
var boxPlan = rows.map(function (a) {
|
||||||
|
if (a.key.indexOf("m:") === 0) {
|
||||||
|
return {
|
||||||
|
kind: "mix",
|
||||||
|
name: labels[a.key] || a.key,
|
||||||
|
box_type: mixTypeOf(a.key),
|
||||||
|
count: a.count,
|
||||||
|
units: units[a.key] || 0,
|
||||||
|
items: (contents[a.key] || []).map(function (it) {
|
||||||
|
return {
|
||||||
|
product_code: it.product_code,
|
||||||
|
product_name: it.product_name,
|
||||||
|
quantity: it.quantity * a.count
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "product",
|
||||||
|
name: labels[a.key] || a.key,
|
||||||
|
product_code: a.key.slice(2),
|
||||||
|
count: a.count,
|
||||||
|
units: units[a.key] || 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
center_id: cid,
|
center_id: cid,
|
||||||
ship_method: methods[String(cid)] || "",
|
ship_method: methods[String(cid)] || "",
|
||||||
boxes: boxes,
|
boxes: boxes,
|
||||||
box_summary: boxSummary,
|
box_summary: boxSummary,
|
||||||
|
box_plan: boxPlan,
|
||||||
items: items
|
items: items
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901m" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% 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=20260901m" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% 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=20260901m" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
@@ -91,27 +91,51 @@
|
|||||||
{% if sel_shipments %}
|
{% if sel_shipments %}
|
||||||
<ul class="cpg-list">
|
<ul class="cpg-list">
|
||||||
{% for s in sel_shipments %}
|
{% for s in sel_shipments %}
|
||||||
<li class="cpg-list-item">
|
{# ③ 센터 분배 카드와 같은 형식 — 혼합 상자는 내용물까지 펼쳐 보여준다 #}
|
||||||
<div class="cpg-ship-card">
|
<li class="cpg-list-item cpg-dist-center has-items">
|
||||||
<div class="cpg-ship-head">
|
<div class="cpg-dist-head">
|
||||||
<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 cpg-mini">{{ s.ship_method }}</span>
|
<span class="erp-badge erp-badge-neutral">{{ s.ship_method }}</span>
|
||||||
|
<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_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>
|
||||||
</div>
|
</span>
|
||||||
<div class="cpg-ship-meta erp-muted">
|
</div>
|
||||||
출고 {{ s.ship_date }} · {{ s.total_boxes }}박스 · {{ s.total_qty }}개
|
|
||||||
</div>
|
<ul class="cpg-dist-items">
|
||||||
<ul class="cpg-ship-items">
|
{% if s.box_plan %}
|
||||||
{% for it in s['items'] %}
|
{% for e in s.box_plan %}
|
||||||
<li>
|
<li class="cpg-dist-item{% if e.kind == 'mix' %} is-mix{% endif %}">
|
||||||
<span class="cpg-ship-iname">{{ it.name }}</span>
|
<div class="cpg-dist-line">
|
||||||
<span class="cpg-ship-iqty">{{ it.qty }}개</span>
|
<span class="cpg-dist-name">{{ e.name }}</span>
|
||||||
<span class="cpg-ship-ibox">{{ it.boxes }}박스</span>
|
<span class="cpg-dist-unit">{{ e.count }}박스 · {{ e.quantity }}개</span>
|
||||||
|
</div>
|
||||||
|
{% if e.kind == 'mix' and e['items'] %}
|
||||||
|
<ul class="cpg-dist-tree">
|
||||||
|
{% for it in e['items'] %}
|
||||||
|
<li><span class="cpg-tree-name">{{ it.product_name }}</span>
|
||||||
|
<span class="cpg-tree-qty">{{ it.quantity }}개</span></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
{% else %}
|
||||||
</div>
|
{# 예전에 만든 출고(박스 구성 미저장) — 제품 합계만 표시 #}
|
||||||
|
{% for it in s['items'] %}
|
||||||
|
<li class="cpg-dist-item">
|
||||||
|
<div class="cpg-dist-line">
|
||||||
|
<span class="cpg-dist-name">{{ it.name }}</span>
|
||||||
|
<span class="cpg-dist-unit">{{ it.boxes }}박스 · {{ it.qty }}개</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="cpg-ship-meta erp-muted">출고 {{ s.ship_date }} · 센터입고 {{ s.center_arrival_date }}</div>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901m" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% 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=20260901m" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901n" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{# 출고 묶음 보기 — 박스 계산 화면과 같은 3열 구성. 읽기 전용(수정 없음). #}
|
{# 출고 묶음 보기 — 박스 계산 화면과 같은 3열 구성. 읽기 전용(수정 없음). #}
|
||||||
@@ -140,14 +140,33 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="cpg-dist-items">
|
<ul class="cpg-dist-items">
|
||||||
{% for it in items %}
|
{% if shipment.box_plan %}
|
||||||
<li class="cpg-dist-item">
|
{% for e in shipment.box_plan %}
|
||||||
<div class="cpg-dist-line">
|
<li class="cpg-dist-item{% if e.kind == 'mix' %} is-mix{% endif %}">
|
||||||
<span class="cpg-dist-name">{{ it.product_name }}</span>
|
<div class="cpg-dist-line">
|
||||||
<span class="cpg-dist-unit">{{ it.quantity }}개</span>
|
<span class="cpg-dist-name">{{ e.name }}</span>
|
||||||
</div>
|
<span class="cpg-dist-unit">{{ e.count }}박스 · {{ e.quantity }}개</span>
|
||||||
</li>
|
</div>
|
||||||
{% endfor %}
|
{% if e.kind == 'mix' and e['items'] %}
|
||||||
|
<ul class="cpg-dist-tree">
|
||||||
|
{% for it in e['items'] %}
|
||||||
|
<li><span class="cpg-tree-name">{{ it.product_name }}</span>
|
||||||
|
<span class="cpg-tree-qty">{{ it.quantity }}개</span></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
{% for it in items %}
|
||||||
|
<li class="cpg-dist-item">
|
||||||
|
<div class="cpg-dist-line">
|
||||||
|
<span class="cpg-dist-name">{{ it.product_name }}</span>
|
||||||
|
<span class="cpg-dist-unit">{{ it.quantity }}개</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1403,3 +1403,22 @@ body.cpg-dragging { cursor: grabbing; user-select: none; }
|
|||||||
.cpg-view-meta dt { font-size: 11px; color: var(--color-midtone-gray); }
|
.cpg-view-meta dt { font-size: 11px; color: var(--color-midtone-gray); }
|
||||||
.cpg-view-meta dd { margin: 2px 0 0; font-size: 13px; }
|
.cpg-view-meta dd { margin: 2px 0 0; font-size: 13px; }
|
||||||
.cpg-view-row td { padding: 4px 6px; }
|
.cpg-view-row td { padding: 4px 6px; }
|
||||||
|
|
||||||
|
/* ── 달력 오른쪽 출고 목록 — ③ 센터 분배 카드와 같은 형식 ── */
|
||||||
|
.cpg-list .cpg-dist-center { overflow: hidden; }
|
||||||
|
.cpg-list .cpg-dist-head .cpg-ship-name {
|
||||||
|
flex: 1 1 auto; min-width: 0;
|
||||||
|
font-size: 13px; font-weight: 700; letter-spacing: .02em;
|
||||||
|
color: var(--color-canvas-white); text-decoration: none;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cpg-list .cpg-dist-head .cpg-ship-name:hover { text-decoration: underline; }
|
||||||
|
.cpg-list .cpg-dist-head .cpg-icon-btn.is-danger {
|
||||||
|
border-color: rgba(255, 255, 255, .35);
|
||||||
|
color: var(--color-canvas-white);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.cpg-list .cpg-dist-head .cpg-icon-btn.is-danger:hover { background: rgba(255, 255, 255, .16); }
|
||||||
|
.cpg-list .cpg-ship-meta { padding: 0 10px 8px; font-size: 11px; }
|
||||||
|
/* 혼합 상자 줄은 굵게 — 어떤 상자인지 바로 보이게 */
|
||||||
|
.cpg-dist-item.is-mix .cpg-dist-name { font-weight: 600; }
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- cupang_db 마이그레이션 005 — 출고 묶음에 박스 구성(box_plan) 보관
|
||||||
|
-- =====================================================================
|
||||||
|
-- 사유: 확정 시점의 박스 구성(제품별 박스 / 자투리 혼합 박스의 내용물과
|
||||||
|
-- 고른 상자 종류)을 그대로 보여주기 위함. 라인(제품·수량)만으로는
|
||||||
|
-- "어느 혼합 상자에 무엇이 몇 개" 들어갔는지 복원할 수 없다.
|
||||||
|
--
|
||||||
|
-- 형식(JSONB 배열):
|
||||||
|
-- [{"kind":"product","name":"미라네 1호 세트","product_code":"MS-1001",
|
||||||
|
-- "count":2,"units":8,"quantity":16},
|
||||||
|
-- {"kind":"mix","name":"6호상자 혼합 #1","box_type":"6호상자",
|
||||||
|
-- "count":1,"units":7,"quantity":7,
|
||||||
|
-- "items":[{"product_name":"미라클백 6.0L","quantity":6}, ...]}]
|
||||||
|
--
|
||||||
|
-- 멱등: ADD COLUMN IF NOT EXISTS. 기존 행은 빈 배열([])이 되며,
|
||||||
|
-- 그 경우 화면은 예전처럼 라인 기준으로 표시한다.
|
||||||
|
--
|
||||||
|
-- 실행:
|
||||||
|
-- docker exec -i postgres-db psql -U postgres -d cupang_db < /opt/www/main/scripts/sql/cupang_db_005_shipment_box_plan.sql
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
\connect cupang_db
|
||||||
|
|
||||||
|
ALTER TABLE cupang_shipments
|
||||||
|
ADD COLUMN IF NOT EXISTS box_plan JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
|
SELECT 'cupang_db 005 done' AS status;
|
||||||
@@ -129,6 +129,8 @@ 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,
|
||||||
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()
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user