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:
2026-09-01 15:58:29 +09:00
parent 8c8f6aebb2
commit 1c1ca69feb
12 changed files with 226 additions and 36 deletions
+1 -1
View File
@@ -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`
+11 -3
View File
@@ -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):
+63 -1
View File
@@ -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">
+38 -14
View File
@@ -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,29 +91,53 @@
{% 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>
</span>
</div> </div>
<div class="cpg-ship-meta erp-muted">
출고 {{ s.ship_date }} · {{ s.total_boxes }}박스 · {{ s.total_qty }}개 <ul class="cpg-dist-items">
{% if s.box_plan %}
{% for e in s.box_plan %}
<li class="cpg-dist-item{% if e.kind == 'mix' %} is-mix{% endif %}">
<div class="cpg-dist-line">
<span class="cpg-dist-name">{{ e.name }}</span>
<span class="cpg-dist-unit">{{ e.count }}박스 · {{ e.quantity }}개</span>
</div> </div>
<ul class="cpg-ship-items"> {% if e.kind == 'mix' and e['items'] %}
{% for it in s['items'] %} <ul class="cpg-dist-tree">
<li> {% for it in e['items'] %}
<span class="cpg-ship-iname">{{ it.name }}</span> <li><span class="cpg-tree-name">{{ it.product_name }}</span>
<span class="cpg-ship-iqty">{{ it.qty }}개</span> <span class="cpg-tree-qty">{{ it.quantity }}개</span></li>
<span class="cpg-ship-ibox">{{ it.boxes }}박스</span>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %}
</li>
{% endfor %}
{% else %}
{# 예전에 만든 출고(박스 구성 미저장) — 제품 합계만 표시 #}
{% 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> </div>
</li> </li>
{% endfor %} {% endfor %}
{% endif %}
</ul>
<div class="cpg-ship-meta erp-muted">출고 {{ s.ship_date }} · 센터입고 {{ s.center_arrival_date }}</div>
</li>
{% endfor %}
</ul> </ul>
{% else %} {% else %}
<p class="erp-muted cpg-empty">선택한 날짜의 출고 묶음이 없습니다. <p class="erp-muted cpg-empty">선택한 날짜의 출고 묶음이 없습니다.
@@ -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">
+20 -1
View File
@@ -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,6 +140,24 @@
</span> </span>
</div> </div>
<ul class="cpg-dist-items"> <ul class="cpg-dist-items">
{% if shipment.box_plan %}
{% for e in shipment.box_plan %}
<li class="cpg-dist-item{% if e.kind == 'mix' %} is-mix{% endif %}">
<div class="cpg-dist-line">
<span class="cpg-dist-name">{{ e.name }}</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>
{% endfor %}
{% else %}
{% for it in items %} {% for it in items %}
<li class="cpg-dist-item"> <li class="cpg-dist-item">
<div class="cpg-dist-line"> <div class="cpg-dist-line">
@@ -148,6 +166,7 @@
</div> </div>
</li> </li>
{% endfor %} {% endfor %}
{% endif %}
</ul> </ul>
</div> </div>
+19
View File
@@ -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;
+2
View File
@@ -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()
); );