feat(cupang): 센터별 출고방식 + 분배 확정 → 출고 묶음 생성
③ 센터 분배 헤더에 [분배 확정] 을 두고, 조건이 갖춰질 때만 활성화한다. 조건은 두 가지다. 남은 박스가 0 이어야 하고, 담긴 센터마다 출고방식 (택배/파렛트)이 골라져 있어야 한다. 비활성일 때는 버튼 툴팁에 막힌 사유를 적는다(남은 박스 수, 출고방식 미선택 센터명). 확정을 누르면 달력이 바로 뜨는 팝업에서 출고일자를 고르고, 확인 시 센터마다 출고 묶음 1건(status=출고준비)을 만들어 쿠팡 달력의 그 날짜로 이동한다. 혼합 박스는 내용물 제품으로 풀어 담고 같은 제품끼리 합산한다. 박스 수는 화면 값을 믿지 않고 저장 시 서버가 수량·입수량으로 다시 계산한다. - router: POST /cupang/api/box-calc/confirm - box_calc.html: 센터 헤더 출고방식 select(미선택은 빨간 배경), 확정 팝업 달력 - 임시 저장 스냅샷에 출고방식 포함 - 센터입고일은 출고일과 같게 저장(상세에서 수정)
This commit is contained in:
@@ -16,6 +16,8 @@ from fractions import Fraction
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from datetime import date as _date
|
||||||
|
|
||||||
from app.timezone import today_kst
|
from app.timezone import today_kst
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request
|
from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request
|
||||||
@@ -908,6 +910,128 @@ async def product_delete(
|
|||||||
return RedirectResponse(url="/cupang/products", status_code=303)
|
return RedirectResponse(url="/cupang/products", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 분배 확정 — 센터별 출고 묶음 생성
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
@router.post("/api/box-calc/confirm")
|
||||||
|
async def box_calc_confirm(
|
||||||
|
request: Request,
|
||||||
|
payload: dict[str, Any] = Body(...),
|
||||||
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""{ship_date, centers:[{center_id, ship_method, boxes, items:[...]}]} → 센터마다 출고 묶음 1건 생성.
|
||||||
|
|
||||||
|
화면에서 보낸 박스 수는 요약 표시용이고, 라인의 박스 수는 저장 시
|
||||||
|
서버(store.compute_boxes)가 수량과 입수량으로 다시 계산한다.
|
||||||
|
"""
|
||||||
|
from .store import SHIP_METHODS as _METHODS # noqa: WPS433
|
||||||
|
|
||||||
|
store = _store(request)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||||
|
|
||||||
|
ship_date = str(payload.get("ship_date") or "").strip()
|
||||||
|
try:
|
||||||
|
_date.fromisoformat(ship_date)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="출고일자를 올바르게 선택하세요.")
|
||||||
|
|
||||||
|
raw_centers = payload.get("centers")
|
||||||
|
if not isinstance(raw_centers, list) or not raw_centers:
|
||||||
|
raise HTTPException(status_code=400, detail="확정할 센터가 없습니다.")
|
||||||
|
|
||||||
|
rules = {r["product_code"]: r for r in store.list_box_rules()}
|
||||||
|
known_centers = {str(c["id"]): c for c in store.list_centers(include_inactive=True)}
|
||||||
|
today = today_kst().isoformat()
|
||||||
|
worker = str(user.get("name") or user.get("email") or "")
|
||||||
|
|
||||||
|
plans: list[dict[str, Any]] = []
|
||||||
|
for raw in raw_centers:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
cid = str(raw.get("center_id") or "").strip()
|
||||||
|
center = known_centers.get(cid)
|
||||||
|
if center is None:
|
||||||
|
raise HTTPException(status_code=400, detail=f"알 수 없는 센터입니다: {cid}")
|
||||||
|
|
||||||
|
method = str(raw.get("ship_method") or "").strip()
|
||||||
|
if method not in _METHODS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail=f"{center['name']} 의 출고방식을 선택하세요."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 같은 제품이 여러 박스로 나뉘어 담겼을 수 있으므로 제품코드로 합친다.
|
||||||
|
merged: dict[str, int] = {}
|
||||||
|
for it in raw.get("items") or []:
|
||||||
|
if not isinstance(it, dict):
|
||||||
|
continue
|
||||||
|
code = str(it.get("product_code") or "").strip()
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
qty = int(it.get("quantity") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
qty = 0
|
||||||
|
if qty <= 0:
|
||||||
|
continue
|
||||||
|
merged[code] = merged.get(code, 0) + qty
|
||||||
|
|
||||||
|
if not merged:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for code, qty in merged.items():
|
||||||
|
rule = rules.get(code)
|
||||||
|
lines.append(
|
||||||
|
{
|
||||||
|
"product_code": code,
|
||||||
|
"product_name_snapshot": (rule or {}).get("product_name_snapshot") or code,
|
||||||
|
"quantity": qty,
|
||||||
|
"units_per_box": (rule or {}).get("units_per_box"),
|
||||||
|
"box_rule_id": (rule or {}).get("id"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
boxes = max(int(raw.get("boxes") or 0), 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
boxes = 0
|
||||||
|
pieces = sum(merged.values())
|
||||||
|
summary = f"{boxes}박스 · {pieces}개" if boxes else f"{pieces}개"
|
||||||
|
|
||||||
|
plans.append({"center": center, "method": method, "lines": lines, "summary": summary})
|
||||||
|
|
||||||
|
if not plans:
|
||||||
|
raise HTTPException(status_code=400, detail="담긴 품목이 없습니다.")
|
||||||
|
|
||||||
|
created: list[dict[str, Any]] = []
|
||||||
|
for plan in plans:
|
||||||
|
center = plan["center"]
|
||||||
|
try:
|
||||||
|
ship = store.create_shipment(
|
||||||
|
created_by=str(user.get("email") or ""),
|
||||||
|
header={
|
||||||
|
"document_date": today,
|
||||||
|
"ship_date": ship_date,
|
||||||
|
# 센터입고일은 출고일과 같게 두고, 필요하면 출고 상세에서 고친다.
|
||||||
|
"center_arrival_date": ship_date,
|
||||||
|
"center_id": center["id"],
|
||||||
|
"center_name_snapshot": center["name"],
|
||||||
|
"ship_method": plan["method"],
|
||||||
|
"outbound_summary": plan["summary"],
|
||||||
|
"worker": worker,
|
||||||
|
"status": "출고준비",
|
||||||
|
"memo": "박스 계산에서 분배 확정",
|
||||||
|
},
|
||||||
|
lines=plan["lines"],
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
created.append({"id": ship["id"], "center_name": center["name"]})
|
||||||
|
|
||||||
|
return JSONResponse({"created": created, "ship_date": ship_date})
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
# 박스 계산 임시 저장 (화면 상태 스냅샷)
|
# 박스 계산 임시 저장 (화면 상태 스냅샷)
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831f" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831g" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
@@ -79,9 +79,11 @@
|
|||||||
|
|
||||||
<!-- ③ 센터 분배 — 선택한 센터만 표시 -->
|
<!-- ③ 센터 분배 — 선택한 센터만 표시 -->
|
||||||
<div class="erp-card cpg-form-card cpg-dist-card">
|
<div class="erp-card cpg-form-card cpg-dist-card">
|
||||||
<div class="cpg-card-head">
|
<div class="cpg-card-head cpg-dist-head-bar">
|
||||||
<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
|
||||||
|
title="모든 박스를 배분하고 센터마다 출고방식을 고르면 활성화됩니다.">분배 확정</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if not centers %}
|
{% if not centers %}
|
||||||
@@ -123,6 +125,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 분배 확정 — 출고일자 선택 -->
|
||||||
|
<div class="cpg-modal" id="cpg-cfm-dlg" hidden>
|
||||||
|
<div class="cpg-modal-back" data-cfm-close></div>
|
||||||
|
<div class="cpg-modal-box cpg-cfm-box" role="dialog" aria-modal="true" aria-labelledby="cpg-cfm-title">
|
||||||
|
<h3 id="cpg-cfm-title">분배 확정 — 출고일자 선택</h3>
|
||||||
|
|
||||||
|
<div class="cpg-cal">
|
||||||
|
<div class="cpg-cal-head">
|
||||||
|
<button type="button" class="erp-btn erp-btn-outline cpg-btn-sm" id="cpg-cal-prev" aria-label="이전 달">◀</button>
|
||||||
|
<strong id="cpg-cal-label"></strong>
|
||||||
|
<button type="button" class="erp-btn erp-btn-outline cpg-btn-sm" id="cpg-cal-next" aria-label="다음 달">▶</button>
|
||||||
|
</div>
|
||||||
|
<div class="cpg-cal-dow">
|
||||||
|
<span>일</span><span>월</span><span>화</span><span>수</span><span>목</span><span>금</span><span>토</span>
|
||||||
|
</div>
|
||||||
|
<div class="cpg-cal-grid" id="cpg-cal-grid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="cpg-cfm-pick" id="cpg-cfm-pick"></p>
|
||||||
|
<div class="cpg-cfm-list" id="cpg-cfm-list"></div>
|
||||||
|
<p class="erp-muted cpg-cfm-note">센터입고일은 출고일과 같게 저장됩니다. 필요하면 출고 상세에서 고치세요.</p>
|
||||||
|
|
||||||
|
<div class="cpg-dlg-actions cpg-cfm-act">
|
||||||
|
<span class="erp-muted" id="cpg-cfm-msg"></span>
|
||||||
|
<button type="button" class="erp-btn erp-btn-primary" id="cpg-cfm-ok" disabled>확인</button>
|
||||||
|
<button type="button" class="erp-btn erp-btn-outline" data-cfm-close>취소</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 임시 저장 / 불러오기 -->
|
<!-- 임시 저장 / 불러오기 -->
|
||||||
<div class="cpg-modal" id="cpg-draft-dlg" hidden>
|
<div class="cpg-modal" id="cpg-draft-dlg" hidden>
|
||||||
<div class="cpg-modal-back" data-draft-close></div>
|
<div class="cpg-modal-back" data-draft-close></div>
|
||||||
@@ -204,6 +236,7 @@
|
|||||||
var labels = {}; // key -> 표시 이름
|
var labels = {}; // key -> 표시 이름
|
||||||
var openCenters = []; // ③ 에 추가한 센터 id (문자열)
|
var openCenters = []; // ③ 에 추가한 센터 id (문자열)
|
||||||
var alloc = {}; // centerId -> [{id, key, count}]
|
var alloc = {}; // centerId -> [{id, key, count}]
|
||||||
|
var methods = {}; // centerId -> 출고방식(택배/파렛트)
|
||||||
var seq = 0;
|
var seq = 0;
|
||||||
var pending = null; // 대화상자 대상 {key, centerId}
|
var pending = null; // 대화상자 대상 {key, centerId}
|
||||||
|
|
||||||
@@ -270,7 +303,7 @@
|
|||||||
units[mixKey(m.box_name, i)] = n;
|
units[mixKey(m.box_name, i)] = n;
|
||||||
labels[mixKey(m.box_name, i)] = m.box_name + " 혼합 #" + (i + 1);
|
labels[mixKey(m.box_name, i)] = m.box_name + " 혼합 #" + (i + 1);
|
||||||
contents[mixKey(m.box_name, i)] = b.items.map(function (it) {
|
contents[mixKey(m.box_name, i)] = b.items.map(function (it) {
|
||||||
return { product_name: it.product_name, quantity: it.quantity };
|
return { product_code: it.product_code, product_name: it.product_name, quantity: it.quantity };
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -486,9 +519,16 @@
|
|||||||
treeHtml(a) +
|
treeHtml(a) +
|
||||||
"</li>";
|
"</li>";
|
||||||
});
|
});
|
||||||
|
var method = methods[String(cid)] || "";
|
||||||
html += '<div class="cpg-dist-center' + (rows.length ? " has-items" : "") + '" data-drop-for="' + esc(cid) + '">' +
|
html += '<div class="cpg-dist-center' + (rows.length ? " has-items" : "") + '" data-drop-for="' + esc(cid) + '">' +
|
||||||
'<div class="cpg-dist-head">' +
|
'<div class="cpg-dist-head">' +
|
||||||
"<strong>" + esc(centerName(cid)) + "</strong>" +
|
"<strong>" + esc(centerName(cid)) + "</strong>" +
|
||||||
|
'<select class="erp-select cpg-dist-method' + (method ? "" : " is-unset") + '"' +
|
||||||
|
' data-method-for="' + esc(cid) + '" aria-label="출고방식">' +
|
||||||
|
'<option value=""' + (method ? "" : " selected") + '>출고방식</option>' +
|
||||||
|
'<option value="택배"' + (method === "택배" ? " selected" : "") + '>택배</option>' +
|
||||||
|
'<option value="파렛트"' + (method === "파렛트" ? " selected" : "") + '>파렛트</option>' +
|
||||||
|
"</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>" +
|
||||||
@@ -501,6 +541,7 @@
|
|||||||
"</div>";
|
"</div>";
|
||||||
});
|
});
|
||||||
distList.innerHTML = html;
|
distList.innerHTML = html;
|
||||||
|
syncConfirm();
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() { renderSummary(); renderDist(); }
|
function render() { renderSummary(); renderDist(); }
|
||||||
@@ -690,6 +731,7 @@
|
|||||||
if (tree) tree.outerHTML = treeHtml(entry);
|
if (tree) tree.outerHTML = treeHtml(entry);
|
||||||
renderSummary();
|
renderSummary();
|
||||||
updateCenterTotals(cid);
|
updateCenterTotals(cid);
|
||||||
|
syncConfirm();
|
||||||
});
|
});
|
||||||
|
|
||||||
distList.addEventListener("click", function (e) {
|
distList.addEventListener("click", function (e) {
|
||||||
@@ -699,6 +741,7 @@
|
|||||||
if (e.target.closest(".cpg-dist-close")) { // 센터 자체를 목록에서 뺀다
|
if (e.target.closest(".cpg-dist-close")) { // 센터 자체를 목록에서 뺀다
|
||||||
openCenters = openCenters.filter(function (x) { return x !== cid; });
|
openCenters = openCenters.filter(function (x) { return x !== cid; });
|
||||||
delete alloc[cid];
|
delete alloc[cid];
|
||||||
|
delete methods[cid];
|
||||||
render();
|
render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -778,6 +821,196 @@
|
|||||||
msg.textContent = "";
|
msg.textContent = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 분배 확정 ─────────────────────────────────────
|
||||||
|
var confirmBtn = document.getElementById("cpg-confirm");
|
||||||
|
var cfmDlg = document.getElementById("cpg-cfm-dlg");
|
||||||
|
var cfmOk = document.getElementById("cpg-cfm-ok");
|
||||||
|
var cfmMsg = document.getElementById("cpg-cfm-msg");
|
||||||
|
var cfmPick = document.getElementById("cpg-cfm-pick");
|
||||||
|
var cfmList = document.getElementById("cpg-cfm-list");
|
||||||
|
var calGrid = document.getElementById("cpg-cal-grid");
|
||||||
|
var calLabel = document.getElementById("cpg-cal-label");
|
||||||
|
var picked = ""; // 선택한 출고일 (YYYY-MM-DD)
|
||||||
|
var calY = 0, calM = 0; // 달력이 보여주는 연/월
|
||||||
|
|
||||||
|
function pad2(n) { return (n < 10 ? "0" : "") + n; }
|
||||||
|
function iso(y, m, d) { return y + "-" + pad2(m + 1) + "-" + pad2(d); }
|
||||||
|
|
||||||
|
// 배분이 남았는지 — ② 요약과 같은 기준
|
||||||
|
function boxTotals() {
|
||||||
|
var total = 0, remain = 0;
|
||||||
|
calc.results.forEach(function (r) {
|
||||||
|
total += r.full_boxes;
|
||||||
|
remain += remainFor(prodKey(r.product_code));
|
||||||
|
});
|
||||||
|
calc.mixes.forEach(function (m) {
|
||||||
|
m.boxes.forEach(function (b, i) {
|
||||||
|
total += 1;
|
||||||
|
remain += remainFor(mixKey(m.box_name, i));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { total: total, remain: remain };
|
||||||
|
}
|
||||||
|
|
||||||
|
function filledCenters() {
|
||||||
|
return openCenters.filter(function (cid) { return (alloc[cid] || []).length > 0; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 확정 가능 여부 + 왜 안 되는지 사유
|
||||||
|
function confirmBlockReason() {
|
||||||
|
var t = boxTotals();
|
||||||
|
if (!t.total) return "먼저 ① 에서 계산하세요.";
|
||||||
|
if (t.remain) return "아직 " + t.remain + "박스가 남았습니다.";
|
||||||
|
var cids = filledCenters();
|
||||||
|
if (!cids.length) return "센터에 담긴 박스가 없습니다.";
|
||||||
|
var missing = cids.filter(function (cid) { return !methods[String(cid)]; });
|
||||||
|
if (missing.length) return "출고방식 미선택: " + missing.map(centerName).join(", ");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncConfirm() {
|
||||||
|
if (!confirmBtn) return;
|
||||||
|
var why = confirmBlockReason();
|
||||||
|
confirmBtn.disabled = !!why;
|
||||||
|
confirmBtn.title = why || "출고일자를 골라 출고 묶음을 만듭니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 센터별 전송 payload — 혼합 박스는 내용물 제품으로 풀어서 담는다.
|
||||||
|
function buildPlans() {
|
||||||
|
return filledCenters().map(function (cid) {
|
||||||
|
var rows = alloc[cid] || [];
|
||||||
|
var items = [];
|
||||||
|
var boxes = 0;
|
||||||
|
rows.forEach(function (a) {
|
||||||
|
boxes += a.count;
|
||||||
|
if (a.key.indexOf("p:") === 0) {
|
||||||
|
items.push({ product_code: a.key.slice(2), quantity: a.count * (units[a.key] || 0) });
|
||||||
|
} else {
|
||||||
|
(contents[a.key] || []).forEach(function (it) {
|
||||||
|
items.push({ product_code: it.product_code, quantity: it.quantity * a.count });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
center_id: cid,
|
||||||
|
ship_method: methods[String(cid)] || "",
|
||||||
|
boxes: boxes,
|
||||||
|
items: items
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCal() {
|
||||||
|
if (!calGrid) return;
|
||||||
|
calLabel.textContent = calY + "년 " + (calM + 1) + "월";
|
||||||
|
var first = new Date(calY, calM, 1).getDay();
|
||||||
|
var days = new Date(calY, calM + 1, 0).getDate();
|
||||||
|
var now = new Date();
|
||||||
|
var todayIso = iso(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
var html = "";
|
||||||
|
for (var i = 0; i < first; i++) html += '<span class="cpg-cal-cell is-blank"></span>';
|
||||||
|
for (var d = 1; d <= days; d++) {
|
||||||
|
var v = iso(calY, calM, d);
|
||||||
|
var dow = (first + d - 1) % 7;
|
||||||
|
html += '<button type="button" class="cpg-cal-cell' +
|
||||||
|
(v === picked ? " is-picked" : "") +
|
||||||
|
(v === todayIso ? " is-today" : "") +
|
||||||
|
(dow === 0 ? " is-sun" : (dow === 6 ? " is-sat" : "")) +
|
||||||
|
'" data-day="' + v + '">' + d + "</button>";
|
||||||
|
}
|
||||||
|
calGrid.innerHTML = html;
|
||||||
|
cfmPick.textContent = picked ? "출고일: " + picked : "달력에서 출고일자를 고르세요.";
|
||||||
|
cfmOk.disabled = !picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openConfirm() {
|
||||||
|
var why = confirmBlockReason();
|
||||||
|
if (why) { msg.textContent = why; return; }
|
||||||
|
var t = new Date();
|
||||||
|
picked = iso(t.getFullYear(), t.getMonth(), t.getDate());
|
||||||
|
calY = t.getFullYear();
|
||||||
|
calM = t.getMonth();
|
||||||
|
cfmMsg.textContent = "";
|
||||||
|
cfmList.innerHTML = buildPlans().map(function (pl) {
|
||||||
|
var pieces = pl.items.reduce(function (n, it) { return n + it.quantity; }, 0);
|
||||||
|
return '<div class="cpg-cfm-row">' +
|
||||||
|
'<span class="cpg-cfm-cname">' + esc(centerName(pl.center_id)) + "</span>" +
|
||||||
|
'<span class="cpg-cfm-cmeta">' + esc(pl.ship_method) + " · " + pl.boxes + "박스 · " + pieces + "개</span>" +
|
||||||
|
"</div>";
|
||||||
|
}).join("");
|
||||||
|
renderCal();
|
||||||
|
cfmDlg.hidden = false;
|
||||||
|
cfmOk.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirm() { cfmDlg.hidden = true; }
|
||||||
|
|
||||||
|
function submitConfirm() {
|
||||||
|
if (!picked) return;
|
||||||
|
var plans = buildPlans();
|
||||||
|
if (!plans.length) { cfmMsg.textContent = "담긴 품목이 없습니다."; return; }
|
||||||
|
cfmOk.disabled = true;
|
||||||
|
cfmMsg.textContent = "저장 중…";
|
||||||
|
fetch("/cupang/api/box-calc/confirm", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ship_date: picked, centers: plans })
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) {
|
||||||
|
return r.json().catch(function () { return {}; }).then(function (e) {
|
||||||
|
throw new Error(e.detail || "http " + r.status);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
var d = (data && data.ship_date) || picked;
|
||||||
|
var parts = d.split("-");
|
||||||
|
// 달력으로 이동해 방금 만든 출고 묶음을 보여준다.
|
||||||
|
window.location.href = "/cupang/?year=" + parts[0] + "&month=" + parseInt(parts[1], 10) + "&date=" + d;
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
cfmMsg.textContent = (err && err.message) || "확정 실패 — 다시 시도하세요.";
|
||||||
|
cfmOk.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmBtn) confirmBtn.addEventListener("click", openConfirm);
|
||||||
|
if (cfmDlg) {
|
||||||
|
Array.prototype.forEach.call(cfmDlg.querySelectorAll("[data-cfm-close]"), function (el) {
|
||||||
|
el.addEventListener("click", closeConfirm);
|
||||||
|
});
|
||||||
|
calGrid.addEventListener("click", function (e) {
|
||||||
|
var cell = e.target.closest("[data-day]");
|
||||||
|
if (!cell) return;
|
||||||
|
picked = cell.getAttribute("data-day");
|
||||||
|
renderCal();
|
||||||
|
});
|
||||||
|
document.getElementById("cpg-cal-prev").addEventListener("click", function () {
|
||||||
|
calM -= 1; if (calM < 0) { calM = 11; calY -= 1; } renderCal();
|
||||||
|
});
|
||||||
|
document.getElementById("cpg-cal-next").addEventListener("click", function () {
|
||||||
|
calM += 1; if (calM > 11) { calM = 0; calY += 1; } renderCal();
|
||||||
|
});
|
||||||
|
cfmOk.addEventListener("click", submitConfirm);
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Escape" && cfmDlg && !cfmDlg.hidden) closeConfirm();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 센터 헤더의 출고방식 선택
|
||||||
|
if (distList) {
|
||||||
|
distList.addEventListener("change", function (e) {
|
||||||
|
var sel = e.target.closest("[data-method-for]");
|
||||||
|
if (!sel) return;
|
||||||
|
var cid = String(sel.getAttribute("data-method-for"));
|
||||||
|
if (sel.value) { methods[cid] = sel.value; } else { delete methods[cid]; }
|
||||||
|
sel.classList.toggle("is-unset", !sel.value);
|
||||||
|
syncConfirm();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── 임시 저장 / 불러오기 ──────────────────────────
|
// ── 임시 저장 / 불러오기 ──────────────────────────
|
||||||
var saveBtn = document.getElementById("cpg-calc-save");
|
var saveBtn = document.getElementById("cpg-calc-save");
|
||||||
var loadBtn = document.getElementById("cpg-calc-load");
|
var loadBtn = document.getElementById("cpg-calc-load");
|
||||||
@@ -792,6 +1025,7 @@
|
|||||||
items: collect(),
|
items: collect(),
|
||||||
openCenters: openCenters.slice(),
|
openCenters: openCenters.slice(),
|
||||||
alloc: alloc,
|
alloc: alloc,
|
||||||
|
methods: methods,
|
||||||
stamped: stamped,
|
stamped: stamped,
|
||||||
allStamped: allStamped
|
allStamped: allStamped
|
||||||
};
|
};
|
||||||
@@ -814,6 +1048,7 @@
|
|||||||
if (addAlloc(cid, a.key, a.count) <= 0) skipped += 1;
|
if (addAlloc(cid, a.key, a.count) <= 0) skipped += 1;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
methods = snap.methods && typeof snap.methods === "object" ? snap.methods : {};
|
||||||
stamped = snap.stamped && typeof snap.stamped === "object" ? snap.stamped : {};
|
stamped = snap.stamped && typeof snap.stamped === "object" ? snap.stamped : {};
|
||||||
allStamped = !!snap.allStamped;
|
allStamped = !!snap.allStamped;
|
||||||
return skipped;
|
return skipped;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831f" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831g" />{% 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=20260831f" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831g" />{% 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=20260831f" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831g" />{% 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=20260831f" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831g" />{% 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=20260831f" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831g" />{% 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=20260831f" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831g" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -809,6 +809,79 @@ body.cpg-dragging { cursor: grabbing; user-select: none; }
|
|||||||
.cpg-dist-card.is-drop-ready { outline: 2px dashed var(--color-rich-black); outline-offset: 2px; }
|
.cpg-dist-card.is-drop-ready { outline: 2px dashed var(--color-rich-black); outline-offset: 2px; }
|
||||||
.cpg-dist-card.is-drop-ready .cpg-dist-center { border-color: var(--color-midtone-gray); }
|
.cpg-dist-card.is-drop-ready .cpg-dist-center { border-color: var(--color-midtone-gray); }
|
||||||
|
|
||||||
|
/* ③ 헤더의 분배 확정 버튼 */
|
||||||
|
.cpg-dist-head-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.cpg-dist-head-bar > .erp-muted { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.cpg-confirm-btn { flex: 0 0 auto; }
|
||||||
|
.cpg-confirm-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* 센터별 출고방식 */
|
||||||
|
.cpg-dist-method {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: auto; min-width: 0;
|
||||||
|
padding: 2px 22px 2px 8px;
|
||||||
|
font-size: 11px; line-height: 1.6;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.cpg-dist-center.has-items .cpg-dist-method {
|
||||||
|
background-color: rgba(255, 255, 255, .14);
|
||||||
|
border-color: rgba(255, 255, 255, .3);
|
||||||
|
color: var(--color-canvas-white);
|
||||||
|
}
|
||||||
|
.cpg-dist-center.has-items .cpg-dist-method option { color: var(--color-rich-black); }
|
||||||
|
/* 미선택은 눈에 띄게 — 확정 버튼이 막히는 이유 */
|
||||||
|
.cpg-dist-center.has-items .cpg-dist-method.is-unset {
|
||||||
|
background-color: var(--color-callout-red);
|
||||||
|
border-color: var(--color-callout-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 분배 확정 대화상자 — 달력 */
|
||||||
|
.cpg-cfm-box { width: min(460px, calc(100vw - 32px)); }
|
||||||
|
.cpg-cal { border: 1px solid var(--color-subtle-ash); border-radius: 8px; overflow: hidden; }
|
||||||
|
.cpg-cal-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--color-ghost-gray);
|
||||||
|
border-bottom: 1px solid var(--color-subtle-ash);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.cpg-cal-dow, .cpg-cal-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(7, 1fr);
|
||||||
|
}
|
||||||
|
.cpg-cal-dow {
|
||||||
|
padding: 4px 6px 2px;
|
||||||
|
font-size: 11px; color: var(--color-midtone-gray); text-align: center;
|
||||||
|
}
|
||||||
|
.cpg-cal-dow > span:first-child { color: var(--color-callout-red); }
|
||||||
|
.cpg-cal-grid { gap: 2px; padding: 6px; }
|
||||||
|
.cpg-cal-cell {
|
||||||
|
border: 1px solid transparent; background: none; cursor: pointer;
|
||||||
|
font-family: var(--font-geist-mono);
|
||||||
|
font-size: 13px; line-height: 1;
|
||||||
|
padding: 7px 0; border-radius: 6px;
|
||||||
|
color: var(--color-rich-black);
|
||||||
|
}
|
||||||
|
.cpg-cal-cell.is-blank { cursor: default; }
|
||||||
|
.cpg-cal-cell.is-sun { color: var(--color-callout-red); }
|
||||||
|
.cpg-cal-cell.is-sat { color: #1a56c2; }
|
||||||
|
.cpg-cal-cell:not(.is-blank):hover { background: var(--color-ghost-gray); }
|
||||||
|
.cpg-cal-cell.is-today { border-color: var(--color-subtle-ash); font-weight: 700; }
|
||||||
|
.cpg-cal-cell.is-picked {
|
||||||
|
background: var(--color-rich-black); border-color: var(--color-rich-black);
|
||||||
|
color: var(--color-canvas-white); font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cpg-cfm-pick { margin: 0; font-size: 13px; font-weight: 600; }
|
||||||
|
.cpg-cfm-list { border: 1px solid var(--color-subtle-ash); border-radius: 8px; max-height: 140px; overflow-y: auto; }
|
||||||
|
.cpg-cfm-row { display: flex; align-items: center; gap: 8px; padding: 6px 10px; }
|
||||||
|
.cpg-cfm-row + .cpg-cfm-row { border-top: 1px solid var(--color-ghost-gray); }
|
||||||
|
.cpg-cfm-cname { flex: 1 1 auto; min-width: 0; font-size: 13px; font-weight: 600;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.cpg-cfm-cmeta { flex: 0 0 auto; font-family: var(--font-geist-mono); font-size: 11px; color: var(--color-midtone-gray); }
|
||||||
|
.cpg-cfm-note { margin: 0; font-size: 12px; }
|
||||||
|
.cpg-cfm-act { display: flex; align-items: center; gap: 6px; justify-content: flex-end; }
|
||||||
|
.cpg-cfm-act > .erp-muted { flex: 1 1 auto; min-width: 0; font-size: 12px; }
|
||||||
|
|
||||||
/* 임시 저장 / 불러오기 대화상자 */
|
/* 임시 저장 / 불러오기 대화상자 */
|
||||||
.cpg-draft-box { width: min(520px, calc(100vw - 32px)); }
|
.cpg-draft-box { width: min(520px, calc(100vw - 32px)); }
|
||||||
.cpg-draft-saveact { display: flex; align-items: center; gap: 8px; }
|
.cpg-draft-saveact { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user