From 6dc14a73504aab4f921983e65d4024047337b21f Mon Sep 17 00:00:00 2001 From: king Date: Fri, 26 Jun 2026 17:06:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(dispatch):=20=EC=A3=BC=EB=AC=B8/=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80/=EC=86=A1=EC=9E=A5=20=EB=B2=88=ED=98=B8?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=9B=EB=8A=94=20=EC=82=AC=EB=9E=8C=20=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=EA=B2=80=EC=83=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배치 목록 상단에 검색 박스 추가. 주문번호·패키지번호·송장번호 일부만 입력하면 전체 출고 배치에서 해당 박스의 받는 사람(이름·전화·주소)과 소속 배치를 찾아준다. - db.search_parcels: 3개 식별자 ILIKE 부분일치 + 배치 조인(파라미터 바인딩) - GET /dispatch/api/search: 2글자 이상, dispatch 권한 가드 - batches.html: 디바운스 검색 UI, 매칭 강조, 한/영 토글 연동 Co-Authored-By: Claude Opus 4.8 --- app/modules/dispatch/db.py | 41 +++++++ app/modules/dispatch/router.py | 14 +++ .../dispatch/templates/dispatch/batches.html | 104 ++++++++++++++++++ 3 files changed, 159 insertions(+) diff --git a/app/modules/dispatch/db.py b/app/modules/dispatch/db.py index 4940005..811c88c 100644 --- a/app/modules/dispatch/db.py +++ b/app/modules/dispatch/db.py @@ -358,6 +358,47 @@ class DispatchStore: ), ) + def search_parcels(self, *, query: str, limit: int = 50) -> list[dict[str, Any]]: + """주문번호/패키지번호/송장번호(부분 일치)로 박스를 찾는다. + + 3개 식별자 어디든 query 가 포함되면 매칭. 받는 사람(이름/전화/주소)과 + 소속 배치(날짜/플랫폼/배치명/id) + 박스 seq 를 함께 돌려준다. + 최신 출고일/배치 우선. query 가 비면 빈 리스트. + """ + q = (query or "").strip() + if not q: + return [] + like = f"%{q}%" + with self._pool.connection() as conn: + rows = conn.execute( + """ + SELECT p.id AS parcel_id, + p.seq, + p.order_id, + p.package_id, + p.tracking_id, + p.shipping_provider, + p.recipient_name, + p.recipient_phone, + p.recipient_address, + p.label_attached, + p.handed_to_kagayaku, + b.id AS batch_id, + b.dispatch_date, + b.platform, + b.batch_name + FROM dispatch_parcels p + JOIN dispatch_batches b ON b.id = p.batch_id + WHERE p.order_id ILIKE %(like)s + OR p.package_id ILIKE %(like)s + OR p.tracking_id ILIKE %(like)s + ORDER BY b.dispatch_date DESC, b.id DESC, p.seq ASC + LIMIT %(limit)s + """, + {"like": like, "limit": int(limit)}, + ).fetchall() + return [self._serialize(r) for r in rows] + def parcel_batch_id(self, *, parcel_id: int) -> int | None: with self._pool.connection() as conn: row = conn.execute( diff --git a/app/modules/dispatch/router.py b/app/modules/dispatch/router.py index 4c8acf1..9aa8148 100644 --- a/app/modules/dispatch/router.py +++ b/app/modules/dispatch/router.py @@ -715,6 +715,20 @@ async def api_parcels( return JSONResponse({"parcels": st.list_parcels(batch_id=batch_id)}) +@router.get("/api/search") +async def api_search( + request: Request, q: str = "", _: dict[str, Any] = Depends(_require_user) +) -> JSONResponse: + """주문번호/패키지번호/송장번호(부분 일치)로 박스 검색 → 받는 사람/배치 반환.""" + st = _store(request) + if st is None: + raise HTTPException(status_code=503, detail="dispatch_db 미설정") + query = (q or "").strip() + if len(query) < 2: + return JSONResponse({"query": query, "results": []}) + return JSONResponse({"query": query, "results": st.search_parcels(query=query)}) + + @router.get("/health") async def health() -> dict[str, str]: return {"status": "ok", "module": "dispatch"} diff --git a/app/modules/dispatch/templates/dispatch/batches.html b/app/modules/dispatch/templates/dispatch/batches.html index a1fc865..70531a5 100644 --- a/app/modules/dispatch/templates/dispatch/batches.html +++ b/app/modules/dispatch/templates/dispatch/batches.html @@ -20,6 +20,20 @@ .dsp-cal-day.has { color:#0f172a;background:#eef2ff;font-weight:700;cursor:pointer;border:1px solid #c7d2fe; } .dsp-cal-day.has:hover { background:#e0e7ff; } .dsp-cal-day.sel { background:#16a34a !important;border-color:#16a34a !important;color:#fff !important; } + /* 검색 */ + .dsp-search { margin:0 0 14px; } + .dsp-search-box { display:flex;gap:8px;align-items:center;flex-wrap:wrap; } + .dsp-search-box input { flex:1 1 320px;min-width:220px;padding:10px 12px;border:1px solid #cbd5e1;border-radius:8px;font-size:15px; } + .dsp-search-note { font-size:12px;color:#94a3b8;margin:6px 2px 0; } + .dsp-search-results { margin-top:10px;display:flex;flex-direction:column;gap:8px; } + .dsp-hit { border:1px solid #e2e8f0;border-radius:10px;padding:10px 12px;background:#fff;display:flex;justify-content:space-between;gap:12px;align-items:flex-start; } + .dsp-hit-name { font-weight:700;font-size:15px;color:#0f172a; } + .dsp-hit-meta { font-size:13px;color:#475569;margin-top:2px;line-height:1.5; } + .dsp-hit-meta code { background:#f1f5f9;padding:1px 5px;border-radius:4px;font-size:12px; } + .dsp-hit mark { background:#fde68a;padding:0 1px;border-radius:2px; } + .dsp-hit-batch { font-size:12px;color:#64748b;margin-top:4px; } + .dsp-hit-go { white-space:nowrap;flex:0 0 auto;align-self:center; } + .dsp-hit-empty { color:#94a3b8;font-size:14px;padding:8px 2px; } {% endblock %} @@ -27,6 +41,20 @@
{% include "dispatch/_nav.html" %} + +
@@ -235,5 +263,81 @@ document.addEventListener('dsp:lang', function () { render(); updateDeductBtn(); }); render(); })(); + +// ── 받는 사람 찾기(주문/패키지/송장 번호 부분 일치) ── +(function () { + var input = document.getElementById('dsp-q'); + var box = document.getElementById('dsp-search-results'); + if (!input || !box) return; + var timer = null, lastQ = ''; + + function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"'); + } + // 매칭된 부분만 강조(이미 escape 된 문자열에 대해 안전하게 처리). + function hi(value, q) { + var s = esc(value); + if (!q) return s; + var i = s.toLowerCase().indexOf(q.toLowerCase()); + if (i < 0) return s; + return s.slice(0, i) + '' + s.slice(i, i + q.length) + '' + s.slice(i + q.length); + } + function en() { return (window.dspLang && window.dspLang() === 'en'); } + + function row(r, q) { + var ids = []; + if (r.order_id) ids.push('Order ' + hi(r.order_id, q) + ''); + if (r.package_id) ids.push('Pkg ' + hi(r.package_id, q) + ''); + if (r.tracking_id) ids.push((en() ? 'Track ' : '송장 ') + '' + hi(r.tracking_id, q) + ''); + var name = r.recipient_name || (en() ? '(no recipient name)' : '(받는 사람 정보 없음)'); + var contact = []; + if (r.recipient_phone) contact.push(esc(r.recipient_phone)); + if (r.recipient_address) contact.push(esc(r.recipient_address)); + var done = r.label_attached && r.handed_to_kagayaku; + var batchLine = esc(r.dispatch_date) + ' · ' + esc(r.platform) + ' · ' + esc(r.batch_name || '') + + ' · ' + (en() ? 'Box #' : '박스 #') + esc(r.seq) + + (done ? ' · ' : ''); + return '
' + + '
' + + '
' + esc(name) + '
' + + (contact.length ? '
' + contact.join(' · ') + '
' : '') + + '
' + ids.join('   ') + '
' + + '
' + batchLine + '
' + + '
' + + '' + + (en() ? 'Open' : '작업 열기') + '' + + '
'; + } + + function run(q) { + fetch('/dispatch/api/search?q=' + encodeURIComponent(q)) + .then(function (r) { return r.json(); }) + .then(function (j) { + if (j.query !== input.value.trim()) return; // 더 최신 입력이 있으면 무시 + var rs = j.results || []; + if (!rs.length) { + box.innerHTML = '
' + + (en() ? 'No match.' : '일치하는 박스가 없습니다.') + '
'; + return; + } + box.innerHTML = rs.map(function (r) { return row(r, q); }).join(''); + }) + .catch(function () { + box.innerHTML = '
' + + (en() ? 'Search failed.' : '검색 실패.') + '
'; + }); + } + + input.addEventListener('input', function () { + var q = input.value.trim(); + if (timer) clearTimeout(timer); + if (q.length < 2) { box.innerHTML = ''; lastQ = ''; return; } + if (q === lastQ) return; + lastQ = q; + timer = setTimeout(function () { run(q); }, 250); + }); +})(); {% endblock %}