From 836443d2d401ca39c123693047fe1d1f1af6e75f Mon Sep 17 00:00:00 2001
From: Nameru Thapa
Date: Tue, 8 Sep 2026 11:58:22 +0200
Subject: [PATCH 1/4] Add KPI and report services with per-station
processing/waiting time
---
app/kpi_service.py | 274 ++++++++++++++++++++++++++++++++++++++++++
app/models.py | 124 +++++++++++++++----
app/report_service.py | 254 +++++++++++++++++++++++++++++++++++++++
3 files changed, 628 insertions(+), 24 deletions(-)
create mode 100644 app/kpi_service.py
create mode 100644 app/report_service.py
diff --git a/app/kpi_service.py b/app/kpi_service.py
new file mode 100644
index 0000000..8b36f41
--- /dev/null
+++ b/app/kpi_service.py
@@ -0,0 +1,274 @@
+from app.models import Job, ProductUnit, ScanEvent
+from app.report_service import get_completed_cutoff
+
+
+def get_ordered_stations_for_job(job):
+ """
+ Distinct stations visited, in the order this job's steps are
+ assigned, collapsing consecutive steps at the same station into
+ one entry. Used to know each station's immediate predecessor
+ for waiting-time math, and to find each job's entry station
+ (index 0), which never has a waiting time.
+ """
+ stations = []
+
+ for step in job.steps:
+ if not stations or stations[-1] != step.station_id:
+ stations.append(step.station_id)
+
+ return stations
+
+
+def get_job_output_counts(job_ids):
+ """
+ Completed-unit count per job, independent of KPI selection.
+ Used to populate the "Output" column for every job in the
+ job-selection table, regardless of whether that job is
+ currently checked for KPI calculation.
+ """
+ outputs = {job_id: 0 for job_id in job_ids}
+
+ rows = (
+ ProductUnit.query.filter(
+ ProductUnit.job_id.in_(job_ids),
+ ProductUnit.status == "Completed",
+ ).all()
+ if job_ids
+ else []
+ )
+
+ for unit in rows:
+ outputs[unit.job_id] = outputs.get(unit.job_id, 0) + 1
+
+ return outputs
+
+
+def calculate_kpis(job_ids):
+ """
+ Computes station processing time, throughput, waiting time per
+ station (skipped for each job's entry station), per-unit
+ throughput time, and production line throughput -- only over
+ units that actually completed every station (unit_number <=
+ that job's cutoff).
+ """
+ jobs = (
+ Job.query.filter(Job.id.in_(job_ids)).all()
+ if job_ids
+ else []
+ )
+
+ station_stats = {}
+ entry_station_ids = set()
+ unit_throughput_times = []
+ line_start = None
+ line_end = None
+ line_completed_units = 0
+
+ for job in jobs:
+ completed_unit_numbers = {
+ unit.unit_number
+ for unit in ProductUnit.query.filter_by(
+ job_id=job.id,
+ status="Completed",
+ ).all()
+ }
+
+ if not completed_unit_numbers:
+ continue
+
+ cutoff = get_completed_cutoff(job.id)
+ station_sequence = get_ordered_stations_for_job(job)
+
+ if not station_sequence:
+ continue
+
+ entry_station_ids.add(station_sequence[0])
+
+ events = (
+ ScanEvent.query.filter_by(job_id=job.id)
+ .order_by(ScanEvent.occurred_at)
+ .all()
+ )
+
+ unit_station_times = {}
+
+ for event in events:
+ unit_number = event.product_unit.unit_number
+
+ if unit_number > cutoff:
+ continue
+
+ key = (unit_number, event.station_id)
+ entry = unit_station_times.setdefault(
+ key,
+ {"arrival": None, "departure": None},
+ )
+
+ if event.event_type == "arrival":
+ if (
+ entry["arrival"] is None
+ or event.occurred_at < entry["arrival"]
+ ):
+ entry["arrival"] = event.occurred_at
+ else:
+ if (
+ entry["departure"] is None
+ or event.occurred_at > entry["departure"]
+ ):
+ entry["departure"] = event.occurred_at
+
+ for unit_number in sorted(completed_unit_numbers):
+ if unit_number > cutoff:
+ continue
+
+ unit_first_time = None
+ unit_last_time = None
+
+ for index, station_id in enumerate(station_sequence):
+ times = unit_station_times.get(
+ (unit_number, station_id)
+ )
+
+ if not times or times["arrival"] is None:
+ continue
+
+ arrival = times["arrival"]
+ departure = times["departure"] or arrival
+
+ stats = station_stats.setdefault(
+ station_id,
+ {
+ "departures": 0,
+ "first_event": None,
+ "last_event": None,
+ "waiting_times": [],
+ "processing_times": [],
+ },
+ )
+
+ stats["departures"] += 1
+
+ if departure >= arrival:
+ stats["processing_times"].append(
+ (departure - arrival).total_seconds()
+ )
+
+ if (
+ stats["first_event"] is None
+ or arrival < stats["first_event"]
+ ):
+ stats["first_event"] = arrival
+
+ if (
+ stats["last_event"] is None
+ or departure > stats["last_event"]
+ ):
+ stats["last_event"] = departure
+
+ if index > 0:
+ previous_station = station_sequence[index - 1]
+ previous_times = unit_station_times.get(
+ (unit_number, previous_station)
+ )
+
+ if previous_times and previous_times["departure"]:
+ waiting_seconds = (
+ arrival - previous_times["departure"]
+ ).total_seconds()
+
+ if waiting_seconds >= 0:
+ stats["waiting_times"].append(
+ waiting_seconds
+ )
+
+ if unit_first_time is None:
+ unit_first_time = arrival
+
+ unit_last_time = departure
+
+ if unit_first_time and unit_last_time:
+ unit_throughput_times.append(
+ (unit_last_time - unit_first_time).total_seconds()
+ )
+
+ if line_start is None or unit_first_time < line_start:
+ line_start = unit_first_time
+
+ if line_end is None or unit_last_time > line_end:
+ line_end = unit_last_time
+
+ line_completed_units += 1
+
+ station_results = []
+
+ for station_id, stats in station_stats.items():
+ elapsed_hours = (
+ (stats["last_event"] - stats["first_event"]).total_seconds()
+ / 3600
+ if stats["first_event"] and stats["last_event"]
+ else 0
+ )
+
+ throughput = (
+ stats["departures"] / elapsed_hours
+ if elapsed_hours > 0
+ else 0
+ )
+
+ avg_waiting_seconds = (
+ sum(stats["waiting_times"]) / len(stats["waiting_times"])
+ if stats["waiting_times"]
+ else 0
+ )
+
+ avg_processing_seconds = (
+ sum(stats["processing_times"])
+ / len(stats["processing_times"])
+ if stats["processing_times"]
+ else 0
+ )
+
+ station_results.append(
+ {
+ "station_id": station_id,
+ "units_processed": stats["departures"],
+ "throughput_per_hour": round(throughput, 2),
+ "avg_processing_minutes": round(
+ avg_processing_seconds / 60, 1
+ ),
+ "avg_waiting_minutes": round(
+ avg_waiting_seconds / 60, 1
+ ),
+ "is_entry_station": station_id in entry_station_ids,
+ }
+ )
+
+ line_elapsed_hours = (
+ (line_end - line_start).total_seconds() / 3600
+ if line_start and line_end
+ else 0
+ )
+
+ line_throughput = (
+ line_completed_units / line_elapsed_hours
+ if line_elapsed_hours > 0
+ else 0
+ )
+
+ avg_unit_throughput_minutes = (
+ sum(unit_throughput_times) / len(unit_throughput_times) / 60
+ if unit_throughput_times
+ else 0
+ )
+
+ return {
+ "stations": sorted(
+ station_results,
+ key=lambda station: station["station_id"],
+ ),
+ "line_throughput_per_hour": round(line_throughput, 2),
+ "avg_unit_throughput_minutes": round(
+ avg_unit_throughput_minutes, 1
+ ),
+ "completed_units_counted": line_completed_units,
+ }
diff --git a/app/models.py b/app/models.py
index 40125e8..d910099 100644
--- a/app/models.py
+++ b/app/models.py
@@ -30,20 +30,9 @@ class InstructionPhoto(db.Model):
__tablename__ = "instruction_photos"
id = db.Column(db.Integer, primary_key=True)
- name = db.Column(
- db.String(100),
- unique=True,
- nullable=False,
- )
- stored_filename = db.Column(
- db.String(100),
- unique=True,
- nullable=False,
- )
- original_filename = db.Column(
- db.String(255),
- nullable=False,
- )
+ name = db.Column(db.String(100), unique=True, nullable=False)
+ stored_filename = db.Column(db.String(100), unique=True, nullable=False)
+ original_filename = db.Column(db.String(255), nullable=False)
uploaded_at = db.Column(
db.DateTime,
nullable=False,
@@ -64,6 +53,19 @@ class Job(db.Model):
status = db.Column(db.String(20), nullable=False, default="Draft")
barcode = db.Column(db.String(30), unique=True)
+ created_at = db.Column(
+ db.DateTime,
+ nullable=False,
+ default=lambda: datetime.now(timezone.utc),
+ )
+ activated_at = db.Column(db.DateTime)
+ finished_at = db.Column(db.DateTime)
+ completed_manually = db.Column(
+ db.Boolean,
+ nullable=False,
+ default=False,
+ )
+
steps = db.relationship(
"JobStep",
back_populates="job",
@@ -172,11 +174,7 @@ class TransferRequest(db.Model):
__tablename__ = "transfer_requests"
id = db.Column(db.Integer, primary_key=True)
- job_id = db.Column(
- db.Integer,
- db.ForeignKey("jobs.id"),
- nullable=False,
- )
+ job_id = db.Column(db.Integer, db.ForeignKey("jobs.id"), nullable=False)
station_id = db.Column(
db.String(5),
db.ForeignKey("stations.id"),
@@ -208,11 +206,7 @@ class ProductUnit(db.Model):
__tablename__ = "product_units"
id = db.Column(db.Integer, primary_key=True)
- job_id = db.Column(
- db.Integer,
- db.ForeignKey("jobs.id"),
- nullable=False,
- )
+ job_id = db.Column(db.Integer, db.ForeignKey("jobs.id"), nullable=False)
unit_number = db.Column(db.Integer, nullable=False)
status = db.Column(db.String(20), nullable=False, default="Pending")
@@ -263,3 +257,85 @@ class UnitStepProgress(db.Model):
name="uq_unit_step_progress",
),
)
+
+
+class ScanEvent(db.Model):
+ """
+ Timestamped history of every kiosk interaction:
+ - 'arrival' : barcode scanned at a station (session opens)
+ - 'step_completed' : "Complete Step (Simulated Foot Pedal)" pressed
+ for one job step. The row for the LAST step
+ assigned to that station also represents the
+ station departure moment (derived when
+ building reports/KPIs, not stored separately).
+ """
+
+ __tablename__ = "scan_events"
+
+ id = db.Column(db.Integer, primary_key=True)
+
+ job_id = db.Column(db.Integer, db.ForeignKey("jobs.id"), nullable=False)
+ product_unit_id = db.Column(
+ db.Integer,
+ db.ForeignKey("product_units.id"),
+ nullable=False,
+ )
+ station_id = db.Column(
+ db.String(5),
+ db.ForeignKey("stations.id"),
+ nullable=False,
+ )
+ job_step_id = db.Column(
+ db.Integer,
+ db.ForeignKey("job_steps.id"),
+ nullable=True,
+ )
+
+ event_type = db.Column(db.String(20), nullable=False)
+
+ occurred_at = db.Column(
+ db.DateTime,
+ nullable=False,
+ default=lambda: datetime.now(timezone.utc),
+ )
+
+ job = db.relationship("Job")
+ product_unit = db.relationship("ProductUnit")
+ station = db.relationship("Station")
+ job_step = db.relationship("JobStep")
+
+ def __repr__(self):
+ return (
+ f""
+ )
+
+
+class ProductionReport(db.Model):
+ """
+ Library entry for an auto-generated CSV report, one per job that
+ reaches 'Completed' status (naturally or via Mark Complete).
+ Survives job deletion (no FK cascade) -- deleting the job never
+ deletes its report; deleting a report is a separate explicit
+ admin action, same pattern as InstructionPhoto.
+ """
+
+ __tablename__ = "production_reports"
+
+ id = db.Column(db.Integer, primary_key=True)
+
+ job_id = db.Column(db.Integer, db.ForeignKey("jobs.id"), nullable=True)
+ job_name = db.Column(db.String(120), nullable=False)
+
+ csv_filename = db.Column(db.String(150), unique=True, nullable=False)
+
+ generated_at = db.Column(
+ db.DateTime,
+ nullable=False,
+ default=lambda: datetime.now(timezone.utc),
+ )
+
+ job = db.relationship("Job")
+
+ def __repr__(self):
+ return f""
diff --git a/app/report_service.py b/app/report_service.py
new file mode 100644
index 0000000..2a279ce
--- /dev/null
+++ b/app/report_service.py
@@ -0,0 +1,254 @@
+import csv
+import re
+import uuid
+from datetime import datetime, timezone
+from pathlib import Path
+
+from flask import current_app
+
+from app import db
+from app.models import (
+ Job,
+ ProductionReport,
+ ProductUnit,
+ ScanEvent,
+)
+
+
+def slugify_job_name(name):
+ slug = re.sub(r"[^A-Za-z0-9]+", "-", name or "").strip("-")
+ return slug or "job"
+
+
+def report_upload_folder():
+ folder = Path(current_app.config["REPORT_UPLOAD_FOLDER"])
+ folder.mkdir(parents=True, exist_ok=True)
+ return folder
+
+
+def get_entry_station_id(job):
+ if not job.steps:
+ return None
+
+ return job.steps[0].station_id
+
+
+def get_completed_cutoff(job_id):
+ """
+ The true completed-unit count for a job: the highest unit_number
+ that has a Unit Completed event (ProductUnit.status == 'Completed').
+ Any unit numbered above this cutoff never finished every station
+ and is excluded from KPI math, but its raw events are still kept
+ in the CSV, flagged as discarded.
+ """
+ completed_unit_numbers = [
+ unit.unit_number
+ for unit in ProductUnit.query.filter_by(
+ job_id=job_id,
+ status="Completed",
+ ).all()
+ ]
+
+ if not completed_unit_numbers:
+ return 0
+
+ return max(completed_unit_numbers)
+
+
+def build_report_rows(job):
+ cutoff = get_completed_cutoff(job.id)
+ entry_station_id = get_entry_station_id(job)
+
+ rows = []
+
+ if job.activated_at:
+ rows.append(
+ {
+ "event": "Barcode Generated",
+ "unit_number": "",
+ "station_id": "",
+ "step_number": "",
+ "timestamp": job.activated_at.isoformat(),
+ "unit_status": "",
+ }
+ )
+
+ scan_events = (
+ ScanEvent.query.filter_by(job_id=job.id)
+ .order_by(ScanEvent.occurred_at)
+ .all()
+ )
+
+ first_arrival_by_unit = {}
+
+ for event in scan_events:
+ unit = event.product_unit
+ unit_status = (
+ "Completed"
+ if unit.unit_number <= cutoff
+ else "Discarded (Job Closed Early)"
+ )
+
+ if (
+ event.event_type == "arrival"
+ and unit.id not in first_arrival_by_unit
+ ):
+ first_arrival_by_unit[unit.id] = event
+
+ 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,
+ }
+ )
+
+ 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)"
+ )
+
+ rows.append(
+ {
+ "event": "Production Started",
+ "unit_number": unit.unit_number,
+ "station_id": event.station_id,
+ "step_number": "",
+ "timestamp": event.occurred_at.isoformat(),
+ "unit_status": 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()
+ )
+
+ unit_status = (
+ "Completed"
+ if unit.unit_number <= cutoff
+ else "Discarded (Job Closed Early)"
+ )
+
+ rows.append(
+ {
+ "event": "Unit Completed",
+ "unit_number": unit.unit_number,
+ "station_id": (
+ last_event.station_id
+ if last_event is not None
+ else ""
+ ),
+ "step_number": "",
+ "timestamp": (
+ last_event.occurred_at.isoformat()
+ if last_event is not None
+ else ""
+ ),
+ "unit_status": unit_status,
+ }
+ )
+
+ if job.finished_at:
+ rows.append(
+ {
+ "event": "Job Completed",
+ "unit_number": "",
+ "station_id": "",
+ "step_number": "",
+ "timestamp": job.finished_at.isoformat(),
+ "unit_status": "",
+ }
+ )
+
+ rows.sort(key=lambda row: row["timestamp"] or "")
+
+ return rows
+
+
+def generate_production_report(job):
+ rows = build_report_rows(job)
+
+ slug = slugify_job_name(job.name)
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
+ csv_filename = f"{slug}_{job.id}_{timestamp}_{uuid.uuid4().hex[:8]}.csv"
+
+ destination = report_upload_folder() / csv_filename
+
+ with open(destination, "w", newline="", encoding="utf-8") as handle:
+ writer = csv.DictWriter(
+ handle,
+ fieldnames=[
+ "event",
+ "unit_number",
+ "station_id",
+ "step_number",
+ "timestamp",
+ "unit_status",
+ ],
+ )
+ writer.writeheader()
+ writer.writerows(rows)
+
+ report = ProductionReport(
+ job_id=job.id,
+ job_name=job.name,
+ csv_filename=csv_filename,
+ )
+
+ db.session.add(report)
+
+ try:
+ db.session.commit()
+ except Exception:
+ db.session.rollback()
+ destination.unlink(missing_ok=True)
+ raise
+
+ return report
+
+
+def read_report_rows(report):
+ path = report_upload_folder() / report.csv_filename
+
+ if not path.exists():
+ return []
+
+ with open(path, newline="", encoding="utf-8") as handle:
+ return list(csv.DictReader(handle))
+
+
+def delete_production_report(report):
+ path = report_upload_folder() / report.csv_filename
+
+ db.session.delete(report)
+ db.session.commit()
+
+ path.unlink(missing_ok=True)
--
GitLab
From f2f5cb93f66b1eec9e1bd3a2eb3589d0b6f23708 Mon Sep 17 00:00:00 2001
From: Nameru Thapa
Date: Tue, 8 Sep 2026 11:58:22 +0200
Subject: [PATCH 2/4] Wire reporting, warehouse, and kiosk routes to updated
services
---
app/__init__.py | 14 ++++-
app/routes.py | 143 ++++++++++++++++++++++++++++++++++++++++++------
app/services.py | 96 +++++++++++++++++++++++++++++++-
3 files changed, 234 insertions(+), 19 deletions(-)
diff --git a/app/__init__.py b/app/__init__.py
index 784130b..8bf1dcb 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -21,6 +21,11 @@ def create_app(test_config=None):
/ "step_photos"
)
+ app.config["REPORT_UPLOAD_FOLDER"] = str(
+ Path(app.instance_path)
+ / "production_reports"
+ )
+
if test_config:
app.config.update(test_config)
@@ -31,6 +36,13 @@ def create_app(test_config=None):
exist_ok=True,
)
+ Path(
+ app.config["REPORT_UPLOAD_FOLDER"]
+ ).mkdir(
+ parents=True,
+ exist_ok=True,
+ )
+
db.init_app(app)
from app.models import Station
@@ -55,4 +67,4 @@ def create_app(test_config=None):
from app.routes import bp
app.register_blueprint(bp)
- return app
\ No newline at end of file
+ return app
diff --git a/app/routes.py b/app/routes.py
index 9a66285..45b12ab 100644
--- a/app/routes.py
+++ b/app/routes.py
@@ -1,11 +1,21 @@
-from flask import Blueprint, abort, redirect, render_template, request, url_for
+from flask import (
+ Blueprint,
+ abort,
+ redirect,
+ render_template,
+ request,
+ send_file,
+ url_for,
+)
from app import db
+from app.kpi_service import calculate_kpis, get_job_output_counts
from app.models import (
InstructionPhoto,
Job,
JobStep,
Part,
+ ProductionReport,
ProductUnit,
Station,
StationInventory,
@@ -18,6 +28,11 @@ from app.photo_service import (
instruction_photo_static_path,
resolve_instruction_photo,
)
+from app.report_service import (
+ delete_production_report,
+ read_report_rows,
+ report_upload_folder,
+)
from app.services import (
activate_job,
complete_current_step,
@@ -28,7 +43,9 @@ from app.services import (
get_current_unit_and_step,
get_or_create_part,
get_or_create_station_inventory,
+ mark_job_complete,
pause_job,
+ record_scan_arrival,
resume_job,
reuse_job,
scan_job_barcode,
@@ -338,6 +355,23 @@ def resume_job_route(job_id):
)
+@bp.route(
+ "/admin/jobs//mark-complete",
+ methods=["POST"],
+)
+def mark_complete_job_route(job_id):
+ job = db.get_or_404(Job, job_id)
+
+ try:
+ mark_job_complete(job)
+ except ValueError as error:
+ abort(409, description=str(error))
+
+ return redirect(
+ url_for("main.job_detail", job_id=job.id)
+ )
+
+
@bp.route(
"/admin/jobs//reuse",
methods=["POST"],
@@ -599,6 +633,82 @@ def delete_station_inventory_route(station_id, part_id):
return redirect(url_for("main.admin_inventory"))
+@bp.route("/admin/reporting")
+def admin_reporting():
+ jobs = Job.query.order_by(Job.id.desc()).all()
+
+ select_all = request.args.get("all") == "1"
+ checked_job_ids = request.args.getlist("job_ids")
+ has_selection_params = select_all or bool(checked_job_ids)
+
+ if select_all:
+ selected_job_ids = [job.id for job in jobs]
+ elif checked_job_ids:
+ selected_job_ids = [
+ int(value)
+ for value in checked_job_ids
+ if value.strip().isdigit()
+ ]
+ else:
+ selected_job_ids = [job.id for job in jobs[:5]]
+
+ kpis = calculate_kpis(selected_job_ids)
+ job_outputs = get_job_output_counts([job.id for job in jobs])
+
+ reports = ProductionReport.query.order_by(
+ ProductionReport.generated_at.desc()
+ ).all()
+
+ return render_template(
+ "admin_reporting.html",
+ jobs=jobs,
+ selected_job_ids=set(selected_job_ids),
+ select_all=select_all,
+ has_selection_params=has_selection_params,
+ kpis=kpis,
+ job_outputs=job_outputs,
+ reports=reports,
+ )
+
+
+@bp.route("/admin/reports//preview")
+def preview_report_route(report_id):
+ report = db.get_or_404(ProductionReport, report_id)
+ rows = read_report_rows(report)
+
+ return render_template(
+ "report_preview.html",
+ report=report,
+ rows=rows,
+ )
+
+
+@bp.route("/admin/reports//download")
+def download_report_route(report_id):
+ report = db.get_or_404(ProductionReport, report_id)
+ path = report_upload_folder() / report.csv_filename
+
+ if not path.exists():
+ abort(404, description="Report file not found.")
+
+ return send_file(
+ path,
+ as_attachment=True,
+ download_name=report.csv_filename,
+ )
+
+
+@bp.route(
+ "/admin/reports//delete",
+ methods=["POST"],
+)
+def delete_report_route(report_id):
+ report = db.get_or_404(ProductionReport, report_id)
+ delete_production_report(report)
+
+ return redirect(url_for("main.admin_reporting"))
+
+
@bp.route("/warehouse/requests")
def warehouse_requests():
transfer_requests = TransferRequest.query.order_by(
@@ -748,11 +858,21 @@ def worker_kiosk(station_id):
"completed_unit_next_step"
]
- if (
+ same_station_continues = (
completed_unit_next_step is not None
and completed_unit_next_step.station_id
- != completed_step.station_id
- ):
+ == completed_step.station_id
+ )
+
+ if same_station_continues:
+ # No rescan needed: show the next step
+ # at this same station immediately so
+ # the worker can press "Complete Step"
+ # again without scanning first.
+ step = completed_unit_next_step
+ current_unit = completed_unit
+
+ else:
transition_message = (
"Pass unit to next station."
)
@@ -760,24 +880,13 @@ def worker_kiosk(station_id):
scan_message = (
"Scan the Barcode for next unit."
)
- else:
- scan_message = (
- "Scan the Barcode to continue "
- "production."
- )
else:
- step, error = scan_job_barcode(
+ current_unit, step, error = record_scan_arrival(
entered_barcode,
normalized_station_id,
)
- if error is None:
- current_unit, step = get_current_unit_and_step(
- step.job,
- normalized_station_id,
- )
-
return render_template(
"worker_kiosk.html",
station=station,
@@ -790,4 +899,4 @@ def worker_kiosk(station_id):
completion_message=completion_message,
transition_message=transition_message,
scan_message=scan_message,
- )
\ No newline at end of file
+ )
diff --git a/app/services.py b/app/services.py
index 9b5508e..de10d0d 100644
--- a/app/services.py
+++ b/app/services.py
@@ -7,11 +7,13 @@ from app.models import (
JobStep,
Part,
ProductUnit,
+ ScanEvent,
StationInventory,
StepPart,
TransferRequest,
UnitStepProgress,
)
+from app.report_service import generate_production_report
def calculate_requirements(job):
@@ -267,6 +269,7 @@ def activate_job(job):
job.barcode = f"MES-{uuid.uuid4().hex[:12].upper()}"
job.status = "Active"
+ job.activated_at = datetime.now(timezone.utc)
existing_unit_numbers = {
unit.unit_number
@@ -349,6 +352,10 @@ def delete_job(job):
UnitStepProgress.product_unit_id.in_(unit_ids)
).delete(synchronize_session=False)
+ ScanEvent.query.filter_by(
+ job_id=job.id,
+ ).delete(synchronize_session=False)
+
ProductUnit.query.filter_by(
job_id=job.id,
).delete(synchronize_session=False)
@@ -381,6 +388,43 @@ def resume_job(job):
db.session.commit()
+def mark_job_complete(job):
+ """
+ Manually closes an Active or Paused job. The job's effective
+ quantity is frozen at whatever number of units actually finished
+ every station (the true completed-unit count) -- never the
+ original target. Data for units beyond that cutoff is kept (not
+ deleted) but excluded from KPI math; the auto-generated report
+ flags it as discarded.
+ """
+ if job.status not in ("Active", "Paused"):
+ raise ValueError(
+ "Only active or paused jobs can be marked complete."
+ )
+
+ completed_count = ProductUnit.query.filter_by(
+ job_id=job.id,
+ status="Completed",
+ ).count()
+
+ if completed_count == 0:
+ raise ValueError(
+ "No units have completed production yet; "
+ "nothing to mark complete."
+ )
+
+ job.quantity = completed_count
+ job.status = "Completed"
+ job.completed_manually = True
+ job.finished_at = datetime.now(timezone.utc)
+
+ db.session.commit()
+
+ generate_production_report(job)
+
+ return job
+
+
def fulfill_transfer_request(request_id, transferred_quantity):
transfer_request = db.session.get(
TransferRequest,
@@ -516,6 +560,42 @@ def scan_job_barcode(barcode, station_id):
return None, "This job is not ready at this station."
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.
+ """
+ step, error = scan_job_barcode(barcode, station_id)
+
+ if error is not None:
+ 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:
+ db.session.add(
+ ScanEvent(
+ job_id=step.job_id,
+ product_unit_id=unit.id,
+ station_id=normalized_station_id,
+ job_step_id=None,
+ event_type="arrival",
+ )
+ )
+ db.session.commit()
+
+ return unit, step, None
+
+
def calculate_remaining_part_requirement(
job,
station_id,
@@ -663,6 +743,16 @@ def complete_current_step(job, station_id):
progress.status = "Completed"
progress.completed_at = datetime.now(timezone.utc)
+ db.session.add(
+ ScanEvent(
+ job_id=job.id,
+ product_unit_id=current_unit.id,
+ station_id=current_step.station_id,
+ job_step_id=current_step.id,
+ event_type="step_completed",
+ )
+ )
+
db.session.flush()
completed_step_ids = {
@@ -697,6 +787,7 @@ def complete_current_step(job, station_id):
if job_completed:
job.status = "Completed"
+ job.finished_at = datetime.now(timezone.utc)
transfer_requests = create_remaining_material_requests(
job,
@@ -725,6 +816,9 @@ def complete_current_step(job, station_id):
db.session.commit()
+ if job_completed:
+ generate_production_report(job)
+
return (
{
"completed_unit": current_unit,
@@ -744,4 +838,4 @@ def complete_current_step(job, station_id):
"transfer_requests": transfer_requests,
},
None,
- )
\ No newline at end of file
+ )
--
GitLab
From 7c98c5ba9ef58cb1bdd81ea2f60fb4623186612e Mon Sep 17 00:00:00 2001
From: Nameru Thapa
Date: Tue, 8 Sep 2026 11:58:22 +0200
Subject: [PATCH 3/4] Fix nav scoping across admin/warehouse/kiosk pages and
redesign reporting dashboard with per-station KPI boxes
---
app/templates/admin_inventory.html | 4 +
app/templates/admin_jobs.html | 2 +
app/templates/admin_photos.html | 4 +
app/templates/admin_reporting.html | 200 +++++++++++++++++++++++++++++
app/templates/job_detail.html | 30 +++++
app/templates/kiosk_index.html | 4 +-
app/templates/report_preview.html | 58 +++++++++
7 files changed, 301 insertions(+), 1 deletion(-)
create mode 100644 app/templates/admin_reporting.html
create mode 100644 app/templates/report_preview.html
diff --git a/app/templates/admin_inventory.html b/app/templates/admin_inventory.html
index 34f8646..c93671e 100644
--- a/app/templates/admin_inventory.html
+++ b/app/templates/admin_inventory.html
@@ -30,6 +30,10 @@
Instruction Photos
+ |
+
+ Reporting
+
Station Inventory
diff --git a/app/templates/admin_jobs.html b/app/templates/admin_jobs.html
index 7cc2d7c..fc2d875 100644
--- a/app/templates/admin_jobs.html
+++ b/app/templates/admin_jobs.html
@@ -24,6 +24,8 @@
Station Inventory
|
Instruction Photos
+ |
+ Reporting
Production Jobs
diff --git a/app/templates/admin_photos.html b/app/templates/admin_photos.html
index 9b10917..3cc7e5b 100644
--- a/app/templates/admin_photos.html
+++ b/app/templates/admin_photos.html
@@ -30,6 +30,10 @@
Instruction Photos
+ |
+
+ Reporting
+
Instruction Photos
diff --git a/app/templates/admin_reporting.html b/app/templates/admin_reporting.html
new file mode 100644
index 0000000..65c5ca2
--- /dev/null
+++ b/app/templates/admin_reporting.html
@@ -0,0 +1,200 @@
+
+
+
+
+
+ MES - Reporting
+
+
+
+
+ The Digital Factory Game
+
+ Production Jobs
+ |
+ Station Inventory
+ |
+ Instruction Photos
+ |
+ Reporting
+
+
+ Reporting
+
+
+
OVERALL
+
+
+ Throughput:
+ {{ kpis.line_throughput_per_hour }} units/hour
+
+
+
+ Output:
+ {{ kpis.completed_units_counted }}
+
+
+
+ {% for station in kpis.stations %}
+
+
STATION {{ station.station_id }}
+
+
+ Processing Time:
+ {{ station.avg_processing_minutes }} minutes
+
+
+
+ Throughput:
+ {{ station.throughput_per_hour }} units/hour
+
+
+
+ Output:
+ {{ station.units_processed }}
+
+
+ {% if not station.is_entry_station %}
+
+ Waiting Time:
+ {{ station.avg_waiting_minutes }} minutes
+
+ {% endif %}
+
+ {% endfor %}
+
+ Select for KPIs
+
+
+
+ Select All Jobs
+
+ — currently showing KPIs for
+ {{ selected_job_ids|length }} of {{ jobs|length }} job(s).
+
+
+ {% if jobs %}
+
+ {% else %}
+ No production jobs exist yet.
+ {% endif %}
+
+ Production Reports
+
+ {% if reports %}
+
+
+
+
+
+ | Job Name |
+ Generated |
+ Actions |
+
+
+ {% for report in reports %}
+
+ | {{ report.job_name }} |
+ {{ report.generated_at }} |
+
+
+ Preview
+
+ |
+
+ Download
+
+ |
+
+ |
+
+ {% endfor %}
+
+
+
+ No reports match your search.
+
+
+
+
+ {% else %}
+ No production reports generated yet.
+ {% endif %}
+
+
diff --git a/app/templates/job_detail.html b/app/templates/job_detail.html
index 64699b8..713714e 100644
--- a/app/templates/job_detail.html
+++ b/app/templates/job_detail.html
@@ -20,6 +20,8 @@
Station Inventory
|
Instruction Photos
+ |
+ Reporting
{{ job.name }}
@@ -28,6 +30,14 @@
Production Quantity: {{ job.quantity }}
Low-stock Threshold: {{ job.threshold_percent }}%
+ {% if job.completed_manually %}
+
+ Note: this job was manually closed
+ before all units finished. Quantity reflects only the
+ units that actually completed every station.
+
+ {% endif %}
+
{% if job.status == "Draft" %}
+
+
{% elif job.status == "Paused" %}
+
+
{% endif %}
{% if transfer_requests %}
diff --git a/app/templates/kiosk_index.html b/app/templates/kiosk_index.html
index bdfd417..9458af8 100644
--- a/app/templates/kiosk_index.html
+++ b/app/templates/kiosk_index.html
@@ -20,6 +20,8 @@
|
Instruction Photos
|
+ Reporting
+ |
Warehouse Requests
|
Kiosk Directory
@@ -42,4 +44,4 @@
{% endfor %}