diff --git a/app/models.py b/app/models.py index 1c173720d8df51fee070c4bf6ba9a73c6f9558e0..4c0be23c4b86ff6faa71083de5e0c721fec50dfc 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 diff --git a/app/routes.py b/app/routes.py index 0c673de3496b68f7e6e13180c4758e4fbe430e35..455b4701fb70b216b92f2c40f9552344fd53464e 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/services.py b/app/services.py index e103a1809d8c6d0cce7888553d8ac825f487996c..dc23177ca8f4cdfaebe7823ff42324180add8526 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/app/templates/worker_kiosk.html b/app/templates/worker_kiosk.html index 29f870f956a42f0ed93f2215e6f777869fd9606f..469684b4c537dc99c99a6469cd24d757f4d44e86 100644 --- a/app/templates/worker_kiosk.html +++ b/app/templates/worker_kiosk.html @@ -5,15 +5,17 @@ Worker Kiosk - +

Worker Kiosk

+ +


+ + + + +

{% endif %} \ No newline at end of file diff --git a/tests/test_barcode_scanning.py b/tests/test_barcode_scanning.py index 94625902ea3abe79c820ad74e9e1053916f10b79..d3c3c3e13bc7b2336ef0dcd32da15dabc85ca518 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 0000000000000000000000000000000000000000..7613426a18fea251c521a3156576bae3d8ba441a --- /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