Job completed
-- Pass the item to next Station + +
-diff --git a/app/kpi_service.py b/app/kpi_service.py index 8b36f412e0439090a0cebc70adc33d72f044d6cd..be5ed21ff106c32c7eb26c24fa228ef3a7b95ba6 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 11d29e160cbe2a3d9202c89b708c6f3b2efa9d15..f8b3e3bd71383a55dd77800e67b48830242bd7bb 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 2a279ce2fd46f589ffce7453e1815fa1ca5f8b6b..8a4e7a2eea972482d2a979e9062533b266ab190c 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 087c504c585da29953d314e99740ea02a37e18ed..4aa230539b3de78102d34358d0bbb8f676d07e35 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 2f66b50947c0e513f265031932574fe5d0d3e123..eee4e69d60714900f8ce749858159a0655f91624 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 17b025cec8910e0867602cf163cdf54f4b270fdf..a4c9b6f607a637d9a557113c99c5c3927cf603ad 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 5300e376b3abcaa342cbbaa5172fc6b31b547b66..5ac9488cca873da13b8b8cc378da76ff6204de09 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" > +
+
Job completed
- Pass the item to next Station
+
+
- Error: {{ error }} -
- {% endif %} +- Material Alert: {{ stock_message }} -
- {% endif %} +{{ message }}
-{% endif %} -- 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/') %} -+ No photo available for this step. +
+ {% endif %} +Error: {{ error }}
- {% endif %} + - {% if completion_message %} -{{ completion_message }}
- {% endif %} + {% if error %} + + {% endif %} - {% if scan_message %} -{{ scan_message }}
- {% endif %} + {% if message %} + + {% endif %} + + {% if stock_message %} + + {% endif %} + + {% if completion_message %} + + {% endif %} + + {% if scan_message %} + + {% endif %} + + {% endif %} + -