feat(cupang): 쿠팡 발주 엑셀 업로드 → 센터별 박스 자동 계산
- ① 카드를 "쿠팡 발주 업로드"로 개편, xlsx 다중 선택 업로드 - POST /cupang/api/box-calc/upload (openpyxl): F13 입고예정일 −1일 = 출고일, 22행부터 B=쿠팡상품코드 / F=센터명 / G=수량을 읽어 파일들을 합산 - 쿠팡상품코드 → cupang_products.coupang_item_code 로 제품 매칭, 센터명 → cupang_centers.name 매칭. 미매칭 코드/센터/박스규칙은 경고로 표시 - 박스는 센터 단위로 포장되므로 센터마다 박스 계산을 돌려 ② 요약에 합치고 ③ 센터에 자동 배분(자투리 혼합 박스는 소속 센터를 라벨에 표시) - 확정 대화상자 기본 출고일도 업로드한 발주서 날짜로 채움 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,7 @@ from datetime import date as _date
|
||||
|
||||
from app.timezone import today_kst
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from .holidays import is_holiday
|
||||
@@ -674,8 +674,8 @@ async def box_calc_page(request: Request) -> HTMLResponse:
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"nav_items": build_erp_nav(user, active="cupang"),
|
||||
"page_title": "쿠팡 밀크런 — 박스 계산",
|
||||
"page_subtitle": "제품명과 수량을 넣으면 박스 수와 남은 낱개를 계산합니다.",
|
||||
"page_title": "쿠팡 밀크런 — 쿠팡 발주 업로드",
|
||||
"page_subtitle": "쿠팡 발주 엑셀을 올리면 센터별 수량을 합산해 박스를 계산합니다.",
|
||||
"box_rules": store.list_box_rules(),
|
||||
# 센터 선택 드롭다운은 가나다순 (한글 음절은 코드포인트 순 = 가나다순)
|
||||
"centers": sorted(store.list_centers(), key=lambda c: (c.get("name") or "")),
|
||||
@@ -683,6 +683,200 @@ async def box_calc_page(request: Request) -> HTMLResponse:
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# 쿠팡 발주 엑셀 업로드 — F13 입고예정일 / 22행부터 상품
|
||||
# B열 = 쿠팡상품코드, F열 = 물류센터, G열 = 발주수량
|
||||
# ────────────────────────────────────────────────────────────
|
||||
PO_FIRST_ROW = 22 # 상품이 시작되는 행
|
||||
PO_ARRIVAL_CELL = "F13" # 입고예정일시
|
||||
|
||||
|
||||
def _po_cell_date(value: Any) -> _date | None:
|
||||
"""F13 값(datetime / date / 문자열) → date. 인식 못 하면 None."""
|
||||
from datetime import datetime as _dt # noqa: WPS433
|
||||
|
||||
if isinstance(value, _dt):
|
||||
return value.date()
|
||||
if isinstance(value, _date):
|
||||
return value
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
text = text.split(" ")[0].replace(".", "-").replace("/", "-")
|
||||
try:
|
||||
return _date.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _po_int(value: Any) -> int:
|
||||
try:
|
||||
return int(float(str(value).replace(",", "").strip()))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _parse_po_sheet(ws: Any) -> dict[str, Any]:
|
||||
"""발주서 시트 1장 → {arrival_date, rows:[{coupang_item_code, center_name, quantity}]}."""
|
||||
arrival = _po_cell_date(ws[PO_ARRIVAL_CELL].value)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for r in range(PO_FIRST_ROW, (ws.max_row or PO_FIRST_ROW) + 1):
|
||||
code = str(ws.cell(row=r, column=2).value or "").strip() # B
|
||||
if not code or code in ("합계", "소계"):
|
||||
continue
|
||||
code = code.split(".")[0] if code.replace(".", "").isdigit() else code
|
||||
center = str(ws.cell(row=r, column=6).value or "").strip() # F
|
||||
qty = _po_int(ws.cell(row=r, column=7).value) # G
|
||||
if not center or qty <= 0:
|
||||
continue
|
||||
rows.append({"coupang_item_code": code, "center_name": center, "quantity": qty})
|
||||
return {"arrival_date": arrival, "rows": rows}
|
||||
|
||||
|
||||
@router.post("/api/box-calc/upload")
|
||||
async def box_calc_upload(
|
||||
request: Request,
|
||||
files: list[UploadFile] = File(...),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> JSONResponse:
|
||||
"""쿠팡 발주 엑셀 여러 개 → 센터별 제품 수량 합산.
|
||||
|
||||
- 출고일 = F13(입고예정일)의 하루 전
|
||||
- 쿠팡상품코드(B) → cupang_products.coupang_item_code 로 제품 매칭
|
||||
- 센터명(F) → cupang_centers.name 으로 매칭(못 찾으면 경고만)
|
||||
"""
|
||||
from io import BytesIO # noqa: WPS433
|
||||
from datetime import timedelta as _timedelta # noqa: WPS433
|
||||
|
||||
from openpyxl import load_workbook # noqa: WPS433
|
||||
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
if not files:
|
||||
raise HTTPException(status_code=400, detail="엑셀 파일을 선택하세요.")
|
||||
|
||||
products = store.list_products(include_inactive=True)
|
||||
by_coupang = {
|
||||
(p.get("coupang_item_code") or "").strip(): p
|
||||
for p in products
|
||||
if (p.get("coupang_item_code") or "").strip()
|
||||
}
|
||||
centers = store.list_centers(include_inactive=True)
|
||||
by_center_name = {(c.get("name") or "").strip(): c for c in centers}
|
||||
rules = {r["product_code"]: r for r in store.list_box_rules()}
|
||||
|
||||
warnings: list[str] = []
|
||||
file_infos: list[dict[str, Any]] = []
|
||||
ship_dates: list[str] = []
|
||||
# (센터명, 제품코드) → 수량
|
||||
agg: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
unknown_codes: set[str] = set()
|
||||
unknown_centers: set[str] = set()
|
||||
no_rule: set[str] = set()
|
||||
|
||||
for up in files:
|
||||
raw = await up.read()
|
||||
name = up.filename or "(이름 없음)"
|
||||
try:
|
||||
wb = load_workbook(BytesIO(raw), data_only=True)
|
||||
except Exception: # noqa: BLE001 - 엑셀이 아니거나 손상
|
||||
warnings.append(f"{name}: 엑셀 파일을 읽지 못했습니다.")
|
||||
continue
|
||||
parsed = _parse_po_sheet(wb.worksheets[0])
|
||||
wb.close()
|
||||
|
||||
arrival = parsed["arrival_date"]
|
||||
ship = (arrival - _timedelta(days=1)) if arrival else None
|
||||
if ship:
|
||||
ship_dates.append(ship.isoformat())
|
||||
else:
|
||||
warnings.append(f"{name}: F13 입고예정일을 읽지 못했습니다.")
|
||||
|
||||
used = 0
|
||||
for row in parsed["rows"]:
|
||||
prod = by_coupang.get(row["coupang_item_code"])
|
||||
if not prod:
|
||||
unknown_codes.add(row["coupang_item_code"])
|
||||
continue
|
||||
cname = row["center_name"]
|
||||
if cname not in by_center_name:
|
||||
unknown_centers.add(cname)
|
||||
code = prod["product_code"]
|
||||
if code not in rules:
|
||||
no_rule.add(f'{prod["product_name"]}({code})')
|
||||
key = (cname, code)
|
||||
cell = agg.setdefault(
|
||||
key,
|
||||
{
|
||||
"center_name": cname,
|
||||
"product_code": code,
|
||||
"product_name": prod["product_name"],
|
||||
"coupang_item_code": row["coupang_item_code"],
|
||||
"quantity": 0,
|
||||
},
|
||||
)
|
||||
cell["quantity"] += row["quantity"]
|
||||
used += row["quantity"]
|
||||
|
||||
file_infos.append(
|
||||
{
|
||||
"filename": name,
|
||||
"arrival_date": arrival.isoformat() if arrival else "",
|
||||
"ship_date": ship.isoformat() if ship else "",
|
||||
"rows": len(parsed["rows"]),
|
||||
"quantity": used,
|
||||
}
|
||||
)
|
||||
|
||||
if unknown_codes:
|
||||
warnings.append(
|
||||
"등록되지 않은 쿠팡상품코드 " + str(len(unknown_codes)) + "건 제외: "
|
||||
+ ", ".join(sorted(unknown_codes)[:10])
|
||||
+ (" 외" if len(unknown_codes) > 10 else "")
|
||||
)
|
||||
if unknown_centers:
|
||||
warnings.append("등록되지 않은 센터: " + ", ".join(sorted(unknown_centers)))
|
||||
if no_rule:
|
||||
warnings.append("박스 입수량 미설정: " + ", ".join(sorted(no_rule)))
|
||||
|
||||
uniq_dates = sorted(set(ship_dates))
|
||||
if len(uniq_dates) > 1:
|
||||
warnings.append("파일마다 출고일이 다릅니다: " + ", ".join(uniq_dates))
|
||||
|
||||
grouped: dict[str, dict[str, Any]] = {}
|
||||
for (cname, _code), cell in agg.items():
|
||||
g = grouped.setdefault(
|
||||
cname,
|
||||
{
|
||||
"center_name": cname,
|
||||
"center_id": (by_center_name.get(cname) or {}).get("id"),
|
||||
"items": [],
|
||||
},
|
||||
)
|
||||
g["items"].append(
|
||||
{
|
||||
"product_code": cell["product_code"],
|
||||
"product_name": cell["product_name"],
|
||||
"coupang_item_code": cell["coupang_item_code"],
|
||||
"quantity": cell["quantity"],
|
||||
}
|
||||
)
|
||||
for g in grouped.values():
|
||||
g["items"].sort(key=lambda it: it["product_code"])
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"ship_date": uniq_dates[0] if uniq_dates else "",
|
||||
"ship_dates": uniq_dates,
|
||||
"files": file_infos,
|
||||
"centers": sorted(grouped.values(), key=lambda g: g["center_name"]),
|
||||
"warnings": warnings,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/box-calc")
|
||||
async def box_calc_api(
|
||||
request: Request,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901h" />{% endblock %}
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901i" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="cpg">
|
||||
@@ -27,10 +27,25 @@
|
||||
<!-- ① 박스 계산 — 제품명 + 수량만 입력 -->
|
||||
<div class="erp-card cpg-form-card cpg-calc-card">
|
||||
<div class="cpg-card-head">
|
||||
<h2>① 박스 계산</h2>
|
||||
<span class="erp-muted">수량 칸에서 Tab = 다음 상품 수량, Enter = 계산</span>
|
||||
<h2>① 쿠팡 발주 업로드</h2>
|
||||
<span class="erp-muted">엑셀 여러 개 선택 가능 · F13 입고예정일의 하루 전 = 출고일</span>
|
||||
</div>
|
||||
|
||||
<!-- 쿠팡 발주서(xlsx) 업로드 → 센터별 수량 합산 → ②③ 자동 채움 -->
|
||||
<div class="cpg-po-box">
|
||||
<div class="cpg-po-row">
|
||||
<input class="cpg-po-file" type="file" id="cpg-po-file"
|
||||
accept=".xlsx,.xlsm" multiple aria-label="쿠팡 발주 엑셀 선택" />
|
||||
<button type="button" class="erp-btn erp-btn-primary" id="cpg-po-run">업로드 분석</button>
|
||||
</div>
|
||||
<p class="erp-muted cpg-po-msg" id="cpg-po-msg">
|
||||
B열 쿠팡상품코드 · F열 센터 · G열 수량을 22행부터 읽습니다.
|
||||
</p>
|
||||
<div class="cpg-po-result" id="cpg-po-result" hidden></div>
|
||||
</div>
|
||||
|
||||
<h3 class="cpg-sum-h3">직접 입력 <span class="erp-muted">업로드 결과도 여기서 고칠 수 있습니다</span></h3>
|
||||
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table cpg-calc-table">
|
||||
<colgroup>
|
||||
@@ -324,6 +339,181 @@
|
||||
.then(function () { runBtn.disabled = false; });
|
||||
}
|
||||
|
||||
// ── 쿠팡 발주 엑셀 업로드 ─────────────────────────
|
||||
// 업로드 → 센터별 수량 합산(서버) → 센터마다 박스 계산 → ②③ 자동 채움.
|
||||
// 박스는 센터별로 포장하므로 계산도 센터 단위로 한다(자투리 혼합 박스 포함).
|
||||
var poFile = document.getElementById("cpg-po-file");
|
||||
var poRun = document.getElementById("cpg-po-run");
|
||||
var poMsg = document.getElementById("cpg-po-msg");
|
||||
var poResult = document.getElementById("cpg-po-result");
|
||||
var uploadShipDate = ""; // F13 − 1일 (확정 대화상자 기본값)
|
||||
|
||||
function calcForItems(items) {
|
||||
return fetch("/cupang/api/box-calc", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items: items })
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error("계산 실패 (http " + r.status + ")");
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
// 센터별 계산 결과를 하나의 ② 요약으로 합치고, 각 박스를 그 센터에 자동 배분한다.
|
||||
function applyUploaded(groups, perCenter) {
|
||||
var mergedProds = {}; // code -> 합산 결과
|
||||
var mergedMixes = {}; // box_name -> {box_name, boxes:[]}
|
||||
var autoAlloc = []; // [{cid, key, count}]
|
||||
var mixOwner = {}; // mixKey -> 센터명
|
||||
|
||||
groups.forEach(function (g, gi) {
|
||||
var cid = String(g.center_id);
|
||||
var data = perCenter[gi] || {};
|
||||
(data.results || []).filter(function (r) { return r.configured; }).forEach(function (r) {
|
||||
var m = mergedProds[r.product_code];
|
||||
if (!m) {
|
||||
m = mergedProds[r.product_code] = {
|
||||
product_code: r.product_code, product_name: r.product_name,
|
||||
box_name: r.box_name, units_per_box: r.units_per_box,
|
||||
configured: true, quantity: 0, full_boxes: 0,
|
||||
remainder_units: 0, required_boxes: 0
|
||||
};
|
||||
}
|
||||
m.quantity += r.quantity;
|
||||
m.full_boxes += r.full_boxes;
|
||||
m.remainder_units += r.remainder_units;
|
||||
m.required_boxes += r.required_boxes;
|
||||
if (r.full_boxes > 0) {
|
||||
autoAlloc.push({ cid: cid, key: prodKey(r.product_code), count: r.full_boxes });
|
||||
}
|
||||
});
|
||||
(data.mixes || []).forEach(function (mx) {
|
||||
var bucket = mergedMixes[mx.box_name];
|
||||
if (!bucket) bucket = mergedMixes[mx.box_name] = { box_name: mx.box_name, boxes: [] };
|
||||
mx.boxes.forEach(function (b) {
|
||||
var idx = bucket.boxes.length;
|
||||
bucket.boxes.push(b);
|
||||
var k = mixKey(mx.box_name, idx);
|
||||
mixOwner[k] = g.center_name;
|
||||
autoAlloc.push({ cid: cid, key: k, count: 1 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
calc.results = Object.keys(mergedProds).map(function (k) { return mergedProds[k]; });
|
||||
calc.mixes = Object.keys(mergedMixes).map(function (k) { return mergedMixes[k]; });
|
||||
|
||||
units = {}; labels = {}; contents = {};
|
||||
calc.results.forEach(function (r) {
|
||||
units[prodKey(r.product_code)] = r.units_per_box;
|
||||
labels[prodKey(r.product_code)] = r.product_name;
|
||||
});
|
||||
calc.mixes.forEach(function (m) {
|
||||
m.boxes.forEach(function (b, i) {
|
||||
var n = 0;
|
||||
b.items.forEach(function (it) { n += it.quantity; });
|
||||
var k = mixKey(m.box_name, i);
|
||||
units[k] = n;
|
||||
labels[k] = m.box_name + " 혼합 #" + (i + 1) +
|
||||
(mixOwner[k] ? " · " + mixOwner[k] : "");
|
||||
contents[k] = b.items.map(function (it) {
|
||||
return { product_code: it.product_code, product_name: it.product_name, quantity: it.quantity };
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ③ 센터 열고 자동 배분
|
||||
alloc = {}; stamped = {}; allStamped = false;
|
||||
openCenters = [];
|
||||
groups.forEach(function (g) {
|
||||
var cid = String(g.center_id);
|
||||
if (openCenters.indexOf(cid) < 0) openCenters.push(cid);
|
||||
});
|
||||
autoAlloc.forEach(function (a) { addAlloc(a.cid, a.key, a.count); });
|
||||
|
||||
// ① 표에는 제품별 합계를 채운다(수정 후 재계산 가능).
|
||||
tbody.innerHTML = "";
|
||||
calc.results.slice().sort(function (a, b) {
|
||||
return String(a.product_name).localeCompare(String(b.product_name), "ko");
|
||||
}).forEach(function (r) { addRow(r.product_code, r.quantity); });
|
||||
if (!calc.results.length) addRow();
|
||||
|
||||
render();
|
||||
}
|
||||
|
||||
function renderPoResult(data, skipped) {
|
||||
if (!poResult) return;
|
||||
var html = '<div class="cpg-po-sum">' +
|
||||
'<span class="erp-badge erp-badge-inverse">출고일 ' + esc(data.ship_date || "미확인") + "</span>" +
|
||||
'<span class="erp-badge erp-badge-neutral">파일 ' + ((data.files || []).length) + "개</span>" +
|
||||
'<span class="erp-badge erp-badge-neutral">센터 ' + ((data.centers || []).length) + "곳</span>" +
|
||||
"</div>";
|
||||
html += '<ul class="cpg-po-files">';
|
||||
(data.files || []).forEach(function (f) {
|
||||
html += "<li><span>" + esc(f.filename) + "</span>" +
|
||||
"<em>입고 " + esc(f.arrival_date || "?") + " → 출고 " + esc(f.ship_date || "?") + "</em>" +
|
||||
"<b>" + f.quantity + "개</b></li>";
|
||||
});
|
||||
html += "</ul>";
|
||||
var warns = (data.warnings || []).slice();
|
||||
if (skipped && skipped.length) {
|
||||
warns.push("등록되지 않은 센터라 자동 배분에서 제외: " + skipped.join(", "));
|
||||
}
|
||||
if (warns.length) {
|
||||
html += '<ul class="cpg-po-warn">' + warns.map(function (w) {
|
||||
return "<li>" + esc(w) + "</li>";
|
||||
}).join("") + "</ul>";
|
||||
}
|
||||
poResult.innerHTML = html;
|
||||
poResult.hidden = false;
|
||||
}
|
||||
|
||||
function runUpload() {
|
||||
if (!poFile || !poFile.files || !poFile.files.length) {
|
||||
poMsg.textContent = "엑셀 파일을 선택하세요."; return;
|
||||
}
|
||||
var fd = new FormData();
|
||||
Array.prototype.forEach.call(poFile.files, function (f) { fd.append("files", f); });
|
||||
poRun.disabled = true;
|
||||
poMsg.textContent = "분석 중…";
|
||||
fetch("/cupang/api/box-calc/upload", { method: "POST", body: fd })
|
||||
.then(function (r) {
|
||||
return r.json().catch(function () { return {}; }).then(function (d) {
|
||||
if (!r.ok) throw new Error(d.detail || "업로드 실패 (http " + r.status + ")");
|
||||
return d;
|
||||
});
|
||||
})
|
||||
.then(function (data) {
|
||||
uploadShipDate = data.ship_date || "";
|
||||
var groups = (data.centers || []).filter(function (g) { return g.center_id != null; });
|
||||
var skipped = (data.centers || []).filter(function (g) { return g.center_id == null; })
|
||||
.map(function (g) { return g.center_name; });
|
||||
if (!groups.length) {
|
||||
renderPoResult(data, skipped);
|
||||
poMsg.textContent = "배분할 수 있는 센터가 없습니다.";
|
||||
return;
|
||||
}
|
||||
return Promise.all(groups.map(function (g) {
|
||||
return calcForItems(g.items.map(function (it) {
|
||||
return { product_code: it.product_code, quantity: it.quantity };
|
||||
}));
|
||||
})).then(function (perCenter) {
|
||||
applyUploaded(groups, perCenter);
|
||||
renderPoResult(data, skipped);
|
||||
var pieces = 0;
|
||||
groups.forEach(function (g) {
|
||||
g.items.forEach(function (it) { pieces += it.quantity; });
|
||||
});
|
||||
poMsg.textContent = "분석 완료 — 센터 " + groups.length + "곳 · " + pieces + "개 자동 배분";
|
||||
msg.textContent = "업로드 결과로 계산했습니다. 센터마다 출고방식을 고르면 확정할 수 있습니다.";
|
||||
});
|
||||
})
|
||||
.catch(function (err) { poMsg.textContent = (err && err.message) || "업로드 실패"; })
|
||||
.then(function () { poRun.disabled = false; });
|
||||
}
|
||||
|
||||
if (poRun) poRun.addEventListener("click", runUpload);
|
||||
|
||||
// ── 배분 집계 ─────────────────────────────────────
|
||||
function allocatedFor(key) {
|
||||
var n = 0;
|
||||
@@ -928,8 +1118,10 @@
|
||||
if (why) { msg.textContent = why; return; }
|
||||
var t = new Date();
|
||||
picked = iso(t.getFullYear(), t.getMonth(), t.getDate());
|
||||
calY = t.getFullYear();
|
||||
calM = t.getMonth();
|
||||
if (uploadShipDate) picked = uploadShipDate; // 업로드한 발주서의 출고일(F13 − 1일)
|
||||
var pp = picked.split("-");
|
||||
calY = parseInt(pp[0], 10);
|
||||
calM = parseInt(pp[1], 10) - 1;
|
||||
cfmMsg.textContent = "";
|
||||
cfmList.innerHTML = buildPlans().map(function (pl) {
|
||||
var pieces = pl.items.reduce(function (n, it) { return n + it.quantity; }, 0);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901h" />{% endblock %}
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901i" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="cpg">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901h" />{% endblock %}
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901i" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="cpg">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901h" />{% endblock %}
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901i" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="cpg">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901h" />{% endblock %}
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901i" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="cpg">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901h" />{% endblock %}
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901i" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="cpg">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901h" />{% endblock %}
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901i" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="cpg">
|
||||
|
||||
@@ -1265,3 +1265,39 @@ body.cpg-dragging { cursor: grabbing; user-select: none; }
|
||||
.cpg-lg-dot { background: var(--color-deep-black); }
|
||||
.cpg-lg-today { background: var(--color-deep-black); box-shadow: 0 0 0 2px #fff, 0 0 0 3px var(--color-subtle-ash); }
|
||||
.cpg-lg-sel { background: var(--color-ghost-gray); box-shadow: inset 0 0 0 1.5px var(--color-deep-black); }
|
||||
|
||||
/* ── 쿠팡 발주 업로드 (① 카드) ── */
|
||||
.cpg-po-box {
|
||||
border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--color-ghost-gray);
|
||||
}
|
||||
.cpg-po-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.cpg-po-file {
|
||||
flex: 1 1 220px; min-width: 0;
|
||||
font-size: 12px;
|
||||
padding: 6px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.cpg-po-msg { margin: 8px 0 0; font-size: 12px; }
|
||||
.cpg-po-result { margin-top: 10px; }
|
||||
.cpg-po-sum { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.cpg-po-files, .cpg-po-warn { list-style: none; margin: 0; padding: 0; font-size: 12px; }
|
||||
.cpg-po-files > li {
|
||||
display: flex; align-items: baseline; gap: 8px;
|
||||
padding: 4px 0; border-top: 1px solid var(--color-subtle-ash);
|
||||
}
|
||||
.cpg-po-files > li > span { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cpg-po-files > li > em { font-style: normal; color: var(--color-midtone-gray); white-space: nowrap; }
|
||||
.cpg-po-files > li > b { font-family: var(--font-geist-mono); white-space: nowrap; }
|
||||
.cpg-po-warn {
|
||||
margin-top: 8px; padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--color-callout-red) 10%, #fff);
|
||||
color: var(--color-callout-red);
|
||||
}
|
||||
.cpg-po-warn > li { padding: 2px 0; }
|
||||
|
||||
Reference in New Issue
Block a user