diff --git a/app/__init__.py b/app/__init__.py
index 784130b854e364484efe0c4f7534466b8f140f5c..8bf1dcb5ef6f449d2e9894dd22fb4302bc5028d1 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/kpi_service.py b/app/kpi_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..8b36f412e0439090a0cebc70adc33d72f044d6cd
--- /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 40125e8fac935ba7e1482e075ff773fa643c1374..d91009980945df51a428f5ac1cd258318e48c942 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"
+ Production Jobs + | + Station Inventory + | + Instruction Photos + | + Reporting +
+ ++ Throughput: + {{ kpis.line_throughput_per_hour }} units/hour +
+ ++ Output: + {{ kpis.completed_units_counted }} +
++ 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 %} ++ + 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 %} + +| Job Name | +Generated | +Actions | +
|---|---|---|
| {{ report.job_name }} | +{{ report.generated_at }} | ++ + Preview + + | + + Download + + | + + | +
No production reports generated yet.
+ {% endif %} + + diff --git a/app/templates/job_detail.html b/app/templates/job_detail.html index 64699b8b728718ecc3b37fbc86ba0a671b9c5198..713714e2e8dd2f7f0f80d4106c6b8a10bc91514d 100644 --- a/app/templates/job_detail.html +++ b/app/templates/job_detail.html @@ -20,6 +20,8 @@ Station Inventory | Instruction Photos + | + ReportingProduction 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 bdfd417f6f34453a171f3da6cf78f85b826da662..9458af8331ad85c330ce11fd71d07cf4c8cca434 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 %}