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}
|
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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user