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:
@@ -250,6 +250,42 @@ class DispatchStore:
|
||||
)
|
||||
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:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
|
||||
@@ -503,6 +503,26 @@ async def parcel_toggle(
|
||||
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")
|
||||
async def api_parcels(
|
||||
request: Request, batch_id: int, _: dict[str, Any] = Depends(_require_user)
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
<style>
|
||||
.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-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-card { border:1px solid #e2e8f0;border-radius:12px;padding:14px;background:#fff;display:flex;flex-direction:column;gap:10px; }
|
||||
.dsp-card { position:relative; }
|
||||
@@ -61,6 +63,10 @@
|
||||
</div>
|
||||
<input class="erp-input dsp-search" id="dsp-search" type="search"
|
||||
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 class="dsp-cards" id="dsp-cards">
|
||||
@@ -123,6 +129,27 @@
|
||||
<script>
|
||||
(function () {
|
||||
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 저장) ──
|
||||
window.dspToggle = function (btn) {
|
||||
|
||||
Reference in New Issue
Block a user