feat(cupang): 제품명 카탈로그 설정 + 달력 공휴일 색상 + 폼 UI 개선

- 설정 화면 추가: /cupang/products — itemcode_db 검색 등록 + 수동 등록.
  제품명 드롭다운 소스. cupang_products 테이블 신규(init.sql 멱등 재실행 필요)
- 폼 품목라인: 제품명 드롭다운 선택 → 제품코드 자동 입력
- 라인 컬럼 순서 제품명·제품코드·수량·입수량·박스계산·메모
- 제품코드 140px, 수량·입수량 60px 폭 고정
- 공통 헤더 입력란 겹침 수정(min-width:0, width:100%, box-sizing)
- 달력: 일/공휴일 빨강, 토 파랑, 글자 크기 확대. 한국 공휴일 모듈(holidays.py)
- document_no null → "None" 출력 버그 수정

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 03:24:05 +09:00
parent 9e707be5ac
commit 34c7e2f964
9 changed files with 457 additions and 106 deletions
+67
View File
@@ -201,6 +201,59 @@ class CupangDBStore:
"""product_code → rule. 폼/계산에서 빠르게 조회하기 위한 dict.""" """product_code → rule. 폼/계산에서 빠르게 조회하기 위한 dict."""
return {r["product_code"]: r for r in self.list_box_rules()} return {r["product_code"]: r for r in self.list_box_rules()}
# ════════════════════════════════════════════════════════════
# 제품명 카탈로그 (cupang_products)
# itemcode_db 에서 가져와 등록한 제품 목록. 폼의 제품명 드롭다운 소스.
# 제품명 선택 → product_code 자동 채움.
# ════════════════════════════════════════════════════════════
def list_products(self, *, include_inactive: bool = False) -> list[dict[str, Any]]:
where = "" if include_inactive else "WHERE active = TRUE"
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM cupang_products {where} "
"ORDER BY active DESC, sort_order ASC, product_name ASC"
).fetchall()
return [self._product_serialize(r) for r in rows]
def upsert_product(
self, *, product_code: str, product_name: str, sort_order: int = 0
) -> dict[str, Any]:
code = (product_code or "").strip()
name = (product_name or "").strip()
if not code or not name:
raise ValueError("제품코드와 제품명 모두 필요합니다.")
with self._pool.connection() as conn:
row = conn.execute(
"""
INSERT INTO cupang_products (product_code, product_name, sort_order)
VALUES (%s, %s, %s)
ON CONFLICT (product_code) DO UPDATE
SET product_name = EXCLUDED.product_name,
sort_order = EXCLUDED.sort_order,
active = TRUE
RETURNING *
""",
(code, name, sort_order),
).fetchone()
return self._product_serialize(row)
def deactivate_product(self, *, product_id: int) -> None:
with self._pool.connection() as conn:
cur = conn.execute(
"UPDATE cupang_products SET active = FALSE WHERE id = %s",
(product_id,),
)
if cur.rowcount == 0:
raise KeyError(product_id)
def delete_product(self, *, product_id: int) -> None:
with self._pool.connection() as conn:
cur = conn.execute(
"DELETE FROM cupang_products WHERE id = %s", (product_id,)
)
if cur.rowcount == 0:
raise KeyError(product_id)
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# 출고 묶음 (cupang_shipments + cupang_shipment_lines) # 출고 묶음 (cupang_shipments + cupang_shipment_lines)
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@@ -549,6 +602,20 @@ class CupangDBStore:
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds") out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds")
return out return out
@staticmethod
def _product_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
out = dict(row)
out["id"] = int(out["id"])
out["active"] = bool(out.get("active", True))
out["sort_order"] = int(out.get("sort_order", 0))
for k in ("created_at", "updated_at"):
v = out.get(k)
if isinstance(v, datetime):
out[k] = v.astimezone(timezone.utc).isoformat(timespec="seconds")
return out
@staticmethod @staticmethod
def _shipment_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None: def _shipment_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None: if row is None:
+62
View File
@@ -0,0 +1,62 @@
"""대한민국 공휴일 판정 (달력 색상용).
- 고정 양력 공휴일은 매년 동일 → 연도 무관 판정.
- 음력 공휴일(설날/부처님오신날/추석)과 대체공휴일은 매년 달라짐 →
연도별 dict(`_LUNAR_AND_SUBSTITUTE`)에 명시. 새 연도는 KASI 발표값을 추가한다.
미수록 연도는 고정 양력 공휴일만 빨강 처리된다(음력/대체는 누락).
"""
from __future__ import annotations
from datetime import date
# 매년 동일한 양력 공휴일 (month, day)
_FIXED_SOLAR: set[tuple[int, int]] = {
(1, 1), # 신정
(3, 1), # 삼일절
(5, 5), # 어린이날
(6, 6), # 현충일
(8, 15), # 광복절
(10, 3), # 개천절
(10, 9), # 한글날
(12, 25), # 성탄절
}
# 연도별 음력 공휴일 + 대체공휴일 (ISO 날짜 문자열). KASI 발표 기준.
_LUNAR_AND_SUBSTITUTE: dict[int, set[str]] = {
2025: {
"2025-01-28", "2025-01-29", "2025-01-30", # 설날 연휴
"2025-03-03", # 삼일절 대체(3/1 토)
"2025-05-06", # 부처님오신날 대체(5/5 겹침)
"2025-05-05", # 부처님오신날(어린이날과 동일일)
"2025-10-06", "2025-10-07", "2025-10-08", # 추석 연휴
"2025-10-08", # 추석 대체 가능
},
2026: {
"2026-02-16", "2026-02-17", "2026-02-18", # 설날 연휴 (설날 2/17)
"2026-03-02", # 삼일절 대체 (3/1 일)
"2026-05-24", # 부처님오신날 (일)
"2026-05-25", # 부처님오신날 대체
"2026-08-17", # 광복절 대체 (8/15 토)
"2026-09-24", "2026-09-25", "2026-09-26", # 추석 연휴 (추석 9/25)
"2026-09-28", # 추석 대체 (9/26 토)
"2026-10-05", # 개천절 대체 (10/3 토)
},
2027: {
"2027-02-06", "2027-02-07", "2027-02-08", # 설날 연휴 (설날 2/7)
"2027-02-09", # 설날 대체 (2/7 일)
"2027-05-13", # 부처님오신날 (목)
"2027-08-16", # 광복절 대체 (8/15 일)
"2027-09-14", "2027-09-15", "2027-09-16", # 추석 연휴 (추석 9/15)
"2027-10-04", # 개천절 대체 (10/3 일)
"2027-10-11", # 한글날 대체 (10/9 토)
},
}
def is_holiday(d: date) -> bool:
"""공휴일(일요일 제외)이면 True. 일/토 색상은 요일로 따로 판정한다."""
if (d.month, d.day) in _FIXED_SOLAR:
return True
return d.isoformat() in _LUNAR_AND_SUBSTITUTE.get(d.year, set())
+70
View File
@@ -23,6 +23,7 @@ from fastapi.responses import (
StreamingResponse, StreamingResponse,
) )
from .holidays import is_holiday
from .store import DEFAULT_CENTERS, SHEET_COLUMNS, SHIP_METHODS, STATUSES, compute_boxes from .store import DEFAULT_CENTERS, SHEET_COLUMNS, SHIP_METHODS, STATUSES, compute_boxes
router = APIRouter(prefix="/cupang", tags=["cupang"]) router = APIRouter(prefix="/cupang", tags=["cupang"])
@@ -153,6 +154,9 @@ async def index(request: Request) -> HTMLResponse:
"in_month": d.month == month, "in_month": d.month == month,
"is_today": d == today, "is_today": d == today,
"is_selected": d.isoformat() == sel, "is_selected": d.isoformat() == sel,
"is_sunday": d.weekday() == 6,
"is_saturday": d.weekday() == 5,
"is_holiday": is_holiday(d),
"counts": counts.get(d.isoformat(), {}), "counts": counts.get(d.isoformat(), {}),
} }
for d in week for d in week
@@ -212,6 +216,7 @@ def _form_context(request: Request, store: Any, user: dict[str, Any]) -> dict[st
"nav_items": build_erp_nav(user, active="cupang"), "nav_items": build_erp_nav(user, active="cupang"),
"centers": store.list_centers(), "centers": store.list_centers(),
"box_rules": store.list_box_rules(), "box_rules": store.list_box_rules(),
"products": store.list_products(),
"ship_methods": list(SHIP_METHODS), "ship_methods": list(SHIP_METHODS),
"statuses": list(STATUSES), "statuses": list(STATUSES),
"search_enabled": bool(reader and reader.enabled), "search_enabled": bool(reader and reader.enabled),
@@ -583,6 +588,71 @@ async def box_rule_delete(
return RedirectResponse(url="/cupang/box-rules", status_code=303) return RedirectResponse(url="/cupang/box-rules", status_code=303)
# ════════════════════════════════════════════════════════════
# 설정 — 제품명 카탈로그 관리 (itemcode_db 에서 등록)
# ════════════════════════════════════════════════════════════
@router.get("/products", response_class=HTMLResponse)
async def products_page(request: Request) -> HTMLResponse:
from app.main import build_erp_nav, render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
store, user = guard
reader = _itemcode(request)
return render_template(
request,
"cupang/products.html",
{
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"),
"page_title": "쿠팡 밀크런 — 설정 (제품명)",
"page_subtitle": "제품명 리스트 관리. itemcode_db 에서 검색해 등록하면 폼 드롭다운에 노출됩니다.",
"products": store.list_products(include_inactive=True),
"search_enabled": bool(reader and reader.enabled),
"search_reason": (reader.reason if reader else ""),
},
)
@router.post("/products")
async def product_upsert(
request: Request,
product_code: str = Form(...),
product_name: str = Form(...),
sort_order: int = Form(0),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
store.upsert_product(
product_code=product_code, product_name=product_name, sort_order=sort_order
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url="/cupang/products", status_code=303)
@router.post("/products/{product_id:int}/delete")
async def product_delete(
request: Request,
product_id: int,
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
store.deactivate_product(product_id=product_id)
except KeyError:
raise HTTPException(status_code=404, detail="제품을 찾을 수 없습니다.")
return RedirectResponse(url="/cupang/products", status_code=303)
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# 상품 검색 (itemcode_db 읽기 전용) + 박스 계산 미리보기 # 상품 검색 (itemcode_db 읽기 전용) + 박스 계산 미리보기
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@@ -55,7 +55,7 @@
<label class="erp-field"><span>문서번호(선택)</span> <label class="erp-field"><span>문서번호(선택)</span>
<input class="erp-input" type="text" name="document_no" <input class="erp-input" type="text" name="document_no"
value="{{ shipment.document_no if shipment else '' }}" /></label> value="{{ shipment.document_no or '' if shipment else '' }}" /></label>
</div> </div>
<label class="erp-field cpg-full"><span>출고/박스 요약 (수동 보정 메모)</span> <label class="erp-field cpg-full"><span>출고/박스 요약 (수동 보정 메모)</span>
@@ -71,8 +71,8 @@
<div class="cpg-card-head"> <div class="cpg-card-head">
<h2>품목 라인</h2> <h2>품목 라인</h2>
<span class="erp-muted"> <span class="erp-muted">
{% if search_enabled %}제품코드 검색 → 제품명 자동 채움.{% else %}상품 검색 비활성(수동 입력).{% endif %} 제품명 선택 시 제품코드 자동 입력. 수량 입력 시 박스 수 자동 계산.
수량 입력 시 박스 수 자동 계산. {% if not products %}<a href="/cupang/products">설정에서 제품명 먼저 등록</a>{% endif %}
</span> </span>
</div> </div>
@@ -80,7 +80,7 @@
<table class="erp-table cpg-lines"> <table class="erp-table cpg-lines">
<thead> <thead>
<tr> <tr>
<th>#</th><th>제품코드</th><th>제품</th><th>수량</th> <th>#</th><th>제품</th><th>제품코드</th><th>수량</th>
<th>입수량</th><th>박스 계산</th><th>라인메모</th><th></th> <th>입수량</th><th>박스 계산</th><th>라인메모</th><th></th>
</tr> </tr>
</thead> </thead>
@@ -110,6 +110,9 @@
<script type="application/json" id="cpg-box-rules"> <script type="application/json" id="cpg-box-rules">
{{ box_rules | tojson }} {{ box_rules | tojson }}
</script> </script>
<script type="application/json" id="cpg-products">
{{ products | tojson }}
</script>
<script> <script>
window.CPG = { window.CPG = {
searchEnabled: {{ 'true' if search_enabled else 'false' }}, searchEnabled: {{ 'true' if search_enabled else 'false' }},
@@ -8,6 +8,7 @@
<!-- 페이지 액션 --> <!-- 페이지 액션 -->
<div class="erp-page-actions cpg-actions"> <div class="erp-page-actions cpg-actions">
<a class="erp-btn erp-btn-primary" href="/cupang/new">+ 신규 등록</a> <a class="erp-btn erp-btn-primary" href="/cupang/new">+ 신규 등록</a>
<a class="erp-btn erp-btn-outline" href="/cupang/products">설정(제품명)</a>
<a class="erp-btn erp-btn-outline" href="/cupang/centers">입고센터 관리</a> <a class="erp-btn erp-btn-outline" href="/cupang/centers">입고센터 관리</a>
<a class="erp-btn erp-btn-outline" href="/cupang/box-rules">박스 입수량</a> <a class="erp-btn erp-btn-outline" href="/cupang/box-rules">박스 입수량</a>
<a class="erp-btn erp-btn-outline" href="/cupang/export?year={{ year }}&month={{ month }}">엑셀({{ month }}월)</a> <a class="erp-btn erp-btn-outline" href="/cupang/export?year={{ year }}&month={{ month }}">엑셀({{ month }}월)</a>
@@ -34,7 +35,8 @@
<a class="cpg-cal-cell <a class="cpg-cal-cell
{% if not cell.in_month %}cpg-out{% endif %} {% if not cell.in_month %}cpg-out{% endif %}
{% if cell.is_today %}cpg-today{% endif %} {% if cell.is_today %}cpg-today{% endif %}
{% if cell.is_selected %}cpg-selected{% endif %}" {% if cell.is_selected %}cpg-selected{% endif %}
{% if cell.is_sunday or cell.is_holiday %}cpg-red{% elif cell.is_saturday %}cpg-blue{% endif %}"
href="/cupang/?year={{ year }}&month={{ month }}&date={{ cell.date }}"> href="/cupang/?year={{ year }}&month={{ month }}&date={{ cell.date }}">
<span class="cpg-cal-day">{{ cell.day }}</span> <span class="cpg-cal-day">{{ cell.day }}</span>
<span class="cpg-cal-badges"> <span class="cpg-cal-badges">
@@ -0,0 +1,111 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css" />{% endblock %}
{% block content %}
<section class="cpg">
<div class="erp-page-actions">
<a class="erp-btn erp-btn-outline" href="/cupang/">← 달력</a>
</div>
<!-- itemcode_db 검색 → 등록 -->
<div class="erp-card cpg-form-card">
<div class="cpg-card-head">
<h2>itemcode_db 에서 제품 검색·등록</h2>
<span class="erp-muted">
{% if search_enabled %}제품코드/제품명으로 검색 후 "등록".{% else %}
상품 검색 비활성: {{ search_reason }} — 아래에서 수동 등록하세요.{% endif %}
</span>
</div>
{% if search_enabled %}
<div class="cpg-inline-form">
<input class="erp-input" type="text" id="cpg-prod-q" placeholder="제품코드 또는 제품명" style="min-width:240px" />
<button type="button" class="erp-btn erp-btn-outline" id="cpg-prod-search">검색</button>
</div>
<div id="cpg-prod-results" class="cpg-prod-results"></div>
{% endif %}
</div>
<!-- 수동 등록 -->
<div class="erp-card cpg-form-card">
<div class="cpg-card-head"><h2>제품명 수동 등록 / 수정</h2>
<span class="erp-muted">같은 제품코드는 덮어씁니다.</span></div>
<form method="post" action="/cupang/products" class="cpg-rule-grid">
<label class="erp-field"><span>제품코드 *</span>
<input class="erp-input" type="text" name="product_code" required placeholder="예: MS-1001" /></label>
<label class="erp-field"><span>제품명 *</span>
<input class="erp-input" type="text" name="product_name" required placeholder="예: 미라네 1호세트" /></label>
<label class="erp-field"><span>정렬</span>
<input class="erp-input" type="number" name="sort_order" value="0" /></label>
<div><button type="submit" class="erp-btn erp-btn-primary">등록</button></div>
</form>
</div>
<!-- 등록된 제품명 목록 -->
<div class="erp-card cpg-form-card">
<div class="cpg-card-head"><h2>등록된 제품명 ({{ products|length }})</h2>
<span class="erp-muted">폼의 제품명 드롭다운에 노출됩니다.</span></div>
<div class="erp-table-wrap">
<table class="erp-table">
<thead><tr><th>제품명</th><th>제품코드</th><th>정렬</th><th>상태</th><th></th></tr></thead>
<tbody>
{% for p in products %}
<tr {% if not p.active %}style="opacity:.55"{% endif %}>
<td>{{ p.product_name }}</td>
<td>{{ p.product_code }}</td>
<td>{{ p.sort_order }}</td>
<td>{% if p.active %}<span class="erp-badge erp-badge-success">활성</span>{% else %}<span class="erp-badge erp-badge-neutral">비활성</span>{% endif %}</td>
<td>
{% if p.active %}
<form method="post" action="/cupang/products/{{ p.id }}/delete" class="cpg-inline-form"
onsubmit="return confirm('비활성화합니다. 계속할까요?');">
<button type="submit" class="erp-btn erp-btn-danger">비활성화</button>
</form>
{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% if search_enabled %}
<script>
(function () {
var q = document.getElementById("cpg-prod-q");
var btn = document.getElementById("cpg-prod-search");
var box = document.getElementById("cpg-prod-results");
function esc(s){ var d=document.createElement("div"); d.textContent=s||""; return d.innerHTML; }
function run() {
var term = (q.value || "").trim();
if (!term) { box.innerHTML = ""; return; }
box.innerHTML = '<p class="erp-muted">검색중…</p>';
fetch("/cupang/api/products/search?q=" + encodeURIComponent(term))
.then(function (r) { return r.json(); })
.then(function (data) {
var rows = (data && data.results) || [];
if (!rows.length) { box.innerHTML = '<p class="erp-muted">결과 없음</p>'; return; }
var html = '<table class="erp-table"><thead><tr><th>제품코드</th><th>제품명</th><th>구분</th><th></th></tr></thead><tbody>';
rows.forEach(function (it) {
html += '<tr><td>' + esc(it.code) + '</td><td>' + esc(it.name) + '</td><td>' + esc(it.type) + '</td>' +
'<td><form method="post" action="/cupang/products" class="cpg-inline-form">' +
'<input type="hidden" name="product_code" value="' + esc(it.code) + '">' +
'<input type="hidden" name="product_name" value="' + esc(it.name) + '">' +
'<button type="submit" class="erp-btn erp-btn-outline">등록</button></form></td></tr>';
});
html += "</tbody></table>";
box.innerHTML = html;
})
.catch(function () { box.innerHTML = '<p class="erp-muted">검색 실패</p>'; });
}
btn.addEventListener("click", run);
q.addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); run(); } });
})();
</script>
{% endif %}
{% endblock %}
+33 -12
View File
@@ -27,27 +27,33 @@
gap: 6px; gap: 6px;
} }
.cpg-cal-wd { .cpg-cal-wd {
text-align: center; font-size: 12px; font-weight: 600; text-align: center; font-size: 15px; font-weight: 600;
color: var(--color-midtone-gray); padding: 4px 0; color: var(--color-midtone-gray); padding: 6px 0;
} }
.cpg-sun { color: var(--color-callout-red); } .cpg-sun { color: var(--color-callout-red); }
.cpg-sat { color: #2b5bc2; } .cpg-sat { color: #2b5bc2; }
.cpg-cal-cell { .cpg-cal-cell {
display: flex; flex-direction: column; gap: 4px; display: flex; flex-direction: column; gap: 4px;
min-height: 84px; padding: 6px; min-height: 96px; padding: 8px;
border: 1px solid var(--color-subtle-ash); border: 1px solid var(--color-subtle-ash);
border-radius: 10px; border-radius: 10px;
background: #fff; background: #fff;
transition: border-color .12s, box-shadow .12s; transition: border-color .12s, box-shadow .12s;
} }
.cpg-cal-cell:hover { border-color: var(--color-midtone-gray); } .cpg-cal-cell:hover { border-color: var(--color-midtone-gray); }
.cpg-out { background: var(--color-ghost-gray); color: var(--color-midtone-gray); } .cpg-out { background: var(--color-ghost-gray); }
.cpg-out .cpg-cal-day { color: var(--color-midtone-gray); }
.cpg-today { border-color: var(--color-deep-black); } .cpg-today { border-color: var(--color-deep-black); }
.cpg-selected { box-shadow: 0 0 0 2px var(--color-deep-black); border-color: var(--color-deep-black); } .cpg-selected { box-shadow: 0 0 0 2px var(--color-deep-black); border-color: var(--color-deep-black); }
.cpg-cal-day { font-size: 13px; font-weight: 600; } .cpg-cal-day { font-size: 17px; font-weight: 600; }
/* 일요일/공휴일 빨강, 토요일 파랑 (당월 셀 우선, 전후월은 옅게) */
.cpg-red .cpg-cal-day { color: var(--color-callout-red); }
.cpg-blue .cpg-cal-day { color: #2b5bc2; }
.cpg-out.cpg-red .cpg-cal-day { color: #e3a59b; }
.cpg-out.cpg-blue .cpg-cal-day { color: #9db4dd; }
.cpg-cal-badges { display: flex; flex-direction: column; gap: 3px; } .cpg-cal-badges { display: flex; flex-direction: column; gap: 3px; }
.cpg-mini { font-size: 11px; padding: 1px 6px; align-self: flex-start; } .cpg-mini { font-size: 12px; padding: 2px 7px; align-self: flex-start; }
/* ── 오른쪽 리스트 ── */ /* ── 오른쪽 리스트 ── */
.cpg-list-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; } .cpg-list-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; }
@@ -68,24 +74,39 @@
.cpg-header-grid { .cpg-header-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px; gap: 12px 16px;
} }
.cpg-rule-grid { .cpg-rule-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px; align-items: end; gap: 12px 16px; align-items: end;
} }
.erp-field { display: flex; flex-direction: column; gap: 4px; } /* 그리드 항목 겹침 방지: 칸이 줄어들 때 내용이 밖으로 넘치지 않게 */
.erp-field > span { font-size: 12px; color: var(--color-midtone-gray); } .cpg-header-grid .erp-field,
.cpg-rule-grid .erp-field { min-width: 0; }
.cpg .erp-field { display: flex; flex-direction: column; gap: 4px; margin: 0; }
.cpg .erp-field > span { font-size: 12px; color: var(--color-midtone-gray); }
/* 입력란이 칸 너비를 넘지 않도록 (date/select 포함) */
.cpg .erp-field > .erp-input,
.cpg .erp-field > .erp-select,
.cpg .erp-field > textarea.erp-input { width: 100%; max-width: 100%; box-sizing: border-box; }
.cpg-full { grid-column: 1 / -1; } .cpg-full { grid-column: 1 / -1; }
/* 검정 배경 버튼 글자는 항상 흰색 (안전 보강) */
.cpg .erp-btn-primary, .cpg .erp-btn-primary:visited { color: var(--color-canvas-white); }
.cpg-inline-form { display: inline-flex; gap: 6px; align-items: center; margin: 0; } .cpg-inline-form { display: inline-flex; gap: 6px; align-items: center; margin: 0; }
.cpg-row-actions { display: flex; gap: 6px; flex-wrap: wrap; } .cpg-row-actions { display: flex; gap: 6px; flex-wrap: wrap; }
/* ── 라인 테이블 ── */ /* ── 라인 테이블 ── */
.cpg-lines td { vertical-align: middle; } .cpg-lines td { vertical-align: middle; }
.cpg-lines input { width: 100%; } .cpg-lines .cpg-name-sel { width: 100%; min-width: 160px; }
.cpg-line-calc { font-size: 12px; color: var(--color-midtone-gray); white-space: nowrap; } /* 폭 고정: 제품코드 140px, 수량·입수량 60px */
.cpg-lines .cpg-code { width: 140px; min-width: 140px; box-sizing: border-box; }
.cpg-lines .cpg-qty,
.cpg-lines .cpg-upb { width: 60px; min-width: 60px; box-sizing: border-box; }
.cpg-lines .cpg-memo { width: 100%; min-width: 120px; box-sizing: border-box; }
.cpg-line-calc { font-size: 13px; color: var(--color-midtone-gray); white-space: nowrap; }
.cpg-line-calc.cpg-warn { color: var(--color-callout-red); } .cpg-line-calc.cpg-warn { color: var(--color-callout-red); }
.cpg-line-del { cursor: pointer; } .cpg-line-del { cursor: pointer; }
+83 -88
View File
@@ -1,5 +1,6 @@
/* 쿠팡 밀크런 — 출고 폼 동적 라인 / 상품검색 / 박스계산. /* 쿠팡 밀크런 — 출고 폼 동적 라인.
서버가 저장 시 박스 수를 재계산하므로(store.compute_boxes) 여기 계산은 미리보기용. */ 제품명 드롭다운(설정에서 등록한 카탈로그) 선택 → 제품코드 자동 입력.
수량 입력 시 박스 수 미리보기(서버가 저장 시 store.compute_boxes 로 재계산). */
(function () { (function () {
"use strict"; "use strict";
var CFG = window.CPG || {}; var CFG = window.CPG || {};
@@ -14,6 +15,12 @@
rules.forEach(function (r) { if (r.active !== false) ruleMap[r.product_code] = r.units_per_box; }); rules.forEach(function (r) { if (r.active !== false) ruleMap[r.product_code] = r.units_per_box; });
} catch (e) {} } catch (e) {}
// 제품 카탈로그: [{product_code, product_name}]
var products = [];
try { products = JSON.parse(document.getElementById("cpg-products").textContent || "[]"); } catch (e) {}
var nameByCode = {};
products.forEach(function (p) { nameByCode[p.product_code] = p.product_name; });
var initLines = []; var initLines = [];
try { initLines = JSON.parse(document.getElementById("cpg-init-lines").textContent || "[]"); } catch (e) {} try { initLines = JSON.parse(document.getElementById("cpg-init-lines").textContent || "[]"); } catch (e) {}
@@ -21,56 +28,85 @@
function recalc(row) { function recalc(row) {
var qty = parseInt(row.querySelector(".cpg-qty").value, 10) || 0; var qty = parseInt(row.querySelector(".cpg-qty").value, 10) || 0;
var upbInput = row.querySelector(".cpg-upb"); var upb = parseInt(row.querySelector(".cpg-upb").value, 10);
var upb = parseInt(upbInput.value, 10);
var cell = row.querySelector(".cpg-line-calc"); var cell = row.querySelector(".cpg-line-calc");
if (!upb || upb <= 0) { if (!upb || upb <= 0) { cell.textContent = "미설정"; cell.classList.add("cpg-warn"); return; }
cell.textContent = "미설정";
cell.classList.add("cpg-warn");
return;
}
cell.classList.remove("cpg-warn"); cell.classList.remove("cpg-warn");
if (qty <= 0) { cell.textContent = "—"; return; } if (qty <= 0) { cell.textContent = "—"; return; }
var boxes = ceilDiv(qty, upb); var boxes = ceilDiv(qty, upb), rem = qty % upb;
var rem = qty % upb;
cell.textContent = boxes + "박스" + (rem ? " +" + rem : " (딱맞음)"); cell.textContent = boxes + "박스" + (rem ? " +" + rem : " (딱맞음)");
} }
function buildNameSelect(selectedCode) {
var sel = document.createElement("select");
sel.className = "erp-select cpg-name-sel";
var opt0 = document.createElement("option");
opt0.value = ""; opt0.textContent = "— 제품명 선택 —";
sel.appendChild(opt0);
var found = false;
products.forEach(function (p) {
var o = document.createElement("option");
o.value = p.product_code;
o.textContent = p.product_name;
o.setAttribute("data-name", p.product_name);
if (p.product_code === selectedCode) { o.selected = true; found = true; }
sel.appendChild(o);
});
// 카탈로그에 없는 기존 라인 코드 → 임시 옵션으로 표시
if (selectedCode && !found) {
var o = document.createElement("option");
o.value = selectedCode;
o.textContent = (nameByCode[selectedCode] || selectedCode) + " (미등록)";
o.setAttribute("data-name", nameByCode[selectedCode] || selectedCode);
o.selected = true;
sel.appendChild(o);
}
return sel;
}
function makeRow(data) { function makeRow(data) {
data = data || {}; data = data || {};
var tr = document.createElement("tr"); var tr = document.createElement("tr");
// 컬럼: # / 제품명(select) / 제품코드 / 수량 / 입수량 / 박스계산 / 라인메모 / 삭제
tr.innerHTML = tr.innerHTML =
'<td class="cpg-line-no"></td>' + '<td class="cpg-line-no"></td>' +
'<td><input class="erp-input cpg-code" type="text" autocomplete="off" placeholder="제품코드" /></td>' + '<td class="cpg-cell-name"></td>' +
'<td><input class="erp-input cpg-name" type="text" placeholder="제품" /></td>' + '<td><input class="erp-input cpg-code" type="text" placeholder="제품코드" /></td>' +
'<td><input class="erp-input cpg-qty" type="number" min="1" style="max-width:90px" /></td>' + '<td><input class="erp-input cpg-qty" type="number" min="1" /></td>' +
'<td><input class="erp-input cpg-upb" type="number" min="1" style="max-width:90px" placeholder="입수량" /></td>' + '<td><input class="erp-input cpg-upb" type="number" min="1" placeholder="입수량" /></td>' +
'<td><span class="cpg-line-calc">—</span></td>' + '<td><span class="cpg-line-calc">—</span></td>' +
'<td><input class="erp-input cpg-memo" type="text" /></td>' + '<td><input class="erp-input cpg-memo" type="text" /></td>' +
'<td><button type="button" class="erp-btn erp-btn-outline cpg-line-del">✕</button></td>'; '<td><button type="button" class="erp-btn erp-btn-outline cpg-line-del">✕</button></td>';
tr.querySelector(".cpg-code").value = data.product_code || "";
tr.querySelector(".cpg-name").value = data.product_name_snapshot || data.product_name || "";
tr.querySelector(".cpg-qty").value = data.quantity || "";
var upb = data.units_per_box;
if (upb == null && data.product_code && ruleMap[data.product_code] != null) upb = ruleMap[data.product_code];
tr.querySelector(".cpg-upb").value = (upb != null ? upb : "");
tr.querySelector(".cpg-line-del").addEventListener("click", function () { var code = data.product_code || "";
tr.remove(); renumber(); var nameSel = buildNameSelect(code);
}); tr.querySelector(".cpg-cell-name").appendChild(nameSel);
tr.querySelector(".cpg-qty").addEventListener("input", function () { recalc(tr); });
tr.querySelector(".cpg-upb").addEventListener("input", function () { recalc(tr); });
var codeInput = tr.querySelector(".cpg-code"); var codeInput = tr.querySelector(".cpg-code");
codeInput.addEventListener("input", function () { codeInput.value = code;
// 입수량 규칙 자동 채움 (수동값 없을 때) tr.querySelector(".cpg-qty").value = data.quantity || "";
var code = codeInput.value.trim(); var upb = data.units_per_box;
if (code && ruleMap[code] != null && !tr.querySelector(".cpg-upb").value) { if (upb == null && code && ruleMap[code] != null) upb = ruleMap[code];
tr.querySelector(".cpg-upb").value = ruleMap[code]; tr.querySelector(".cpg-upb").value = (upb != null ? upb : "");
}
if (CFG.searchEnabled) scheduleSearch(codeInput, tr); // 제품명 선택 → 코드 자동 입력 + 입수량 자동
nameSel.addEventListener("change", function () {
var c = nameSel.value;
codeInput.value = c;
if (c && ruleMap[c] != null) tr.querySelector(".cpg-upb").value = ruleMap[c];
recalc(tr);
}); });
codeInput.addEventListener("blur", function () { setTimeout(hidePop, 150); }); // 코드 직접 입력 시 입수량 규칙 자동
codeInput.addEventListener("input", function () {
var c = codeInput.value.trim();
if (c && ruleMap[c] != null && !tr.querySelector(".cpg-upb").value) {
tr.querySelector(".cpg-upb").value = ruleMap[c];
}
});
tr.querySelector(".cpg-line-del").addEventListener("click", function () { tr.remove(); renumber(); });
tr.querySelector(".cpg-qty").addEventListener("input", function () { recalc(tr); });
tr.querySelector(".cpg-upb").addEventListener("input", function () { recalc(tr); });
body.appendChild(tr); body.appendChild(tr);
recalc(tr); recalc(tr);
@@ -84,68 +120,28 @@
}); });
} }
// ── 상품 검색 드롭다운 ── // 초기 라인
var pop = document.getElementById("cpg-search-pop");
var searchTimer = null;
function hidePop() { if (pop) { pop.hidden = true; pop.innerHTML = ""; } }
function scheduleSearch(input, tr) {
if (!pop) return;
clearTimeout(searchTimer);
var q = input.value.trim();
if (q.length < 1) { hidePop(); return; }
searchTimer = setTimeout(function () { doSearch(q, input, tr); }, 220);
}
function doSearch(q, input, tr) {
fetch(CFG.searchUrl + "?q=" + encodeURIComponent(q))
.then(function (r) { return r.json(); })
.then(function (data) {
var results = (data && data.results) || [];
if (!results.length) { hidePop(); return; }
pop.innerHTML = "";
results.forEach(function (item) {
var div = document.createElement("div");
div.className = "cpg-search-item";
div.innerHTML = '<span class="cpg-sc"></span><span class="cpg-sn"></span>';
div.querySelector(".cpg-sc").textContent = item.code;
div.querySelector(".cpg-sn").textContent = item.name + (item.type ? " · " + item.type : "");
div.addEventListener("mousedown", function (e) {
e.preventDefault();
input.value = item.code;
tr.querySelector(".cpg-name").value = item.name || "";
var upb = item.units_per_box != null ? item.units_per_box
: (ruleMap[item.code] != null ? ruleMap[item.code] : "");
if (upb !== "" && !tr.querySelector(".cpg-upb").value) tr.querySelector(".cpg-upb").value = upb;
recalc(tr);
hidePop();
});
pop.appendChild(div);
});
var rect = input.getBoundingClientRect();
pop.style.left = (window.scrollX + rect.left) + "px";
pop.style.top = (window.scrollY + rect.bottom + 2) + "px";
pop.hidden = false;
})
.catch(hidePop);
}
// ── 초기 라인 ──
if (initLines.length) { initLines.forEach(makeRow); } else { makeRow(); } if (initLines.length) { initLines.forEach(makeRow); } else { makeRow(); }
document.getElementById("cpg-add-line").addEventListener("click", function () { makeRow(); }); document.getElementById("cpg-add-line").addEventListener("click", function () { makeRow(); });
// ── 제출: 라인 직렬화 ── // 제출: 라인 직렬화
form.addEventListener("submit", function (e) { form.addEventListener("submit", function (e) {
var lines = []; var lines = [];
Array.prototype.forEach.call(body.querySelectorAll("tr"), function (tr) { Array.prototype.forEach.call(body.querySelectorAll("tr"), function (tr) {
var code = tr.querySelector(".cpg-code").value.trim(); var code = tr.querySelector(".cpg-code").value.trim();
var qty = parseInt(tr.querySelector(".cpg-qty").value, 10) || 0; var qty = parseInt(tr.querySelector(".cpg-qty").value, 10) || 0;
if (!code || qty <= 0) return; // 빈 라인 스킵 (서버도 스킵) if (!code || qty <= 0) return;
var upb = parseInt(tr.querySelector(".cpg-upb").value, 10); var upb = parseInt(tr.querySelector(".cpg-upb").value, 10);
var sel = tr.querySelector(".cpg-name-sel");
var name = "";
if (sel && sel.selectedIndex >= 0) {
var opt = sel.options[sel.selectedIndex];
name = opt ? (opt.getAttribute("data-name") || "") : "";
}
if (!name) name = nameByCode[code] || code;
lines.push({ lines.push({
product_code: code, product_code: code,
product_name_snapshot: tr.querySelector(".cpg-name").value.trim(), product_name_snapshot: name,
quantity: qty, quantity: qty,
units_per_box: (upb > 0 ? upb : null), units_per_box: (upb > 0 ? upb : null),
memo: tr.querySelector(".cpg-memo").value.trim() memo: tr.querySelector(".cpg-memo").value.trim()
@@ -153,13 +149,13 @@
}); });
if (!lines.length) { if (!lines.length) {
e.preventDefault(); e.preventDefault();
alert("품목 라인을 최소 1개 입력하세요 (제품코드 + 수량)."); alert("품목 라인을 최소 1개 입력하세요 (제품명/코드 + 수량).");
return; return;
} }
document.getElementById("cpg-lines-json").value = JSON.stringify(lines); document.getElementById("cpg-lines-json").value = JSON.stringify(lines);
}); });
// ── 입고센터 select → 스냅샷 hidden 동기화 ── // 입고센터 select → 스냅샷 hidden 동기화
var centerSel = document.getElementById("cpg-center-select"); var centerSel = document.getElementById("cpg-center-select");
var centerName = document.getElementById("cpg-center-name"); var centerName = document.getElementById("cpg-center-name");
if (centerSel && centerName) { if (centerSel && centerName) {
@@ -168,7 +164,6 @@
if (opt && opt.value) centerName.value = opt.getAttribute("data-name") || opt.text; if (opt && opt.value) centerName.value = opt.getAttribute("data-name") || opt.text;
} }
centerSel.addEventListener("change", syncCenter); centerSel.addEventListener("change", syncCenter);
// 신규일 때만 자동 동기화 (수정 시 기존 스냅샷 보존)
if (!centerName.value) syncCenter(); if (!centerName.value) syncCenter();
} }
})(); })();
+21 -1
View File
@@ -92,6 +92,25 @@ CREATE TRIGGER trg_cupang_box_rules_updated
BEFORE UPDATE ON cupang_box_rules BEFORE UPDATE ON cupang_box_rules
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at(); FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 2-b) 제품명 카탈로그 (itemcode_db 에서 가져와 등록 → 폼 드롭다운 소스)
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cupang_products (
id BIGSERIAL PRIMARY KEY,
product_code TEXT NOT NULL UNIQUE,
product_name TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_cupang_products_name ON cupang_products (product_name);
DROP TRIGGER IF EXISTS trg_cupang_products_updated ON cupang_products;
CREATE TRIGGER trg_cupang_products_updated
BEFORE UPDATE ON cupang_products
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
-- 3) 출고 묶음 (헤더) -- 3) 출고 묶음 (헤더)
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
@@ -169,7 +188,8 @@ ON CONFLICT (name) DO NOTHING;
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
GRANT USAGE ON SCHEMA public TO cupang_app; GRANT USAGE ON SCHEMA public TO cupang_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON GRANT SELECT, INSERT, UPDATE, DELETE ON
cupang_centers, cupang_box_rules, cupang_shipments, cupang_shipment_lines cupang_centers, cupang_box_rules, cupang_products,
cupang_shipments, cupang_shipment_lines
TO cupang_app; TO cupang_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO cupang_app; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO cupang_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public ALTER DEFAULT PRIVILEGES IN SCHEMA public