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:
@@ -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=출고준비`, 센터입고일=출고일). 작업 중 상태는 `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/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`
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ 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, File, Form, HTTPException, Request, UploadFile
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
|
||||||
from .holidays import is_holiday
|
from .holidays import is_holiday
|
||||||
@@ -674,8 +674,8 @@ async def box_calc_page(request: Request) -> HTMLResponse:
|
|||||||
"user": user,
|
"user": user,
|
||||||
"is_admin": is_admin(user),
|
"is_admin": is_admin(user),
|
||||||
"nav_items": build_erp_nav(user, active="cupang"),
|
"nav_items": build_erp_nav(user, active="cupang"),
|
||||||
"page_title": "쿠팡 밀크런 — 박스 계산",
|
"page_title": "쿠팡 밀크런 — 쿠팡 발주 업로드",
|
||||||
"page_subtitle": "제품명과 수량을 넣으면 박스 수와 남은 낱개를 계산합니다.",
|
"page_subtitle": "쿠팡 발주 엑셀을 올리면 센터별 수량을 합산해 박스를 계산합니다.",
|
||||||
"box_rules": store.list_box_rules(),
|
"box_rules": store.list_box_rules(),
|
||||||
# 센터 선택 드롭다운은 가나다순 (한글 음절은 코드포인트 순 = 가나다순)
|
# 센터 선택 드롭다운은 가나다순 (한글 음절은 코드포인트 순 = 가나다순)
|
||||||
"centers": sorted(store.list_centers(), key=lambda c: (c.get("name") or "")),
|
"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")
|
@router.post("/api/box-calc")
|
||||||
async def box_calc_api(
|
async def box_calc_api(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% 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 %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
@@ -27,10 +27,25 @@
|
|||||||
<!-- ① 박스 계산 — 제품명 + 수량만 입력 -->
|
<!-- ① 박스 계산 — 제품명 + 수량만 입력 -->
|
||||||
<div class="erp-card cpg-form-card cpg-calc-card">
|
<div class="erp-card cpg-form-card cpg-calc-card">
|
||||||
<div class="cpg-card-head">
|
<div class="cpg-card-head">
|
||||||
<h2>① 박스 계산</h2>
|
<h2>① 쿠팡 발주 업로드</h2>
|
||||||
<span class="erp-muted">수량 칸에서 Tab = 다음 상품 수량, Enter = 계산</span>
|
<span class="erp-muted">엑셀 여러 개 선택 가능 · F13 입고예정일의 하루 전 = 출고일</span>
|
||||||
</div>
|
</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">
|
<div class="erp-table-wrap">
|
||||||
<table class="erp-table cpg-calc-table">
|
<table class="erp-table cpg-calc-table">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
@@ -324,6 +339,181 @@
|
|||||||
.then(function () { runBtn.disabled = false; });
|
.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) {
|
function allocatedFor(key) {
|
||||||
var n = 0;
|
var n = 0;
|
||||||
@@ -928,8 +1118,10 @@
|
|||||||
if (why) { msg.textContent = why; return; }
|
if (why) { msg.textContent = why; return; }
|
||||||
var t = new Date();
|
var t = new Date();
|
||||||
picked = iso(t.getFullYear(), t.getMonth(), t.getDate());
|
picked = iso(t.getFullYear(), t.getMonth(), t.getDate());
|
||||||
calY = t.getFullYear();
|
if (uploadShipDate) picked = uploadShipDate; // 업로드한 발주서의 출고일(F13 − 1일)
|
||||||
calM = t.getMonth();
|
var pp = picked.split("-");
|
||||||
|
calY = parseInt(pp[0], 10);
|
||||||
|
calM = parseInt(pp[1], 10) - 1;
|
||||||
cfmMsg.textContent = "";
|
cfmMsg.textContent = "";
|
||||||
cfmList.innerHTML = buildPlans().map(function (pl) {
|
cfmList.innerHTML = buildPlans().map(function (pl) {
|
||||||
var pieces = pl.items.reduce(function (n, it) { return n + it.quantity; }, 0);
|
var pieces = pl.items.reduce(function (n, it) { return n + it.quantity; }, 0);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% 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 %}
|
{% 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=20260901h" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901i" />{% 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=20260901h" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901i" />{% 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=20260901h" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901i" />{% 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=20260901h" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901i" />{% 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=20260901h" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901i" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<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-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-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-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