feat(dispatch): 주문/패키지/송장 번호로 받는 사람 찾기 검색

배치 목록 상단에 검색 박스 추가. 주문번호·패키지번호·송장번호 일부만
입력하면 전체 출고 배치에서 해당 박스의 받는 사람(이름·전화·주소)과
소속 배치를 찾아준다.

- db.search_parcels: 3개 식별자 ILIKE 부분일치 + 배치 조인(파라미터 바인딩)
- GET /dispatch/api/search: 2글자 이상, dispatch 권한 가드
- batches.html: 디바운스 검색 UI, 매칭 강조, 한/영 토글 연동

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 17:06:45 +09:00
parent 26d0753579
commit 6dc14a7350
3 changed files with 159 additions and 0 deletions
+41
View File
@@ -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(
+14
View File
@@ -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"}
@@ -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; }
</style>
{% endblock %}
@@ -27,6 +41,20 @@
<section class="dsp">
{% include "dispatch/_nav.html" %}
<div class="erp-card dsp-search">
<div class="cpg-card-head"><h3 style="margin:0;" data-ko="🔎 받는 사람 찾기" data-en="🔎 Find Recipient">🔎 받는 사람 찾기</h3></div>
<div class="dsp-search-box">
<input type="text" id="dsp-q" autocomplete="off"
data-ko-ph="주문번호 · 패키지번호 · 송장번호 (일부만 입력해도 됨)"
data-en-ph="Order / Package / Tracking number (partial OK)"
placeholder="주문번호 · 패키지번호 · 송장번호 (일부만 입력해도 됨)" />
</div>
<div class="dsp-search-note"
data-ko="번호 일부만 입력하면 전체 출고 배치에서 해당 박스의 받는 사람을 찾아줍니다. (2글자 이상)"
data-en="Type any part of a number to find the recipient across all batches. (2+ chars)">번호 일부만 입력하면 전체 출고 배치에서 해당 박스의 받는 사람을 찾아줍니다. (2글자 이상)</div>
<div class="dsp-search-results" id="dsp-search-results"></div>
</div>
<div class="dsp-layout">
<div class="erp-card dsp-list">
<div class="cpg-card-head" style="display:flex;justify-content:space-between;align-items:center;">
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// 매칭된 부분만 강조(이미 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) + '<mark>' + s.slice(i, i + q.length) + '</mark>' + 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 <code>' + hi(r.order_id, q) + '</code>');
if (r.package_id) ids.push('Pkg <code>' + hi(r.package_id, q) + '</code>');
if (r.tracking_id) ids.push((en() ? 'Track ' : '송장 ') + '<code>' + hi(r.tracking_id, q) + '</code>');
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 ? ' · <span style="color:#16a34a;font-weight:600;">✓</span>' : '');
return '<div class="dsp-hit">'
+ '<div>'
+ '<div class="dsp-hit-name">' + esc(name) + '</div>'
+ (contact.length ? '<div class="dsp-hit-meta">' + contact.join(' · ') + '</div>' : '')
+ '<div class="dsp-hit-meta">' + ids.join(' &nbsp; ') + '</div>'
+ '<div class="dsp-hit-batch">' + batchLine + '</div>'
+ '</div>'
+ '<a class="erp-btn erp-btn-outline dsp-hit-go" href="/dispatch/batches/' + encodeURIComponent(r.batch_id) + '">'
+ (en() ? 'Open' : '작업 열기') + '</a>'
+ '</div>';
}
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 = '<div class="dsp-hit-empty">'
+ (en() ? 'No match.' : '일치하는 박스가 없습니다.') + '</div>';
return;
}
box.innerHTML = rs.map(function (r) { return row(r, q); }).join('');
})
.catch(function () {
box.innerHTML = '<div class="dsp-hit-empty">'
+ (en() ? 'Search failed.' : '검색 실패.') + '</div>';
});
}
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);
});
})();
</script>
{% endblock %}