From dfab93bc7dab2b59598f0c70a81edffa8ff7d3b5 Mon Sep 17 00:00:00 2001
From: Nameru Thapa
Date: Sat, 12 Sep 2026 19:43:40 +0200
Subject: [PATCH] feat: final foot-pedal flow,, duplicate-scan protection, and
improved CSV report layout
---
app/kpi_service.py | 8 +-
app/models.py | 2 +-
app/report_service.py | 354 +++++++++++++-----
app/routes.py | 33 +-
app/services.py | 94 ++++-
app/templates/job_detail.html | 12 +-
app/templates/worker_kiosk.html | 619 +++++++++++++++++++++++++-------
7 files changed, 875 insertions(+), 247 deletions(-)
diff --git a/app/kpi_service.py b/app/kpi_service.py
index 8b36f41..be5ed21 100644
--- a/app/kpi_service.py
+++ b/app/kpi_service.py
@@ -129,11 +129,15 @@ def calculate_kpis(job_ids):
(unit_number, station_id)
)
- if not times or times["arrival"] is None:
+ if (
+ not times
+ or times["arrival"] is None
+ or times["departure"] is None
+ ):
continue
arrival = times["arrival"]
- departure = times["departure"] or arrival
+ departure = times["departure"]
stats = station_stats.setdefault(
station_id,
diff --git a/app/models.py b/app/models.py
index 11d29e1..f8b3e3b 100644
--- a/app/models.py
+++ b/app/models.py
@@ -55,7 +55,7 @@ class Job(db.Model):
default=2,
)
status = db.Column(db.String(20), nullable=False, default="Draft")
- barcode = db.Column(db.String(30), unique=True)
+ barcode = db.Column(db.String(30), nullable=True)
created_at = db.Column(
db.DateTime,
diff --git a/app/report_service.py b/app/report_service.py
index 2a279ce..8a4e7a2 100644
--- a/app/report_service.py
+++ b/app/report_service.py
@@ -12,8 +12,10 @@ from app.models import (
ProductionReport,
ProductUnit,
ScanEvent,
+ JobStep,
+ UnitStepProgress,
)
-
+from sqlalchemy import func, distinct
def slugify_job_name(name):
slug = re.sub(r"[^A-Za-z0-9]+", "-", name or "").strip("-")
@@ -54,141 +56,305 @@ def get_completed_cutoff(job_id):
return max(completed_unit_numbers)
+def get_station_output(job_id: int, station_id: str) -> int:
+ """
+ Number of units that have completed ALL steps for `station_id`
+ in job `job_id`.
+ """
+ total_steps = (
+ JobStep.query
+ .filter_by(job_id=job_id, station_id=station_id)
+ .count()
+ )
+ if total_steps == 0:
+ return 0
-def build_report_rows(job):
- cutoff = get_completed_cutoff(job.id)
- entry_station_id = get_entry_station_id(job)
+ subq = (
+ db.session.query(
+ UnitStepProgress.product_unit_id,
+ func.count(UnitStepProgress.job_step_id).label("completed_steps"),
+ )
+ .join(JobStep, JobStep.id == UnitStepProgress.job_step_id)
+ .filter(
+ JobStep.job_id == job_id,
+ JobStep.station_id == station_id,
+ UnitStepProgress.status == "Completed",
+ )
+ .group_by(UnitStepProgress.product_unit_id)
+ .subquery()
+ )
- rows = []
+ completed_units = (
+ db.session.query(func.count(distinct(subq.c.product_unit_id)))
+ .filter(subq.c.completed_steps == total_steps)
+ .scalar()
+ )
- if job.activated_at:
- rows.append(
- {
- "event": "Barcode Generated",
- "unit_number": "",
- "station_id": "",
- "step_number": "",
- "timestamp": job.activated_at.isoformat(),
- "unit_status": "",
- }
+ return completed_units or 0
+
+
+def get_job_finished_goods(job_id: int) -> int:
+ """
+ Number of units that have completed ALL steps in the job
+ (i.e. finished goods).
+ """
+ total_steps = JobStep.query.filter_by(job_id=job_id).count()
+ if total_steps == 0:
+ return 0
+
+ subq = (
+ db.session.query(
+ UnitStepProgress.product_unit_id,
+ func.count(UnitStepProgress.job_step_id).label("completed_steps"),
+ )
+ .join(JobStep, JobStep.id == UnitStepProgress.job_step_id)
+ .filter(
+ JobStep.job_id == job_id,
+ UnitStepProgress.status == "Completed",
)
+ .group_by(UnitStepProgress.product_unit_id)
+ .subquery()
+ )
- scan_events = (
- ScanEvent.query.filter_by(job_id=job.id)
- .order_by(ScanEvent.occurred_at)
- .all()
+ finished_units = (
+ db.session.query(func.count(distinct(subq.c.product_unit_id)))
+ .filter(subq.c.completed_steps == total_steps)
+ .scalar()
)
- first_arrival_by_unit = {}
+ return finished_units or 0
+
+
+def get_all_station_outputs(job_id: int) -> dict[str, int]:
+ """
+ Returns {station_id: completed_units_count} for all stations in the job.
+ """
+ stations = (
+ db.session.query(
+ JobStep.station_id,
+ func.count(JobStep.id).label("total_steps"),
+ )
+ .filter_by(job_id=job_id)
+ .group_by(JobStep.station_id)
+ .all()
+ )
- for event in scan_events:
- unit = event.product_unit
- unit_status = (
- "Completed"
- if unit.unit_number <= cutoff
- else "Discarded (Job Closed Early)"
+ subq = (
+ db.session.query(
+ UnitStepProgress.product_unit_id,
+ JobStep.station_id,
+ func.count(UnitStepProgress.job_step_id).label("completed_steps"),
+ )
+ .join(JobStep, JobStep.id == UnitStepProgress.job_step_id)
+ .filter(
+ JobStep.job_id == job_id,
+ UnitStepProgress.status == "Completed",
)
+ .group_by(UnitStepProgress.product_unit_id, JobStep.station_id)
+ .subquery()
+ )
+
+ result = {}
+ for station_id, total_steps in stations:
+ completed = (
+ db.session.query(func.count(distinct(subq.c.product_unit_id)))
+ .filter(
+ subq.c.station_id == station_id,
+ subq.c.completed_steps == total_steps,
+ )
+ .scalar()
+ ) or 0
+ result[station_id] = completed
- if (
- event.event_type == "arrival"
- and unit.id not in first_arrival_by_unit
- ):
- first_arrival_by_unit[unit.id] = event
+ return result
+
+def build_report_rows(job):
+
+ rows = []
+
+ # ---------- Job summary ----------
+ completion_method = (
+ "Manually completed"
+ if job.completed_manually
+ else "Naturally completed"
+ )
+
+ completed_units_count = ProductUnit.query.filter_by(
+ job_id=job.id,
+ status="Completed",
+ ).count()
+
+ finished_goods = get_job_finished_goods(job.id)
+ station_outputs = get_all_station_outputs(job.id)
+
+ def add_summary_row_no_ts(label, value):
rows.append(
{
- "event": (
- "Station Arrival"
- if event.event_type == "arrival"
- else "Step Completed"
- ),
- "unit_number": unit.unit_number,
- "station_id": event.station_id,
- "step_number": (
- event.job_step.step_number
- if event.job_step is not None
- else ""
- ),
- "timestamp": event.occurred_at.isoformat(),
- "unit_status": unit_status,
+ "event": label,
+ "unit_number": value,
+ "station_id": "",
+ "step_number": "",
+ "timestamp": "",
+ "unit_status": "",
}
)
- for unit_id, event in first_arrival_by_unit.items():
- if event.station_id != entry_station_id:
- continue
-
- unit = event.product_unit
- unit_status = (
- "Completed"
- if unit.unit_number <= cutoff
- else "Discarded (Job Closed Early)"
- )
-
+ def add_summary_row_with_ts(label, value, ts):
rows.append(
{
- "event": "Production Started",
- "unit_number": unit.unit_number,
- "station_id": event.station_id,
+ "event": label,
+ "unit_number": value,
+ "station_id": "",
"step_number": "",
- "timestamp": event.occurred_at.isoformat(),
- "unit_status": unit_status,
+ "timestamp": ts.strftime("%H:%M:%S") if ts else "",
+ "unit_status": "",
}
)
- completed_units = ProductUnit.query.filter_by(
- job_id=job.id,
- status="Completed",
- ).all()
-
- for unit in completed_units:
- last_event = (
- ScanEvent.query.filter_by(
- job_id=job.id,
- product_unit_id=unit.id,
- )
- .order_by(ScanEvent.occurred_at.desc())
- .first()
+ # No timestamp
+ add_summary_row_no_ts("Job Name", job.name)
+ add_summary_row_no_ts("Date",job.finished_at.strftime("%Y-%m-%d") if job.finished_at else "",)
+ add_summary_row_no_ts("Planned Quantity", str(job.quantity))
+ add_summary_row_no_ts("Completed Units (all)", str(completed_units_count))
+ add_summary_row_no_ts("Finished Goods", str(finished_goods))
+
+ for station_id in sorted(station_outputs.keys()):
+ add_summary_row_no_ts(
+ f"Station Output ({station_id})",
+ str(station_outputs[station_id]),
)
- unit_status = (
- "Completed"
- if unit.unit_number <= cutoff
- else "Discarded (Job Closed Early)"
+ add_summary_row_no_ts("Completion Method", completion_method)
+
+ # With timestamp (value in column B, no timestamp in column E)
+ add_summary_row_with_ts(
+ "Activated At",
+ job.activated_at.strftime("%H:%M:%S") if job.activated_at else "",
+ None, # no timestamp in column E
+ )
+ add_summary_row_with_ts(
+ "Finished At",
+ job.finished_at.strftime("%H:%M:%S") if job.finished_at else "",
+ None, # no timestamp in column E
+ )
+ # Blank row to separate summary from unit timelines
+ rows.append(
+ {
+ "event": "",
+ "unit_number": "",
+ "station_id": "",
+ "step_number": "",
+ "timestamp": "",
+ "unit_status": "",
+ }
+ )
+
+ # ---------- Per-unit timelines ----------
+ completed_units = (
+ ProductUnit.query.filter_by(job_id=job.id, status="Completed")
+ .order_by(ProductUnit.unit_number)
+ .all()
+ )
+
+ # Build per-unit step completions from UnitStepProgress
+ unit_steps = {}
+ for unit in completed_units:
+ steps = (
+ UnitStepProgress.query.filter_by(product_unit_id=unit.id)
+ .join(JobStep)
+ .filter(UnitStepProgress.status == "Completed")
+ .order_by(JobStep.step_number)
+ .all()
)
+ unit_steps[unit.unit_number] = steps
+ for unit in completed_units:
+ # Unit header
rows.append(
{
- "event": "Unit Completed",
+ "event": f"--- Unit {unit.unit_number} ---",
"unit_number": unit.unit_number,
- "station_id": (
- last_event.station_id
- if last_event is not None
- else ""
- ),
+ "station_id": "",
"step_number": "",
- "timestamp": (
- last_event.occurred_at.isoformat()
- if last_event is not None
- else ""
- ),
- "unit_status": unit_status,
+ "timestamp": "",
+ "unit_status": unit.status,
}
)
- if job.finished_at:
+ # Production started: first completed step's station & time
+ steps = unit_steps.get(unit.unit_number, [])
+ if steps:
+ first_step = steps[0]
+ last_step = steps[-1]
+
+ # Find earliest completed step's scan event to get start time
+ first_event = (
+ ScanEvent.query.filter_by(
+ job_id=job.id,
+ product_unit_id=unit.id,
+ )
+ .filter(ScanEvent.event_type == "arrival")
+ .order_by(ScanEvent.occurred_at)
+ .first()
+ )
+
+ if first_event:
+ rows.append(
+ {
+ "event": "Production Started",
+ "unit_number": unit.unit_number,
+ "station_id": first_event.station_id,
+ "step_number": "",
+ "timestamp":first_event.occurred_at.strftime("%H:%M:%S"),
+ "unit_status": unit.status,
+ }
+ )
+
+ # Step Completed rows in step_number order
+ for usp in steps:
+ step = usp.job_step
+ # Use the completion timestamp from UnitStepProgress
+ ts = usp.completed_at
+ rows.append(
+ {
+ "event": "Step Completed",
+ "unit_number": unit.unit_number,
+ "station_id": step.station_id,
+ "step_number": step.step_number,
+ "timestamp": ts.strftime("%H:%M:%S") if ts else "",
+ "unit_status": unit.status,
+ }
+ )
+
+ # Unit Completed: use last step's completion time
+ last_ts = last_step.completed_at
+ rows.append(
+ {
+ "event": "Unit Completed",
+ "unit_number": unit.unit_number,
+ "station_id": last_step.job_step.station_id,
+ "step_number": "",
+ "timestamp": ts.strftime("%H:%M:%S") if ts else "",
+ "unit_status": unit.status,
+ }
+ )
+
+ # Blank row between units
rows.append(
{
- "event": "Job Completed",
+ "event": "",
"unit_number": "",
"station_id": "",
"step_number": "",
- "timestamp": job.finished_at.isoformat(),
+ "timestamp": "",
"unit_status": "",
}
)
- rows.sort(key=lambda row: row["timestamp"] or "")
+ # Remove trailing blank row if present
+ while rows and rows[-1]["event"] == "":
+ rows.pop()
return rows
diff --git a/app/routes.py b/app/routes.py
index 087c504..4aa2305 100644
--- a/app/routes.py
+++ b/app/routes.py
@@ -216,7 +216,14 @@ def job_detail(job_id):
400,
description="Production instruction is required.",
)
-
+ if len(instruction) > 120:
+ abort(
+ 400,
+ description=(
+ "Production instruction must not exceed "
+ "120 characters."
+ ),
+ )
if not part_name:
abort(
400,
@@ -914,12 +921,23 @@ def worker_kiosk(station_id):
if action == "complete":
normalized_barcode = entered_barcode.upper()
- job = Job.query.filter_by(
- barcode=normalized_barcode,
- ).first()
+ job = (
+ Job.query
+ .filter(
+ Job.barcode == normalized_barcode,
+ db.func.lower(
+ db.func.trim(Job.status)
+ ) == "active",
+ )
+ .order_by(Job.id.desc())
+ .first()
+ )
if job is None:
- error = "Barcode not recognized."
+ error = (
+ "No active production job was found "
+ "for this barcode."
+ )
else:
result, error = complete_current_step(
job,
@@ -930,10 +948,7 @@ def worker_kiosk(station_id):
completed_unit = result["completed_unit"]
completed_step = result["completed_step"]
- message = (
- f"Unit {completed_unit.unit_number}, "
- f"Step {completed_step.step_number} completed."
- )
+ message = None
if result["transfer_requests"]:
stock_message = (
diff --git a/app/services.py b/app/services.py
index 2f66b50..eee4e69 100644
--- a/app/services.py
+++ b/app/services.py
@@ -332,6 +332,7 @@ def reuse_job(
if low_stock_threshold is not None
else job.low_stock_threshold
),
+ barcode=job.barcode,
status="Draft",
)
@@ -603,17 +604,44 @@ def scan_job_barcode(barcode, station_id):
if not normalized_station_id:
return None, "Select a station."
- job = Job.query.filter_by(
- barcode=normalized_barcode,
- ).first()
+ jobs = (
+ Job.query
+ .filter_by(
+ barcode=normalized_barcode,
+ )
+ .order_by(Job.id.desc())
+ .all()
+ )
- if job is None:
+ if not jobs:
return None, "Barcode not recognized."
- if job.status == "Completed":
- return None, "This production job is complete."
+ job = next(
+ (
+ current_job
+ for current_job in jobs
+ if (
+ current_job.status
+ and current_job.status.strip().lower() == "active"
+ )
+ ),
+ None,
+ )
+
+ if job is None:
+ if any(
+ current_job.status
+ and current_job.status.strip().lower() == "paused"
+ for current_job in jobs
+ ):
+ return None, "This production job is paused."
+
+ if all(
+ current_job.status == "Completed"
+ for current_job in jobs
+ ):
+ return None, "This production job is complete."
- if job.status != "Active":
return None, "This production job is not active."
if not job.steps:
@@ -625,7 +653,9 @@ def scan_job_barcode(barcode, station_id):
)
if current_unit is None or current_step is None:
- remaining_unit, remaining_step = get_current_unit_and_step(job)
+ remaining_unit, remaining_step = get_current_unit_and_step(
+ job
+ )
if remaining_unit is None or remaining_step is None:
return None, "All product units are complete."
@@ -634,14 +664,12 @@ def scan_job_barcode(barcode, station_id):
return current_step, None
-
def record_scan_arrival(barcode, station_id):
"""
- Wraps scan_job_barcode with ScanEvent logging. This is what the
- kiosk 'scan' action calls: on success it opens (or re-affirms) a
- session for the FIFO-selected unit at this station and logs the
- arrival timestamp. Returns (unit, step, error) so the route can
- render exactly as before.
+ Validates a scan and records one arrival event for the current
+ unit/station visit. Repeated scans at the same station do not
+ create duplicate arrival events unless the unit has departed
+ from that station.
"""
step, error = scan_job_barcode(barcode, station_id)
@@ -649,12 +677,47 @@ def record_scan_arrival(barcode, station_id):
return None, None, error
normalized_station_id = (station_id or "").strip().upper()
+
unit, step = get_current_unit_and_step(
step.job,
normalized_station_id,
)
- if unit is not None and step is not None:
+ if unit is None or step is None:
+ return None, None, "This job is not ready at this station."
+
+ latest_arrival = (
+ ScanEvent.query.filter_by(
+ job_id=step.job_id,
+ product_unit_id=unit.id,
+ station_id=normalized_station_id,
+ event_type="arrival",
+ )
+ .order_by(ScanEvent.occurred_at.desc())
+ .first()
+ )
+
+ latest_completion = (
+ ScanEvent.query.filter_by(
+ job_id=step.job_id,
+ product_unit_id=unit.id,
+ station_id=normalized_station_id,
+ event_type="step_completed",
+ )
+ .order_by(ScanEvent.occurred_at.desc())
+ .first()
+ )
+
+ arrival_is_already_open = (
+ latest_arrival is not None
+ and (
+ latest_completion is None
+ or latest_arrival.occurred_at
+ > latest_completion.occurred_at
+ )
+ )
+
+ if not arrival_is_already_open:
db.session.add(
ScanEvent(
job_id=step.job_id,
@@ -668,7 +731,6 @@ def record_scan_arrival(barcode, station_id):
return unit, step, None
-
def calculate_remaining_part_requirement(
job,
station_id,
diff --git a/app/templates/job_detail.html b/app/templates/job_detail.html
index 17b025c..a4c9b6f 100644
--- a/app/templates/job_detail.html
+++ b/app/templates/job_detail.html
@@ -154,7 +154,17 @@
-
+
+
+
+
+ Maximum 120 characters.
+
+
diff --git a/app/templates/worker_kiosk.html b/app/templates/worker_kiosk.html
index 5300e37..5ac9488 100644
--- a/app/templates/worker_kiosk.html
+++ b/app/templates/worker_kiosk.html
@@ -6,160 +6,531 @@
name="viewport"
content="width=device-width, initial-scale=1"
>
+
MES - Worker Kiosk {{ station.id }}
+
+
+
- The Digital Factory Game
- {{ station.name }}
-
{% if transition_message %}
-
-
+
+
Job completed
-
- Pass the item to next Station
+
+
+ {{ transition_message }}
-
+
{% elif step and current_unit %}
-
- {% if error %}
-
- Error: {{ error }}
-
- {% endif %}
+
+
+ The Digital Factory Game
+ {{ station.name }}
+
-{% if stock_message %}
-
- Material Alert: {{ stock_message }}
-
- {% endif %}
+
+
+
+ {% if step.photo_url and step.photo_url.startswith(
+ 'uploads/step_photos/'
+ ) %}
+
+ {% else %}
+
+ No photo available for this step.
+
+ {% endif %}
+
+
+
+
+
+
+
{% else %}
-
+ {{ station.name }}
- {% if error %}
- Error: {{ error }}
- {% endif %}
+
- {% if completion_message %}
- {{ completion_message }}
- {% endif %}
+ {% if error %}
+
+ Error:
+ {{ error }}
+
+ {% endif %}
- {% if scan_message %}
- {{ scan_message }}
- {% endif %}
+ {% if message %}
+
+ {{ message }}
+
+ {% endif %}
+
+ {% if stock_message %}
+
+ Material Alert:
+ {{ stock_message }}
+
+ {% endif %}
+
+ {% if completion_message %}
+
+ {{ completion_message }}
+
+ {% endif %}
+
+ {% if scan_message %}
+
+ {{ scan_message }}
+
+ {% endif %}
+
+
{% endif %}
+
-