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 error %} +
+ Error: + {{ error }} +
+ {% endif %} - {% if message %} -

{{ message }}

-{% endif %} -

{{ step.job.name }}

- -

- Product Unit: - {{ current_unit.unit_number }} of {{ step.job.quantity }} -

- -

- Production Step {{ step.step_number }} - of {{ step.job.steps|length }} -

- -

- Required Parts: - {% if step.parts %} - {% for usage in step.parts %} - {{ usage.part.name }} x {{ usage.quantity }}{% if not loop.last %}, {% endif %} - {% endfor %} - {% else %} - None - {% endif %} -

- -

Instruction: {{ step.instruction }}

- - {% if step.photo_url and step.photo_url.startswith('uploads/step_photos/') %} - Instruction photo for step {{ step.step_number }} - {% endif %} - -
- - + Material Alert: + {{ stock_message }} + + {% endif %} + + {% if message %} +
+ {{ message }} +
+ {% endif %} + +

+ Job: + {{ step.job.name }} +

+ +

+ Unit: + {{ current_unit.unit_number }} + of {{ step.job.quantity }} +

+ +

+ Step: + {{ step.step_number }} + of {{ step.job.steps|length }} +

+ +

+ Parts: + + {% if step.parts %} + {% for usage in step.parts %} + {{ usage.part.name }} + x {{ usage.quantity }}{% if not loop.last %}, {% endif %} + {% endfor %} + {% else %} + None + {% endif %} +

+ +

+ Instruction: + {{ step.instruction[:120] }}{% if step.instruction|length > 120 %}…{% endif %} +

+
+ +
+ {% if step.photo_url and step.photo_url.startswith( + 'uploads/step_photos/' + ) %} + Instruction photo for step {{ step.step_number }} + {% else %} +

+ No photo available for this step. +

+ {% endif %} +
+ +
+

+ Press the foot pedal to complete this step +

+ + - - - + + + + +
+ + +
+ {% else %} -
- - -

-
- -

+
+
+

The Digital Factory Game

- - +

{{ station.name }}

- {% if error %} -

Error: {{ error }}

- {% endif %} +
+ - {% if message %} -

{{ message }}

- {% endif %} + - {% if stock_message %} -

- Material Alert: {{ stock_message }} -

- {% 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 %} + - + \ No newline at end of file -- GitLab