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:
2026-09-01 13:23:33 +09:00
parent eef18f7eb9
commit a6d7de709d
10 changed files with 437 additions and 15 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아
- 반품관리
- 외부 쇼핑몰 API 연동 (카페24, 네이버 스마트스토어, 사방넷 등)
- 개인경비 (`app/modules/expense/`, `expense_db`)
- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용. 출고 묶음은 **박스 계산 화면의 [분배 확정] 으로만** 만든다(신규 등록 폼 없음, `/cupang/new` 는 박스 계산으로 리다이렉트). 확정 조건: 미배분 박스 0 + 담긴 센터마다 출고방식(택배/파렛트) 선택. 확정 시 센터마다 출고 묶음 1건(`status=출고준비`, 센터입고일=출고일). 작업 중 상태는 `cupang_box_calc_drafts` 에 이름 붙여 임시 저장/불러오기
- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용. 출고 묶음은 **박스 계산 화면의 [분배 확정] 으로만** 만든다(신규 등록 폼 없음, `/cupang/new` 는 박스 계산으로 리다이렉트). 확정 조건: 미배분 박스 0 + 담긴 센터마다 출고방식(택배/파렛트) 선택. 확정 시 센터마다 출고 묶음 1건(`status=출고준비`, 센터입고일=출고일). 작업 중 상태는 `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/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`
+197 -3
View File
@@ -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">
+36
View File
@@ -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; }