From 8a17b2cd91789f635fedfe1f9944babfc68d430e Mon Sep 17 00:00:00 2001
From: Nameru Thapa
Date: Mon, 10 Aug 2026 11:24:04 +0200
Subject: [PATCH 1/3] feat: add product unit step progress model
---
app/models.py | 37 +++++++++++++++++++++++++++++++++++++
1 file changed, 37 insertions(+)
diff --git a/app/models.py b/app/models.py
index 1c17372..4c0be23 100644
--- a/app/models.py
+++ b/app/models.py
@@ -190,4 +190,41 @@ class ProductUnit(db.Model):
"unit_number",
name="uq_job_unit_number",
),
+ )
+
+
+class UnitStepProgress(db.Model):
+ __tablename__ = "unit_step_progress"
+
+ id = db.Column(db.Integer, primary_key=True)
+
+ product_unit_id = db.Column(
+ db.Integer,
+ db.ForeignKey("product_units.id"),
+ nullable=False,
+ )
+
+ job_step_id = db.Column(
+ db.Integer,
+ db.ForeignKey("job_steps.id"),
+ nullable=False,
+ )
+
+ status = db.Column(
+ db.String(20),
+ nullable=False,
+ default="Pending",
+ )
+
+ completed_at = db.Column(db.DateTime)
+
+ product_unit = db.relationship("ProductUnit")
+ job_step = db.relationship("JobStep")
+
+ __table_args__ = (
+ db.UniqueConstraint(
+ "product_unit_id",
+ "job_step_id",
+ name="uq_unit_step_progress",
+ ),
)
\ No newline at end of file
--
GitLab
From 72027bb3777fdd70a7e2b94bb4dfd332ca33fc63 Mon Sep 17 00:00:00 2001
From: Nameru Thapa
Date: Mon, 10 Aug 2026 11:38:31 +0200
Subject: [PATCH 2/3] feat: implement FIFO step completion and stock checks
---
app/services.py | 199 ++++++++++++++++++++++++++++++-
tests/test_barcode_scanning.py | 17 ++-
tests/test_step_completion.py | 209 +++++++++++++++++++++++++++++++++
3 files changed, 420 insertions(+), 5 deletions(-)
create mode 100644 tests/test_step_completion.py
diff --git a/app/services.py b/app/services.py
index e103a18..dc23177 100644
--- a/app/services.py
+++ b/app/services.py
@@ -7,6 +7,7 @@ from app.models import (
ProductUnit,
StationInventory,
TransferRequest,
+ UnitStepProgress,
)
@@ -159,6 +160,25 @@ def fulfill_transfer_request(request_id, transferred_quantity):
db.session.commit()
return transfer_request
+def get_current_unit_and_step(job):
+ units = ProductUnit.query.filter_by(
+ job_id=job.id,
+ ).order_by(ProductUnit.unit_number).all()
+
+ for unit in units:
+ completed_step_ids = {
+ progress.job_step_id
+ for progress in UnitStepProgress.query.filter_by(
+ product_unit_id=unit.id,
+ status="Completed",
+ ).all()
+ }
+
+ for step in job.steps:
+ if step.id not in completed_step_ids:
+ return unit, step
+
+ return None, None
def scan_job_barcode(barcode, station_id):
normalized_barcode = (barcode or "").strip().upper()
normalized_station_id = (station_id or "").strip().upper()
@@ -179,12 +199,183 @@ def scan_job_barcode(barcode, station_id):
if job.status != "Active":
return None, "This production job is not active."
- current_step = job.steps[0] if job.steps else None
-
- if current_step is None:
+ if not job.steps:
return None, "This production job has no production steps."
+ current_unit, current_step = get_current_unit_and_step(job)
+
+ if current_unit is None or current_step is None:
+ return None, "All product units are complete."
+
+ if current_step.station_id != normalized_station_id:
+ return None, "This job is not ready at this station."
+
+ return current_step, None
+def calculate_remaining_part_requirement(
+ job,
+ station_id,
+ part_id,
+):
+ remaining_quantity = 0
+
+ units = ProductUnit.query.filter_by(
+ job_id=job.id,
+ ).all()
+
+ for unit in units:
+ completed_step_ids = {
+ progress.job_step_id
+ for progress in UnitStepProgress.query.filter_by(
+ product_unit_id=unit.id,
+ status="Completed",
+ ).all()
+ }
+
+ for step in job.steps:
+ if step.id in completed_step_ids:
+ continue
+
+ if step.station_id != station_id:
+ continue
+
+ for usage in step.parts:
+ if usage.part_id == part_id:
+ remaining_quantity += usage.quantity
+
+ return remaining_quantity
+
+
+def create_remaining_material_requests(
+ job,
+ station_id,
+ part_ids,
+):
+ created_requests = []
+
+ for part_id in part_ids:
+ inventory = StationInventory.query.filter_by(
+ station_id=station_id,
+ part_id=part_id,
+ ).first()
+
+ available_quantity = (
+ inventory.quantity
+ if inventory is not None
+ else 0
+ )
+
+ remaining_quantity = calculate_remaining_part_requirement(
+ job,
+ station_id,
+ part_id,
+ )
+
+ if available_quantity >= remaining_quantity:
+ continue
+
+ existing_request = TransferRequest.query.filter(
+ TransferRequest.job_id == job.id,
+ TransferRequest.station_id == station_id,
+ TransferRequest.part_id == part_id,
+ TransferRequest.status.in_(
+ ["Pending", "Partially Fulfilled"]
+ ),
+ ).first()
+
+ if existing_request is not None:
+ continue
+
+ transfer_request = TransferRequest(
+ job_id=job.id,
+ station_id=station_id,
+ part_id=part_id,
+ requested_quantity=(
+ remaining_quantity - available_quantity
+ ),
+ status="Pending",
+ )
+
+ db.session.add(transfer_request)
+ created_requests.append(transfer_request)
+
+ return created_requests
+def complete_current_step(job, station_id):
+ normalized_station_id = (station_id or "").strip().upper()
+
+ if job.status != "Active":
+ return None, "This production job is not active."
+
+ current_unit, current_step = get_current_unit_and_step(job)
+
+ if current_unit is None or current_step is None:
+ return None, "All product units are complete."
+
if current_step.station_id != normalized_station_id:
return None, "This job is not ready at this station."
- return current_step, None
\ No newline at end of file
+ inventory_usages = []
+
+ for usage in current_step.parts:
+ inventory = StationInventory.query.filter_by(
+ station_id=current_step.station_id,
+ part_id=usage.part_id,
+ ).first()
+
+ available_quantity = (
+ inventory.quantity
+ if inventory is not None
+ else 0
+ )
+
+ if available_quantity < usage.quantity:
+ return (
+ None,
+ f"Insufficient station inventory for {usage.part.name}.",
+ )
+
+ inventory_usages.append((inventory, usage))
+
+ for inventory, usage in inventory_usages:
+ inventory.quantity -= usage.quantity
+
+ progress = UnitStepProgress.query.filter_by(
+ product_unit_id=current_unit.id,
+ job_step_id=current_step.id,
+ ).first()
+
+ if progress is None:
+ progress = UnitStepProgress(
+ product_unit_id=current_unit.id,
+ job_step_id=current_step.id,
+ )
+ db.session.add(progress)
+
+ progress.status = "Completed"
+ progress.completed_at = datetime.now(timezone.utc)
+ current_unit.status = "In Progress"
+
+ db.session.flush()
+
+ transfer_requests = create_remaining_material_requests(
+ job,
+ current_step.station_id,
+ {
+ usage.part_id
+ for usage in current_step.parts
+ },
+ )
+
+ db.session.commit()
+
+ next_unit, next_step = get_current_unit_and_step(job)
+
+ return (
+ {
+ "completed_unit": current_unit,
+ "completed_step": current_step,
+ "next_unit": next_unit,
+ "next_step": next_step,
+ "transfer_requests": transfer_requests,
+ },
+ None,
+ )
\ No newline at end of file
diff --git a/tests/test_barcode_scanning.py b/tests/test_barcode_scanning.py
index 9462590..d3c3c3e 100644
--- a/tests/test_barcode_scanning.py
+++ b/tests/test_barcode_scanning.py
@@ -1,7 +1,7 @@
import pytest
from app import create_app, db
-from app.models import Job, JobStep, Part, StepPart
+from app.models import Job, JobStep, Part, ProductUnit, StepPart
from app.services import scan_job_barcode
@@ -34,6 +34,21 @@ def create_active_job():
db.session.add_all([part, job])
db.session.flush()
+ db.session.add_all(
+ [
+ ProductUnit(
+ job_id=job.id,
+ unit_number=1,
+ status="Pending",
+ ),
+ ProductUnit(
+ job_id=job.id,
+ unit_number=2,
+ status="Pending",
+ ),
+ ]
+ )
+
step = JobStep(
job_id=job.id,
step_number=1,
diff --git a/tests/test_step_completion.py b/tests/test_step_completion.py
new file mode 100644
index 0000000..7613426
--- /dev/null
+++ b/tests/test_step_completion.py
@@ -0,0 +1,209 @@
+import pytest
+
+from app import create_app, db
+from app.models import (
+ Job,
+ JobStep,
+ Part,
+ ProductUnit,
+ StationInventory,
+ StepPart,
+ TransferRequest,
+ UnitStepProgress,
+)
+from app.services import (
+ complete_current_step,
+ scan_job_barcode,
+)
+
+
+@pytest.fixture
+def app():
+ test_app = create_app(
+ {
+ "TESTING": True,
+ "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
+ }
+ )
+
+ yield test_app
+
+
+def create_step_completion_job():
+ part = Part(
+ name="Part A",
+ unit="pcs",
+ )
+
+ job = Job(
+ name="Day 4 Step Test",
+ quantity=2,
+ threshold_percent=20.0,
+ status="Active",
+ barcode="MES-DAY4-0001",
+ )
+
+ db.session.add_all([part, job])
+ db.session.flush()
+
+ unit_1 = ProductUnit(
+ job_id=job.id,
+ unit_number=1,
+ status="Pending",
+ )
+
+ unit_2 = ProductUnit(
+ job_id=job.id,
+ unit_number=2,
+ status="Pending",
+ )
+
+ step_1 = JobStep(
+ job_id=job.id,
+ step_number=1,
+ station_id="ST-01",
+ instruction="Attach Part A",
+ photo_url="step-1.jpg",
+ )
+
+ step_2 = JobStep(
+ job_id=job.id,
+ step_number=2,
+ station_id="ST-01",
+ instruction="Inspect Part A",
+ photo_url="step-2.jpg",
+ )
+
+ db.session.add_all([unit_1, unit_2, step_1, step_2])
+ db.session.flush()
+
+ db.session.add_all(
+ [
+ StepPart(
+ step_id=step_1.id,
+ part_id=part.id,
+ quantity=2,
+ ),
+ StepPart(
+ step_id=step_2.id,
+ part_id=part.id,
+ quantity=1,
+ ),
+ StationInventory(
+ station_id="ST-01",
+ part_id=part.id,
+ quantity=10,
+ ),
+ ]
+ )
+
+ db.session.commit()
+
+ return {
+ "job_id": job.id,
+ "unit_1_id": unit_1.id,
+ "step_1_id": step_1.id,
+ "step_2_id": step_2.id,
+ "part_id": part.id,
+ }
+
+
+def test_tc10_complete_step_records_time_and_deducts_parts(app):
+ with app.app_context():
+ ids = create_step_completion_job()
+ job = db.session.get(Job, ids["job_id"])
+
+ result, error = complete_current_step(
+ job,
+ "ST-01",
+ )
+
+ assert error is None
+ assert result is not None
+
+ progress = UnitStepProgress.query.filter_by(
+ product_unit_id=ids["unit_1_id"],
+ job_step_id=ids["step_1_id"],
+ ).first()
+
+ assert progress is not None
+ assert progress.status == "Completed"
+ assert progress.completed_at is not None
+
+ inventory = StationInventory.query.filter_by(
+ station_id="ST-01",
+ part_id=ids["part_id"],
+ ).first()
+
+ assert inventory.quantity == 8
+
+ unit = db.session.get(
+ ProductUnit,
+ ids["unit_1_id"],
+ )
+
+ assert unit.status == "In Progress"
+
+
+def test_tc11_next_step_at_same_station_is_displayed(app):
+ with app.app_context():
+ ids = create_step_completion_job()
+ job = db.session.get(Job, ids["job_id"])
+
+ result, error = complete_current_step(
+ job,
+ "ST-01",
+ )
+
+ assert error is None
+ assert result["next_unit"].id == ids["unit_1_id"]
+ assert result["next_step"].id == ids["step_2_id"]
+
+ step, scan_error = scan_job_barcode(
+ "MES-DAY4-0001",
+ "ST-01",
+ )
+
+ assert scan_error is None
+ assert step.id == ids["step_2_id"]
+ assert step.step_number == 2
+ assert step.instruction == "Inspect Part A"
+def test_tc12_low_stock_creates_one_transfer_request(app):
+ with app.app_context():
+ ids = create_step_completion_job()
+
+ inventory = StationInventory.query.filter_by(
+ station_id="ST-01",
+ part_id=ids["part_id"],
+ ).first()
+
+ inventory.quantity = 3
+ db.session.commit()
+
+ job = db.session.get(Job, ids["job_id"])
+
+ result, error = complete_current_step(
+ job,
+ "ST-01",
+ )
+
+ assert error is None
+ assert result is not None
+ assert inventory.quantity == 1
+
+ transfer_request = TransferRequest.query.one()
+
+ assert transfer_request.job_id == job.id
+ assert transfer_request.station_id == "ST-01"
+ assert transfer_request.part_id == ids["part_id"]
+ assert transfer_request.requested_quantity == 3
+ assert transfer_request.status == "Pending"
+
+ second_result, second_error = complete_current_step(
+ job,
+ "ST-01",
+ )
+
+ assert second_error is None
+ assert second_result is not None
+ assert TransferRequest.query.count() == 1
--
GitLab
From 4d11ce1158547ef6041d84493a7d238d6de9fc40 Mon Sep 17 00:00:00 2001
From: Nameru Thapa
Date: Mon, 10 Aug 2026 11:55:17 +0200
Subject: [PATCH 3/3] feat: add simulated foot pedal workflow
---
app/routes.py | 68 ++++++++++++++++++++++++++++++---
app/templates/worker_kiosk.html | 53 +++++++++++++++++++++++--
2 files changed, 112 insertions(+), 9 deletions(-)
diff --git a/app/routes.py b/app/routes.py
index 0c673de..455b470 100644
--- a/app/routes.py
+++ b/app/routes.py
@@ -13,12 +13,13 @@ from app.models import (
)
from app.services import (
activate_job,
+ complete_current_step,
fulfill_transfer_request,
+ get_current_unit_and_step,
scan_job_barcode,
set_station_inventory,
)
-
bp = Blueprint("main", __name__)
@@ -218,7 +219,10 @@ def worker_kiosk():
selected_station_id = ""
entered_barcode = ""
step = None
+ current_unit = None
error = None
+ message = None
+ stock_message = None
if request.method == "POST":
selected_station_id = request.form.get(
@@ -231,10 +235,61 @@ def worker_kiosk():
"",
).strip()
- step, error = scan_job_barcode(
- entered_barcode,
- selected_station_id,
- )
+ action = request.form.get("action", "scan")
+
+ if action == "complete":
+ normalized_barcode = entered_barcode.upper()
+
+ job = Job.query.filter_by(
+ barcode=normalized_barcode,
+ ).first()
+
+ if job is None:
+ error = "Barcode not recognized."
+ else:
+ result, error = complete_current_step(
+ job,
+ selected_station_id,
+ )
+
+ if error is None:
+ completed_unit = result["completed_unit"]
+ completed_step = result["completed_step"]
+
+ message = (
+ f"Unit {completed_unit.unit_number}, "
+ f"Step {completed_step.step_number} completed."
+ )
+
+ if result["transfer_requests"]:
+ stock_message = (
+ "Station stock is below the remaining "
+ "production requirement. A transfer "
+ "request was created."
+ )
+
+ next_unit = result["next_unit"]
+ next_step = result["next_step"]
+
+ if (
+ next_unit is not None
+ and next_step is not None
+ and next_step.station_id
+ == selected_station_id
+ ):
+ current_unit = next_unit
+ step = next_step
+
+ else:
+ step, error = scan_job_barcode(
+ entered_barcode,
+ selected_station_id,
+ )
+
+ if error is None:
+ current_unit, step = get_current_unit_and_step(
+ step.job
+ )
return render_template(
"worker_kiosk.html",
@@ -242,5 +297,8 @@ def worker_kiosk():
selected_station_id=selected_station_id,
entered_barcode=entered_barcode,
step=step,
+ current_unit=current_unit,
error=error,
+ message=message,
+ stock_message=stock_message,
)
\ No newline at end of file
diff --git a/app/templates/worker_kiosk.html b/app/templates/worker_kiosk.html
index 29f870f..469684b 100644
--- a/app/templates/worker_kiosk.html
+++ b/app/templates/worker_kiosk.html
@@ -5,15 +5,17 @@
Worker Kiosk
-
+
Worker Kiosk
{% if error %}
- Error: {{ error }}
+
+ Error: {{ error }}
+
+ {% endif %}
+
+ {% if message %}
+
+ {{ message }}
+
{% endif %}
- {% if step %}
+ {% if stock_message %}
+
+ Material Alert:
+ {{ stock_message }}
+
+ {% endif %}
+
+ {% if step and current_unit %}
{{ step.job.name }}
@@ -59,6 +76,12 @@
{{ step.job.barcode }}
+
+ Product Unit:
+ {{ current_unit.unit_number }}
+ of {{ step.job.quantity }}
+
+
Station:
{{ step.station.name }}
@@ -91,6 +114,28 @@
{% else %}
No parts required for this step.
{% endif %}
+
+
{% endif %}