feat(dispatch): 일괄 완료 버튼(모두 라벨부착/모두 Kagayaku 전달) + 검색바 축소

- 출고 작업 툴바 우측에 일괄 버튼 2개. 배치 내 전 박스 상태를 완료로 일괄 설정
- db.bulk_set_status: 변경된 박스만 로그(set 기반 INSERT), 화이트리스트 검증
- 라우터 POST /batches/{id}/bulk
- 검색 입력칸 폭 축소(flex 0 1 240px)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 15:29:55 +09:00
parent ef4658fb1d
commit a1dd9bed4a
3 changed files with 84 additions and 1 deletions
+36
View File
@@ -250,6 +250,42 @@ class DispatchStore:
) )
return {"parcel_id": parcel_id, "field": field, "value": new_value} return {"parcel_id": parcel_id, "field": field, "value": new_value}
def bulk_set_status(
self, *, batch_id: int, field: str, value: bool = True, worker_name: str = ""
) -> int:
"""배치 내 모든 박스의 작업 상태 1개를 value 로 일괄 설정. 변경된 박스만 로그.
반환: 실제로 바뀐 박스 수(이미 value 인 박스는 건드리지 않음).
field 는 store.STATUS_FIELDS 화이트리스트 검증(SQL 식별자 안전).
"""
if field not in store.STATUS_FIELDS:
raise ValueError(f"허용되지 않는 작업 상태: {field}")
with self._pool.connection() as conn:
with conn.transaction():
rows = conn.execute(
# field 는 화이트리스트라 인젝션 위험 없음.
f"UPDATE dispatch_parcels SET {field} = %s "
f"WHERE batch_id = %s AND {field} = %s RETURNING id",
(value, batch_id, not value),
).fetchall()
ids = [r["id"] for r in rows]
if ids:
conn.execute(
"""
INSERT INTO dispatch_logs
(parcel_id, action, old_value, new_value, worker_name)
SELECT unnest(%s::bigint[]), %s, %s, %s, %s
""",
(
ids,
field,
str(not value).lower(),
str(value).lower(),
(worker_name or "").lower().strip(),
),
)
return len(ids)
def parcel_batch_id(self, *, parcel_id: int) -> int | None: def parcel_batch_id(self, *, parcel_id: int) -> int | None:
with self._pool.connection() as conn: with self._pool.connection() as conn:
row = conn.execute( row = conn.execute(
+20
View File
@@ -503,6 +503,26 @@ async def parcel_toggle(
return JSONResponse({"ok": True, **result}) return JSONResponse({"ok": True, **result})
@router.post("/batches/{batch_id:int}/bulk")
async def parcels_bulk(
request: Request,
batch_id: int,
field: str = Form(...),
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
"""배치 내 모든 박스의 작업 상태 1개를 완료(TRUE)로 일괄 설정. JSON 반환."""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="dispatch_db 미설정")
try:
count = st.bulk_set_status(
batch_id=batch_id, field=field, value=True, worker_name=user.get("email", "")
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return JSONResponse({"ok": True, "field": field, "count": count})
@router.get("/api/batches/{batch_id:int}/parcels") @router.get("/api/batches/{batch_id:int}/parcels")
async def api_parcels( async def api_parcels(
request: Request, batch_id: int, _: dict[str, Any] = Depends(_require_user) request: Request, batch_id: int, _: dict[str, Any] = Depends(_require_user)
@@ -4,7 +4,9 @@
<style> <style>
.dsp-toolbar { display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0; } .dsp-toolbar { display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0; }
.dsp-filter { display:flex;gap:6px;flex-wrap:wrap; } .dsp-filter { display:flex;gap:6px;flex-wrap:wrap; }
.dsp-search { flex:1 1 220px;min-width:180px; } .dsp-search { flex:0 1 240px;min-width:150px; }
.dsp-bulk { margin-left:auto;display:flex;gap:8px;flex-wrap:wrap; }
.dsp-bulk .erp-btn:disabled { opacity:.6;cursor:wait; }
.dsp-cards { display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px; } .dsp-cards { display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px; }
.dsp-card { border:1px solid #e2e8f0;border-radius:12px;padding:14px;background:#fff;display:flex;flex-direction:column;gap:10px; } .dsp-card { border:1px solid #e2e8f0;border-radius:12px;padding:14px;background:#fff;display:flex;flex-direction:column;gap:10px; }
.dsp-card { position:relative; } .dsp-card { position:relative; }
@@ -61,6 +63,10 @@
</div> </div>
<input class="erp-input dsp-search" id="dsp-search" type="search" <input class="erp-input dsp-search" id="dsp-search" type="search"
placeholder="검색: Order ID / Tracking ID / 받는 사람 / SKU" /> placeholder="검색: Order ID / Tracking ID / 받는 사람 / SKU" />
<div class="dsp-bulk">
<button class="erp-btn erp-btn-outline" type="button" onclick="dspBulk(this,'label_attached')">모두 라벨 부착</button>
<button class="erp-btn erp-btn-outline" type="button" onclick="dspBulk(this,'handed_to_kagayaku')">모두 Kagayaku 전달</button>
</div>
</div> </div>
<div class="dsp-cards" id="dsp-cards"> <div class="dsp-cards" id="dsp-cards">
@@ -123,6 +129,27 @@
<script> <script>
(function () { (function () {
var STATUS_FIELDS = {{ status_fields|list|tojson }}; var STATUS_FIELDS = {{ status_fields|list|tojson }};
var BATCH_ID = document.querySelector('.dsp').getAttribute('data-batch');
// ── 일괄 완료 처리 ──
var BULK_LABELS = { label_attached: '라벨 부착', handed_to_kagayaku: 'Kagayaku 전달' };
window.dspBulk = function (btn, field) {
if (!confirm('이 배치의 모든 박스를 "' + (BULK_LABELS[field] || field) + '" 완료로 표시할까요?')) return;
document.querySelectorAll('.dsp-bulk .erp-btn').forEach(function (b) { b.disabled = true; });
var body = new URLSearchParams();
body.set('field', field);
fetch('/dispatch/batches/' + BATCH_ID + '/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function () { location.reload(); })
.catch(function () {
alert('일괄 처리에 실패했습니다. 다시 시도하세요.');
document.querySelectorAll('.dsp-bulk .erp-btn').forEach(function (b) { b.disabled = false; });
});
};
// ── 상태 토글 (즉시 DB 저장) ── // ── 상태 토글 (즉시 DB 저장) ──
window.dspToggle = function (btn) { window.dspToggle = function (btn) {