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"" + ) + + +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 0000000000000000000000000000000000000000..2a279ce2fd46f589ffce7453e1815fa1ca5f8b6b --- /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) diff --git a/app/routes.py b/app/routes.py index 9a66285d7b7d0844c45217c788ca065ea03ad09a..45b12ab1174de3b1a75eef185ae8a54bd9e05880 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 9b5508e05141f58b7576991aeeb80669195c4351..de10d0d58364914d83f030d18487fd86b821be60 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 + ) diff --git a/app/templates/admin_inventory.html b/app/templates/admin_inventory.html index 34f86468f715df89dae620c706a08d5833420950..c93671e3869bed756ba9a896ca35ed4bc53d241f 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 7cc2d7c8ac891acf08e9ca5a39b6100f3eaee6a3..fc2d875feafd80e01841908ec62e8aa6e341bd38 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 9b10917658543d3774ce8c1069a5d2f9e0d6275b..3cc7e5befa3865b1c8eb926a24b33151075965b6 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 0000000000000000000000000000000000000000..65c5ca220f4a29039dda7c9db625849378ccffff --- /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 %} +
+
+ + + + + + + + + + {% for job in jobs %} + + + + + + {% endfor %} +
JobOutputClick to include
+ {{ job.name }} — {{ job.status }} + ({{ job.quantity }} units) + + {{ job_outputs.get(job.id, 0) }} + + +
+ + + + +
+ + +
+ {% else %} +

No production jobs exist yet.

+ {% endif %} + +

Production Reports

+ + {% if reports %} +
+ + + + + + + + + + {% for report in reports %} + + + + + + {% endfor %} +
Job NameGeneratedActions
{{ report.job_name }}{{ report.generated_at }} + + Preview + + | + + Download + + | +
+ +
+
+ + + + +
+ {% else %} +

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 + | + 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 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 %} - \ No newline at end of file + diff --git a/app/templates/report_preview.html b/app/templates/report_preview.html new file mode 100644 index 0000000000000000000000000000000000000000..80c5c7d8dbd3b1d0452701cc44c21577db64648f --- /dev/null +++ b/app/templates/report_preview.html @@ -0,0 +1,58 @@ + + + + + + MES - Report Preview: {{ report.job_name }} + + + +

The Digital Factory Game

+

+ + ← Back to Reporting + +

+ +

Report: {{ report.job_name }}

+

Generated: {{ report.generated_at }}

+ +

+ + Download CSV + +

+ + {% if rows %} + + + + + + + + + + + {% for row in rows %} + + + + + + + + + {% endfor %} +
EventUnitStationStepTimestampUnit Status
{{ row.event }}{{ row.unit_number }}{{ row.station_id }}{{ row.step_number }}{{ row.timestamp }}{{ row.unit_status }}
+ {% else %} +

This report has no rows, or its file could not be found.

+ {% endif %} + + diff --git a/tests/test_reporting_feature.py b/tests/test_reporting_feature.py new file mode 100644 index 0000000000000000000000000000000000000000..f7b7fb1d680e4fc11863d98a0929c6015f9ceaa1 --- /dev/null +++ b/tests/test_reporting_feature.py @@ -0,0 +1,314 @@ +import pytest + +from app import create_app, db +from app.kpi_service import calculate_kpis +from app.models import ( + Job, + JobStep, + Part, + ProductionReport, + ProductUnit, + ScanEvent, + StationInventory, + StepPart, +) +from app.report_service import read_report_rows +from app.services import ( + activate_job, + complete_current_step, + delete_job, + mark_job_complete, + record_scan_arrival, +) + + +@pytest.fixture +def app(tmp_path): + test_app = create_app( + { + "TESTING": True, + "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", + "REPORT_UPLOAD_FOLDER": str(tmp_path / "reports"), + } + ) + + yield test_app + + +@pytest.fixture +def client(app): + return app.test_client() + + +def create_two_step_same_station_job(): + """One station, two steps -- to test the no-rescan kiosk fix.""" + part = Part(name="Bolt", unit="pcs") + + job = Job( + name="Reporting Test Job", + quantity=1, + threshold_percent=0.0, + status="Draft", + ) + + db.session.add_all([part, job]) + db.session.flush() + + step_1 = JobStep( + job_id=job.id, + step_number=1, + station_id="ST-01", + instruction="Attach bolt", + ) + step_2 = JobStep( + job_id=job.id, + step_number=2, + station_id="ST-01", + instruction="Tighten bolt", + ) + + db.session.add_all([step_1, step_2]) + db.session.flush() + + db.session.add( + StepPart(step_id=step_1.id, part_id=part.id, quantity=1) + ) + db.session.add( + StationInventory(station_id="ST-01", part_id=part.id, quantity=5) + ) + + db.session.commit() + + activate_job(job) + + return job.id + + +def create_two_station_job(quantity): + """Two stations, one step each -- for cutoff/KPI tests.""" + job = Job( + name="Cutoff Test Job", + quantity=quantity, + threshold_percent=0.0, + status="Draft", + ) + + db.session.add(job) + db.session.flush() + + step_1 = JobStep( + job_id=job.id, + step_number=1, + station_id="ST-01", + instruction="Step at station 1", + ) + step_2 = JobStep( + job_id=job.id, + step_number=2, + station_id="ST-02", + instruction="Step at station 2", + ) + + db.session.add_all([step_1, step_2]) + db.session.commit() + + activate_job(job) + + return job.id + + +def test_kiosk_does_not_require_rescan_within_same_station(app): + """ + Core bug fix: two steps at the SAME station should be completable + with one scan and two 'Complete Step' presses -- no second scan. + """ + with app.app_context(): + job_id = create_two_step_same_station_job() + job = db.session.get(Job, job_id) + + unit, step, error = record_scan_arrival( + job.barcode, "ST-01" + ) + assert error is None + assert step.step_number == 1 + + arrivals_after_scan = ScanEvent.query.filter_by( + event_type="arrival" + ).count() + assert arrivals_after_scan == 1 + + result, error = complete_current_step(job, "ST-01") + assert error is None + assert result["unit_completed"] is False + + next_step = result["completed_unit_next_step"] + assert next_step is not None + assert next_step.station_id == "ST-01" + assert next_step.step_number == 2 + + result2, error2 = complete_current_step(job, "ST-01") + assert error2 is None + assert result2["unit_completed"] is True + assert result2["job_completed"] is True + + arrivals_final = ScanEvent.query.filter_by( + event_type="arrival" + ).count() + assert arrivals_final == 1, ( + "Only one arrival scan should have been logged " + "even though two steps were completed at the " + "same station" + ) + + step_completions = ScanEvent.query.filter_by( + event_type="step_completed" + ).count() + assert step_completions == 2 + + +def test_activate_sets_activated_at_and_completion_sets_finished_at(app): + with app.app_context(): + job_id = create_two_step_same_station_job() + job = db.session.get(Job, job_id) + + assert job.activated_at is not None + assert job.finished_at is None + + record_scan_arrival(job.barcode, "ST-01") + complete_current_step(job, "ST-01") + complete_current_step(job, "ST-01") + + job = db.session.get(Job, job_id) + assert job.status == "Completed" + assert job.finished_at is not None + + report = ProductionReport.query.filter_by( + job_id=job.id + ).first() + assert report is not None + assert report.job_name == job.name + + +def test_mark_job_complete_freezes_quantity_at_true_cutoff(app): + """ + Mirrors the exact scenario discussed: target quantity is higher + than what actually finished. Mark Complete should freeze + job.quantity at the true completed count and flag the rest as + discarded in the report, without deleting any raw event data. + """ + with app.app_context(): + job_id = create_two_station_job(quantity=5) + job = db.session.get(Job, job_id) + + for unit_index in range(3): + record_scan_arrival(job.barcode, "ST-01") + complete_current_step(job, "ST-01") + record_scan_arrival(job.barcode, "ST-02") + complete_current_step(job, "ST-02") + + record_scan_arrival(job.barcode, "ST-01") + complete_current_step(job, "ST-01") + + completed_before = ProductUnit.query.filter_by( + job_id=job.id, status="Completed" + ).count() + assert completed_before == 3 + + mark_job_complete(job) + + job = db.session.get(Job, job_id) + assert job.status == "Completed" + assert job.completed_manually is True + assert job.quantity == 3 + + report = ProductionReport.query.filter_by( + job_id=job.id + ).first() + assert report is not None + + rows = read_report_rows(report) + assert len(rows) > 0 + + discarded_rows = [ + row + for row in rows + if row["unit_status"] == "Discarded (Job Closed Early)" + ] + assert len(discarded_rows) > 0, ( + "Unit 4's partial progress at ST-01 should be kept " + "in the CSV but flagged as discarded" + ) + + completed_rows = [ + row + for row in rows + if row["unit_status"] == "Completed" + ] + assert len(completed_rows) > 0 + + +def test_kpis_exclude_discarded_units(app): + with app.app_context(): + job_id = create_two_station_job(quantity=3) + job = db.session.get(Job, job_id) + + for unit_index in range(2): + record_scan_arrival(job.barcode, "ST-01") + complete_current_step(job, "ST-01") + record_scan_arrival(job.barcode, "ST-02") + complete_current_step(job, "ST-02") + + record_scan_arrival(job.barcode, "ST-01") + complete_current_step(job, "ST-01") + + mark_job_complete(job) + + job = db.session.get(Job, job_id) + kpis = calculate_kpis([job.id]) + + assert kpis is not None + assert kpis["completed_units_counted"] == 2 + + station_ids = {s["station_id"] for s in kpis["stations"]} + assert station_ids == {"ST-01", "ST-02"} + + entry_station = next( + s for s in kpis["stations"] if s["station_id"] == "ST-01" + ) + assert entry_station["avg_waiting_minutes"] == 0.0 + + for station in kpis["stations"]: + assert station["units_processed"] == 2 + + +def test_report_survives_job_deletion(app): + with app.app_context(): + job_id = create_two_step_same_station_job() + job = db.session.get(Job, job_id) + + record_scan_arrival(job.barcode, "ST-01") + complete_current_step(job, "ST-01") + complete_current_step(job, "ST-01") + + job = db.session.get(Job, job_id) + assert job.status == "Completed" + + report = ProductionReport.query.filter_by( + job_id=job.id + ).first() + assert report is not None + report_id = report.id + csv_filename = report.csv_filename + + delete_job(job) + + assert db.session.get(Job, job_id) is None + + surviving_report = db.session.get( + ProductionReport, report_id + ) + assert surviving_report is not None + assert surviving_report.csv_filename == csv_filename + assert surviving_report.job_id is None or ( + db.session.get(Job, surviving_report.job_id) is None + )