diff --git a/app/routes.py b/app/routes.py index 90aff62d20b1d9e676854844a7ae18e3561399c6..374cacaff12201ac91850fcab90d7bea677560a1 100644 --- a/app/routes.py +++ b/app/routes.py @@ -387,6 +387,7 @@ def worker_kiosk(): stock_message = None completion_message = None transition_message = None + scan_message = None if request.method == "POST": selected_station_id = request.form.get( @@ -432,9 +433,6 @@ def worker_kiosk(): "request was created." ) - next_unit = result["next_unit"] - next_step = result["next_step"] - completed_count = ProductUnit.query.filter_by( job_id=completed_unit.job_id, status="Completed", @@ -442,46 +440,54 @@ def worker_kiosk(): if result["job_completed"]: completion_message = ( - "Product Complete: Unit " - f"{completed_unit.unit_number} " - "is completed. Move this unit to " - "Finished Goods. Production Complete: " + "Production Complete: " f"all {completed_step.job.quantity} " "product units are completed." ) + + transition_message = ( + "Pass unit to Finished Goods Section." + ) + elif result["unit_completed"]: completion_message = ( "Product Complete: Unit " f"{completed_unit.unit_number} " - "is completed. Move this unit to " - "Finished Goods. Production Progress: " + "is completed. Production Progress: " f"{completed_count} of " f"{completed_step.job.quantity} " "units completed." ) - if ( - next_unit is not None - and next_step is not None - ): + transition_message = ( + "Pass unit to Finished Goods Section." + ) + + scan_message = ( + "Scan the Barcode for next unit." + ) + + else: + completed_unit_next_step = result[ + "completed_unit_next_step" + ] + if ( - next_step.station_id - == selected_station_id + completed_unit_next_step is not None + and completed_unit_next_step.station_id + != completed_step.station_id ): - current_unit = next_unit - step = next_step - elif result["unit_completed"]: transition_message = ( - "Next Unit " - f"{next_unit.unit_number} starts at " - f"{next_step.station.name}." + "Pass unit to next station." + ) + + scan_message = ( + "Scan the Barcode for next unit." ) else: - transition_message = ( - "Pass Unit " - f"{completed_unit.unit_number} " - "to Next Station: " - f"{next_step.station.name}." + scan_message = ( + "Scan the Barcode to continue " + "production." ) else: @@ -492,7 +498,8 @@ def worker_kiosk(): if error is None: current_unit, step = get_current_unit_and_step( - step.job + step.job, + selected_station_id, ) return render_template( @@ -507,4 +514,5 @@ def worker_kiosk(): stock_message=stock_message, 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 bd26665e1773f275dfacd30a014ebfac556b0669..f4b5e445b6186c005dabc5dc725c117ad3ac1f51 100644 --- a/app/services.py +++ b/app/services.py @@ -189,25 +189,48 @@ def fulfill_transfer_request(request_id, transferred_quantity): db.session.commit() return transfer_request -def get_current_unit_and_step(job): +def get_next_incomplete_step(unit, job): + 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 step + + return None + + +def get_current_unit_and_step(job, station_id=None): + normalized_station_id = ( + (station_id or "").strip().upper() + if station_id is not None + else None + ) + 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() - } + next_step = get_next_incomplete_step(unit, job) - for step in job.steps: - if step.id not in completed_step_ids: - return unit, step + if next_step is None: + continue + + if ( + normalized_station_id is None + or next_step.station_id == normalized_station_id + ): + return unit, next_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() @@ -234,12 +257,17 @@ def scan_job_barcode(barcode, station_id): if not job.steps: return None, "This production job has no production steps." - current_unit, current_step = get_current_unit_and_step(job) + current_unit, current_step = get_current_unit_and_step( + job, + normalized_station_id, + ) if current_unit is None or current_step is None: - return None, "All product units are complete." + remaining_unit, remaining_step = get_current_unit_and_step(job) + + if remaining_unit is None or remaining_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 @@ -337,12 +365,17 @@ def complete_current_step(job, station_id): if job.status != "Active": return None, "This production job is not active." - current_unit, current_step = get_current_unit_and_step(job) + current_unit, current_step = get_current_unit_and_step( + job, + normalized_station_id, + ) if current_unit is None or current_step is None: - return None, "All product units are complete." + remaining_unit, remaining_step = get_current_unit_and_step(job) + + if remaining_unit is None or remaining_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." inventory_usages = [] @@ -429,14 +462,29 @@ def complete_current_step(job, station_id): }, ) - db.session.commit() + db.session.flush() + + completed_unit_next_step = get_next_incomplete_step( + current_unit, + job, + ) next_unit, next_step = get_current_unit_and_step(job) + station_next_unit, station_next_step = ( + get_current_unit_and_step( + job, + normalized_station_id, + ) + ) + + db.session.commit() + return ( - { + { "completed_unit": current_unit, "completed_step": current_step, + "completed_unit_next_step": completed_unit_next_step, "next_unit": next_unit, "next_step": next_step, "next_station_id": ( @@ -444,6 +492,8 @@ def complete_current_step(job, station_id): if next_step is not None else None ), + "station_next_unit": station_next_unit, + "station_next_step": station_next_step, "unit_completed": unit_completed, "job_completed": job_completed, "transfer_requests": transfer_requests, diff --git a/app/templates/worker_kiosk.html b/app/templates/worker_kiosk.html index 3887dcf203b960d992966ce4fd7ba9c994a525fc..c8348b9af4e414a28aa69f445f4516daba90891f 100644 --- a/app/templates/worker_kiosk.html +++ b/app/templates/worker_kiosk.html @@ -62,7 +62,7 @@ {% endif %} {% if message %} -

+

{{ message }}

{% endif %} @@ -73,14 +73,23 @@ {{ stock_message }}

{% endif %} - {% if completion_message %} + + {% if completion_message %}

{{ completion_message }}

{% endif %} {% if transition_message %} -

{{ transition_message }}

+

+ {{ transition_message }} +

+ {% endif %} + + {% if scan_message %} +

+ {{ scan_message }} +

{% endif %} {% if step and current_unit %} diff --git a/tests/test_parallel_station_flow.py b/tests/test_parallel_station_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..bbf5f81d4dc412850da0a4f603f695e3910ab2a2 --- /dev/null +++ b/tests/test_parallel_station_flow.py @@ -0,0 +1,379 @@ +import pytest + +from app import create_app, db +from app.models import ( + Job, + JobStep, + Part, + ProductUnit, + StationInventory, + StepPart, + UnitStepProgress, +) +from app.services import ( + complete_current_step, + get_current_unit_and_step, + scan_job_barcode, +) + + +@pytest.fixture +def app(): + test_app = create_app( + { + "TESTING": True, + "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", + } + ) + + yield test_app + + +def create_parallel_station_job( + upstream_station_id, + downstream_station_id, +): + upstream_part = Part( + name="Parallel Upstream Part", + unit="pcs", + ) + + downstream_part = Part( + name="Parallel Downstream Part", + unit="pcs", + ) + + job = Job( + name="Parallel Station Test", + quantity=2, + threshold_percent=20.0, + status="Active", + barcode="MES-PARALLEL-001", + ) + + db.session.add_all( + [ + upstream_part, + downstream_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", + ) + + upstream_step = JobStep( + job_id=job.id, + step_number=1, + station_id=upstream_station_id, + instruction="Complete upstream work", + ) + + downstream_step = JobStep( + job_id=job.id, + step_number=2, + station_id=downstream_station_id, + instruction="Complete downstream work", + ) + + db.session.add_all( + [ + unit_1, + unit_2, + upstream_step, + downstream_step, + ] + ) + db.session.flush() + + db.session.add_all( + [ + StepPart( + step_id=upstream_step.id, + part_id=upstream_part.id, + quantity=1, + ), + StepPart( + step_id=downstream_step.id, + part_id=downstream_part.id, + quantity=1, + ), + StationInventory( + station_id=upstream_station_id, + part_id=upstream_part.id, + quantity=2, + ), + StationInventory( + station_id=downstream_station_id, + part_id=downstream_part.id, + quantity=2, + ), + ] + ) + + db.session.commit() + + return { + "job_id": job.id, + "unit_1_id": unit_1.id, + "unit_2_id": unit_2.id, + "upstream_step_id": upstream_step.id, + "downstream_step_id": downstream_step.id, + } + + +@pytest.mark.parametrize( + ( + "upstream_station_id", + "downstream_station_id", + ), + [ + ("ST-01", "ST-02"), + ("ST-02", "ST-03"), + ("ST-03", "ST-04"), + ("ST-04", "ST-05"), + ("ST-05", "ST-01"), + ], +) +def test_tc16_upstream_station_can_start_next_unit( + app, + upstream_station_id, + downstream_station_id, +): + with app.app_context(): + ids = create_parallel_station_job( + upstream_station_id, + downstream_station_id, + ) + + job = db.session.get(Job, ids["job_id"]) + + first_result, first_error = complete_current_step( + job, + upstream_station_id, + ) + + assert first_error is None + assert ( + first_result["completed_unit"].id + == ids["unit_1_id"] + ) + assert ( + first_result["completed_step"].id + == ids["upstream_step_id"] + ) + + second_result, second_error = complete_current_step( + job, + upstream_station_id, + ) + + assert second_error is None + assert ( + second_result["completed_unit"].id + == ids["unit_2_id"] + ) + assert ( + second_result["completed_step"].id + == ids["upstream_step_id"] + ) + + upstream_progress_count = ( + UnitStepProgress.query.filter_by( + job_step_id=ids["upstream_step_id"], + status="Completed", + ).count() + ) + + downstream_progress_count = ( + UnitStepProgress.query.filter_by( + job_step_id=ids["downstream_step_id"], + status="Completed", + ).count() + ) + + assert upstream_progress_count == 2 + assert downstream_progress_count == 0 + + ready_unit, ready_step = get_current_unit_and_step( + job, + downstream_station_id, + ) + + assert ready_unit.id == ids["unit_1_id"] + assert ready_step.id == ids["downstream_step_id"] + + scanned_step, scan_error = scan_job_barcode( + "MES-PARALLEL-001", + downstream_station_id, + ) + + assert scan_error is None + assert scanned_step.id == ids["downstream_step_id"] + + downstream_result, downstream_error = ( + complete_current_step( + job, + downstream_station_id, + ) + ) + + assert downstream_error is None + assert ( + downstream_result["completed_unit"].id + == ids["unit_1_id"] + ) + assert downstream_result["unit_completed"] is True + + next_downstream_unit, next_downstream_step = ( + get_current_unit_and_step( + job, + downstream_station_id, + ) + ) + + assert next_downstream_unit.id == ids["unit_2_id"] + assert ( + next_downstream_step.id + == ids["downstream_step_id"] + ) + + unavailable_step, unavailable_error = scan_job_barcode( + "MES-PARALLEL-001", + upstream_station_id, + ) + + assert unavailable_step is None + assert unavailable_error == ( + "This job is not ready at this station." + ) +def test_tc17_kiosk_requires_new_scan_after_completion(app): + with app.app_context(): + ids = create_parallel_station_job( + "ST-03", + "ST-05", + ) + + client = app.test_client() + + completion_response = client.post( + "/kiosk", + data={ + "station_id": "ST-03", + "barcode": "MES-PARALLEL-001", + "action": "complete", + }, + ) + + assert completion_response.status_code == 200 + assert b"Unit 1, Step 1 completed." in ( + completion_response.data + ) + assert b"Pass unit to next station." in ( + completion_response.data + ) + assert b"Scan the Barcode for next unit." in ( + completion_response.data + ) + + assert b"Complete upstream work" not in ( + completion_response.data + ) + + with app.app_context(): + job = db.session.get(Job, ids["job_id"]) + + ready_unit, ready_step = get_current_unit_and_step( + job, + "ST-03", + ) + + assert ready_unit.id == ids["unit_2_id"] + assert ready_step.id == ids["upstream_step_id"] + + scan_response = client.post( + "/kiosk", + data={ + "station_id": "ST-03", + "barcode": "MES-PARALLEL-001", + "action": "scan", + }, + ) + + assert scan_response.status_code == 200 + assert b"Complete upstream work" in scan_response.data + + normalized_page = " ".join( + scan_response.get_data(as_text=True).split() + ) + + assert "Product Unit: 2 of 2" in normalized_page +def test_tc18_finished_unit_returns_kiosk_to_waiting(app): + with app.app_context(): + ids = create_parallel_station_job( + "ST-03", + "ST-05", + ) + + job = db.session.get(Job, ids["job_id"]) + + for _ in range(2): + result, error = complete_current_step( + job, + "ST-03", + ) + + assert error is None + assert result is not None + + client = app.test_client() + + first_finished_response = client.post( + "/kiosk", + data={ + "station_id": "ST-05", + "barcode": "MES-PARALLEL-001", + "action": "complete", + }, + ) + + assert first_finished_response.status_code == 200 + assert b"Pass unit to Finished Goods Section." in ( + first_finished_response.data + ) + assert b"Scan the Barcode for next unit." in ( + first_finished_response.data + ) + assert b"Complete downstream work" not in ( + first_finished_response.data + ) + + final_finished_response = client.post( + "/kiosk", + data={ + "station_id": "ST-05", + "barcode": "MES-PARALLEL-001", + "action": "complete", + }, + ) + + assert final_finished_response.status_code == 200 + assert b"Pass unit to Finished Goods Section." in ( + final_finished_response.data + ) + assert b"Production Complete:" in ( + final_finished_response.data + ) + assert b"Scan the Barcode for next unit." not in ( + final_finished_response.data + ) \ No newline at end of file