Legacy manual batch start locked until plate entered: - POST /api/batch/start manual branch returns 400 missing_plate when plate empty (strip + uppercase, no format regex) - operator + monitoring start modals gain required plate input, confirm button disabled until filled - operator plate tile also shows for manual batches (was do_manual only) - do_manual unchanged (plate from DOs)
This commit is contained in:
1 parent
f621ae86fb
commit
7ad995d8d1
6 files changed
+118
-6
No files matched your search
@@ -7,6 +7,13 @@ version tags are cut in this repo — POC stage, `main` is the release line).
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Manual mode plate entry**: legacy `manual` batch start is now locked until a
|
||||||
|
plate number is entered — plate input in operator + monitoring start modals
|
||||||
|
(confirm button disabled while empty), `POST /api/batch/start` rejects
|
||||||
|
manual start with 400 `missing_plate` (strip + uppercase, no format regex);
|
||||||
|
plate flows through `current_batch.json` → `batches.plate` on stop, so
|
||||||
|
history/XLSX show it for manual batches. Operator plate tile now also shows
|
||||||
|
for manual batches (was `do_manual` only).
|
||||||
- **DO-gated manual batch mode** (`auto` default · `do_manual` · legacy `manual`):
|
- **DO-gated manual batch mode** (`auto` default · `do_manual` · legacy `manual`):
|
||||||
smartphone photo capture → OCR draft → plate-grouped start gates → net
|
smartphone photo capture → OCR draft → plate-grouped start gates → net
|
||||||
expected/counted on live panel → stop soft-warn + force → discard only when
|
expected/counted on live panel → stop soft-warn + force → discard only when
|
||||||
|
|||||||
@@ -472,6 +472,17 @@ def api_batch_start():
|
|||||||
expected_sack = sum(int(d.get("expected_sack") or 0) for d in dos)
|
expected_sack = sum(int(d.get("expected_sack") or 0) for d in dos)
|
||||||
expected_box = sum(int(d.get("expected_box") or 0) for d in dos)
|
expected_box = sum(int(d.get("expected_box") or 0) for d in dos)
|
||||||
|
|
||||||
|
elif mode == "manual":
|
||||||
|
# Legacy manual mode: batch start locked until plate entered.
|
||||||
|
req_data = request.get_json(silent=True) or request.form or {}
|
||||||
|
plate = str(req_data.get("plate", "")).strip().upper()
|
||||||
|
if not plate:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"reason": "missing_plate",
|
||||||
|
"error": "Plat nomor wajib diisi sebelum batch dimulai.",
|
||||||
|
}), 400
|
||||||
|
|
||||||
counting_date = get_counting_date()
|
counting_date = get_counting_date()
|
||||||
batch_num = get_next_batch_number(counting_date)
|
batch_num = get_next_batch_number(counting_date)
|
||||||
now_iso = datetime.now().isoformat()
|
now_iso = datetime.now().isoformat()
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ deprecated (warned).
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `auto` (default) | AI truck FSM | n/a |
|
| `auto` (default) | AI truck FSM | n/a |
|
||||||
| `do_manual` | Operator start/stop + staged DO photos | yes |
|
| `do_manual` | Operator start/stop + staged DO photos | yes |
|
||||||
| `manual` | Operator start/stop (legacy) | no |
|
| `manual` | Operator start/stop (legacy) — plate required at start | no |
|
||||||
|
|
||||||
`POST /api/batch/mode` with `mode` and/or `model_mode` is **office port only**
|
`POST /api/batch/mode` with `mode` and/or `model_mode` is **office port only**
|
||||||
(403 on operator `:5000`); mode switch while a batch is active → **409**.
|
(403 on operator `:5000`); mode switch while a batch is active → **409**.
|
||||||
|
|||||||
@@ -405,10 +405,16 @@
|
|||||||
<div id="modalStart" style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: none; align-items: center; justify-content: center; z-index: 999; padding: 20px;">
|
<div id="modalStart" style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: none; align-items: center; justify-content: center; z-index: 999; padding: 20px;">
|
||||||
<div style="background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 24px; max-width: 400px; width: 100%; text-align: center;">
|
<div style="background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 24px; max-width: 400px; width: 100%; text-align: center;">
|
||||||
<h3 style="font-size: 18px; font-weight: 700; margin-bottom: 12px;"><i class="fa-solid fa-play" style="color: var(--accent-green);"></i> Mulai Batch Baru?</h3>
|
<h3 style="font-size: 18px; font-weight: 700; margin-bottom: 12px;"><i class="fa-solid fa-play" style="color: var(--accent-green);"></i> Mulai Batch Baru?</h3>
|
||||||
<p style="font-size: 14px; color: var(--text-secondary); margin-bottom: 24px;">Counter akan diaktifkan dan batch baru akan dibuat.</p>
|
<p style="font-size: 14px; color: var(--text-secondary); margin-bottom: 12px;">Counter akan diaktifkan dan batch baru akan dibuat.</p>
|
||||||
|
<div id="monStartPlateRow" style="display:none;margin-bottom: 24px; text-align: left;">
|
||||||
|
<label for="monStartPlateInput" style="display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px;">Plat Nomor (wajib)</label>
|
||||||
|
<input type="text" id="monStartPlateInput" placeholder="B 1234 XYZ" autocomplete="off"
|
||||||
|
oninput="onMonStartPlateInput()"
|
||||||
|
style="width:100%;padding:8px 10px;border-radius:var(--radius-md);border:1px solid var(--border-color);background:var(--bg-card);color:var(--text-primary);font-size:14px;text-transform:uppercase;box-sizing:border-box;">
|
||||||
|
</div>
|
||||||
<div style="display: flex; gap: 12px;">
|
<div style="display: flex; gap: 12px;">
|
||||||
<button class="btn btn-secondary" style="flex: 1;" onclick="document.getElementById('modalStart').style.display='none'">Batal</button>
|
<button class="btn btn-secondary" style="flex: 1;" onclick="document.getElementById('modalStart').style.display='none'">Batal</button>
|
||||||
<button class="btn btn-primary" style="flex: 1; background-color: var(--accent-green);" onclick="executeStartBatch()">Ya, Mulai</button>
|
<button id="monBtnStartConfirm" class="btn btn-primary" style="flex: 1; background-color: var(--accent-green);" onclick="executeStartBatch()">Ya, Mulai</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -479,7 +485,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onMonStartPlateInput() {
|
||||||
|
const plate = document.getElementById('monStartPlateInput').value.trim();
|
||||||
|
const btn = document.getElementById('monBtnStartConfirm');
|
||||||
|
btn.style.opacity = plate.length === 0 ? '0.6' : '';
|
||||||
|
btn.style.pointerEvents = plate.length === 0 ? 'none' : '';
|
||||||
|
}
|
||||||
|
|
||||||
function confirmStartBatch() {
|
function confirmStartBatch() {
|
||||||
|
const plateRow = document.getElementById('monStartPlateRow');
|
||||||
|
const isManual = currentBatchMode === 'manual';
|
||||||
|
plateRow.style.display = isManual ? 'block' : 'none';
|
||||||
|
if (isManual) {
|
||||||
|
document.getElementById('monStartPlateInput').value = '';
|
||||||
|
onMonStartPlateInput();
|
||||||
|
}
|
||||||
document.getElementById('modalStart').style.display = 'flex';
|
document.getElementById('modalStart').style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,7 +510,18 @@
|
|||||||
async function executeStartBatch() {
|
async function executeStartBatch() {
|
||||||
document.getElementById('modalStart').style.display = 'none';
|
document.getElementById('modalStart').style.display = 'none';
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/batch/start', { method: 'POST' });
|
let body = null;
|
||||||
|
let headers = {};
|
||||||
|
if (currentBatchMode === 'manual') {
|
||||||
|
const plate = document.getElementById('monStartPlateInput').value.trim().toUpperCase();
|
||||||
|
if (!plate) {
|
||||||
|
alert('Plat nomor wajib diisi sebelum batch dimulai.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body = JSON.stringify({ plate: plate });
|
||||||
|
headers = { 'Content-Type': 'application/json' };
|
||||||
|
}
|
||||||
|
const res = await fetch('/api/batch/start', { method: 'POST', headers: headers, body: body });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
await pollCurrentBatch();
|
await pollCurrentBatch();
|
||||||
|
|||||||
+30
-2
@@ -118,6 +118,7 @@
|
|||||||
.btn-disabled {
|
.btn-disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Simple Confirmation Modal */
|
/* Simple Confirmation Modal */
|
||||||
@@ -425,10 +426,16 @@
|
|||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 class="modal-title"><i class="fa-solid fa-play" style="color: var(--accent-green);"></i> Mulai Batch Baru?</h3>
|
<h3 class="modal-title"><i class="fa-solid fa-play" style="color: var(--accent-green);"></i> Mulai Batch Baru?</h3>
|
||||||
<p class="modal-desc" id="startModalDesc">Pastikan truk sudah siap di posisi pemuatan karung.</p>
|
<p class="modal-desc" id="startModalDesc">Pastikan truk sudah siap di posisi pemuatan karung.</p>
|
||||||
|
<div id="startPlateRow" style="display:none;margin:8px 0;">
|
||||||
|
<label for="startPlateInput" style="display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px;">Plat Nomor (wajib)</label>
|
||||||
|
<input type="text" id="startPlateInput" placeholder="B 1234 XYZ" autocomplete="off"
|
||||||
|
style="width:100%;padding:8px 10px;border-radius:var(--radius-md);border:1px solid var(--border-color);background:var(--bg-card);color:var(--text-primary);font-size:14px;text-transform:uppercase;box-sizing:border-box;"
|
||||||
|
oninput="onStartPlateInput()">
|
||||||
|
</div>
|
||||||
<div id="startGateList" class="modal-desc" style="display:none;color:var(--accent-red);"></div>
|
<div id="startGateList" class="modal-desc" style="display:none;color:var(--accent-red);"></div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-secondary" onclick="closeModal('modalStart')">Batal</button>
|
<button class="btn btn-secondary" onclick="closeModal('modalStart')">Batal</button>
|
||||||
<button class="btn btn-primary" style="background-color: var(--accent-green);" onclick="executeStartBatch()">Ya, Mulai</button>
|
<button id="btnStartConfirm" class="btn btn-primary" style="background-color: var(--accent-green);" onclick="executeStartBatch()">Ya, Mulai</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -687,10 +694,19 @@
|
|||||||
return (data && data.items) || [];
|
return (data && data.items) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onStartPlateInput() {
|
||||||
|
const plate = document.getElementById('startPlateInput').value.trim();
|
||||||
|
const btn = document.getElementById('btnStartConfirm');
|
||||||
|
btn.classList.toggle('btn-disabled', plate.length === 0);
|
||||||
|
}
|
||||||
|
|
||||||
function confirmStartBatch() {
|
function confirmStartBatch() {
|
||||||
const gate = document.getElementById('startGateList');
|
const gate = document.getElementById('startGateList');
|
||||||
const desc = document.getElementById('startModalDesc');
|
const desc = document.getElementById('startModalDesc');
|
||||||
|
const plateRow = document.getElementById('startPlateRow');
|
||||||
if (currentMode === 'do_manual') {
|
if (currentMode === 'do_manual') {
|
||||||
|
plateRow.style.display = 'none';
|
||||||
|
document.getElementById('btnStartConfirm').classList.remove('btn-disabled');
|
||||||
fetchStagedForStart().then(items => {
|
fetchStagedForStart().then(items => {
|
||||||
stagedDos = items;
|
stagedDos = items;
|
||||||
const problems = [];
|
const problems = [];
|
||||||
@@ -717,6 +733,9 @@
|
|||||||
}
|
}
|
||||||
gate.style.display = 'none';
|
gate.style.display = 'none';
|
||||||
desc.textContent = 'Pastikan truk sudah siap di posisi pemuatan karung.';
|
desc.textContent = 'Pastikan truk sudah siap di posisi pemuatan karung.';
|
||||||
|
document.getElementById('startPlateInput').value = '';
|
||||||
|
plateRow.style.display = 'block';
|
||||||
|
onStartPlateInput();
|
||||||
document.getElementById('modalStart').style.display = 'flex';
|
document.getElementById('modalStart').style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -744,6 +763,14 @@
|
|||||||
if (currentMode === 'do_manual') {
|
if (currentMode === 'do_manual') {
|
||||||
const items = await fetchStagedForStart();
|
const items = await fetchStagedForStart();
|
||||||
body = JSON.stringify({ do_ids: items.map(d => d.id) });
|
body = JSON.stringify({ do_ids: items.map(d => d.id) });
|
||||||
|
} else if (currentMode === 'manual') {
|
||||||
|
const plate = document.getElementById('startPlateInput').value.trim().toUpperCase();
|
||||||
|
if (!plate) {
|
||||||
|
closeModal('modalStart');
|
||||||
|
alert('Plat nomor wajib diisi sebelum batch dimulai.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body = JSON.stringify({ plate: plate });
|
||||||
}
|
}
|
||||||
const res = await fetch('/api/batch/start', {
|
const res = await fetch('/api/batch/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -817,7 +844,8 @@
|
|||||||
const plateItem = document.getElementById('opPlateItem');
|
const plateItem = document.getElementById('opPlateItem');
|
||||||
const plateDos = document.getElementById('opPlateDos');
|
const plateDos = document.getElementById('opPlateDos');
|
||||||
const batchActive = !!(data.success && data.batch_number);
|
const batchActive = !!(data.success && data.batch_number);
|
||||||
const showPlate = currentMode === 'do_manual' && batchActive;
|
const showPlate = batchActive &&
|
||||||
|
(currentMode === 'do_manual' || !!(data.plate && data.plate.trim()));
|
||||||
if (doSack) doSack.textContent = data.expected_sack || 0;
|
if (doSack) doSack.textContent = data.expected_sack || 0;
|
||||||
if (doBox) doBox.textContent = data.expected_box || 0;
|
if (doBox) doBox.textContent = data.expected_box || 0;
|
||||||
if (plateItem) plateItem.style.display = showPlate ? 'block' : 'none';
|
if (plateItem) plateItem.style.display = showPlate ? 'block' : 'none';
|
||||||
|
|||||||
@@ -266,6 +266,41 @@ def test_start_stop_auto_409(dash_client):
|
|||||||
assert res.status_code == 409
|
assert res.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_start_missing_plate_400(dash_client):
|
||||||
|
client, mode_path, state_path, _ = dash_client
|
||||||
|
office = {"Host": f"localhost:{cd_office_port()}"}
|
||||||
|
client.post("/api/batch/mode", json={"mode": "manual"}, headers=office)
|
||||||
|
res = client.post("/api/batch/start", headers={"Host": "localhost:5000"})
|
||||||
|
assert res.status_code == 400
|
||||||
|
body = res.get_json()
|
||||||
|
assert body["success"] is False
|
||||||
|
assert body["reason"] == "missing_plate"
|
||||||
|
assert not state_path.exists()
|
||||||
|
|
||||||
|
res = client.post("/api/batch/start", json={"plate": " "},
|
||||||
|
headers={"Host": "localhost:5000"})
|
||||||
|
assert res.status_code == 400
|
||||||
|
assert res.get_json()["reason"] == "missing_plate"
|
||||||
|
assert not state_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_start_with_plate_normalizes_and_persists(dash_client):
|
||||||
|
client, mode_path, state_path, _ = dash_client
|
||||||
|
office = {"Host": f"localhost:{cd_office_port()}"}
|
||||||
|
client.post("/api/batch/mode", json={"mode": "manual"}, headers=office)
|
||||||
|
res = client.post("/api/batch/start", json={"plate": " b 1234 xyz "},
|
||||||
|
headers={"Host": "localhost:5000"})
|
||||||
|
assert res.status_code == 200
|
||||||
|
body = res.get_json()
|
||||||
|
assert body["success"] is True
|
||||||
|
assert body["plate"] == "B 1234 XYZ"
|
||||||
|
assert state_path.exists()
|
||||||
|
state = json.loads(state_path.read_text())
|
||||||
|
assert state["plate"] == "B 1234 XYZ"
|
||||||
|
assert state["batch_mode"] == "manual"
|
||||||
|
assert state["manual_control"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_stop_discard_gross_zero(dash_client):
|
def test_stop_discard_gross_zero(dash_client):
|
||||||
client, mode_path, state_path, _ = dash_client
|
client, mode_path, state_path, _ = dash_client
|
||||||
office = {"Host": f"localhost:{cd_office_port()}"}
|
office = {"Host": f"localhost:{cd_office_port()}"}
|
||||||
|
|||||||
Reference in new issue
Block a user