From ff38e9f78fc1c3db58c7e87e7e1f81cb6bd2b994 Mon Sep 17 00:00:00 2001 From: Nameru Thapa Date: Tue, 18 Aug 2026 17:06:24 +0200 Subject: [PATCH] feat: add independent part management --- app/routes.py | 86 ++++++-- app/services.py | 110 +++++++++++ app/templates/admin_inventory.html | 112 ++++++++++- app/templates/job_detail.html | 24 ++- tests/test_part_management.py | 305 +++++++++++++++++++++++++++++ 5 files changed, 612 insertions(+), 25 deletions(-) create mode 100644 tests/test_part_management.py diff --git a/app/routes.py b/app/routes.py index 374caca..5ea611a 100644 --- a/app/routes.py +++ b/app/routes.py @@ -14,8 +14,11 @@ from app.models import ( from app.services import ( activate_job, complete_current_step, + create_station_part, fulfill_transfer_request, get_current_unit_and_step, + get_or_create_part, + get_or_create_station_inventory, scan_job_barcode, set_station_inventory, ) @@ -114,6 +117,7 @@ def admin_jobs(): def job_detail(job_id): job = db.get_or_404(Job, job_id) stations = Station.query.order_by(Station.id).all() + parts = Part.query.order_by(Part.name).all() if request.method == "POST": if job.status != "Draft": @@ -175,11 +179,15 @@ def job_detail(job_id): ), ) - part = Part.query.filter_by(name=part_name).first() + try: + part = get_or_create_part(part_name) + except ValueError as error: + abort(400, description=str(error)) - if part is None: - part = Part(name=part_name) - db.session.add(part) + get_or_create_station_inventory( + station.id, + part.id, + ) next_step_number = max( (step.step_number for step in job.steps), @@ -224,11 +232,11 @@ def job_detail(job_id): "job_detail.html", job=job, stations=stations, + parts=parts, product_units=product_units, transfer_requests=transfer_requests, ) - @bp.route( "/admin/jobs//activate", methods=["POST"], @@ -261,11 +269,71 @@ def activate_job_route(job_id): @bp.route("/admin/inventory", methods=["GET", "POST"]) def admin_inventory(): if request.method == "POST": + action = request.form.get( + "action", + "update_inventory", + ).strip() + station_id = request.form.get( "station_id", "", ).strip() + station = db.session.get(Station, station_id) + + if station is None: + abort( + 400, + description="A valid station is required.", + ) + + if action == "create_part": + part_name = request.form.get( + "part_name", + "", + ).strip() + + part_unit = request.form.get( + "part_unit", + "", + ).strip() + + try: + initial_quantity = int( + request.form.get( + "initial_quantity", + "", + ) + ) + except (TypeError, ValueError): + abort( + 400, + description=( + "Initial quantity must be " + "a valid number." + ), + ) + + try: + create_station_part( + part_name, + part_unit, + station_id, + initial_quantity, + ) + except ValueError as error: + abort(400, description=str(error)) + + return redirect( + url_for("main.admin_inventory") + ) + + if action != "update_inventory": + abort( + 400, + description="Invalid inventory action.", + ) + try: part_id = int( request.form.get("part_id", "") @@ -281,15 +349,8 @@ def admin_inventory(): ), ) - station = db.session.get(Station, station_id) part = db.session.get(Part, part_id) - if station is None: - abort( - 400, - description="A valid station is required.", - ) - if part is None: abort( 400, @@ -333,7 +394,6 @@ def admin_inventory(): inventory_rows=inventory_rows, ) - @bp.route("/warehouse/requests") def warehouse_requests(): transfer_requests = TransferRequest.query.order_by( diff --git a/app/services.py b/app/services.py index f4b5e44..c312c6f 100644 --- a/app/services.py +++ b/app/services.py @@ -4,6 +4,7 @@ import uuid from app import db from app.models import ( Job, + Part, ProductUnit, StationInventory, TransferRequest, @@ -26,7 +27,116 @@ def calculate_requirements(job): return requirements +def normalize_part_name(name): + return " ".join((name or "").split()) + +def find_part_by_name(name): + normalized_name = normalize_part_name(name) + + if not normalized_name: + return None + + return Part.query.filter( + db.func.lower(Part.name) + == normalized_name.lower() + ).first() + + +def get_or_create_part(name, unit="pcs"): + normalized_name = normalize_part_name(name) + normalized_unit = (unit or "").strip() or "pcs" + + if not normalized_name: + raise ValueError("Part name is required") + + if len(normalized_name) > 100: + raise ValueError( + "Part name cannot exceed 100 characters" + ) + + if len(normalized_unit) > 20: + raise ValueError( + "Part unit cannot exceed 20 characters" + ) + + part = find_part_by_name(normalized_name) + + if part is None: + part = Part( + name=normalized_name, + unit=normalized_unit, + ) + + db.session.add(part) + db.session.flush() + + return part + + +def create_station_part( + name, + unit, + station_id, + initial_quantity, +): + normalized_name = normalize_part_name(name) + normalized_unit = (unit or "").strip() + + if not normalized_name: + raise ValueError("Part name is required") + + if not normalized_unit: + raise ValueError("Part unit is required") + + if initial_quantity < 0: + raise ValueError( + "Initial quantity cannot be negative" + ) + + existing_part = find_part_by_name(normalized_name) + + if ( + existing_part is not None + and existing_part.unit.lower() + != normalized_unit.lower() + ): + raise ValueError( + "This part already exists with unit " + f"'{existing_part.unit}'" + ) + + if existing_part is not None: + existing_inventory = ( + StationInventory.query.filter_by( + station_id=station_id, + part_id=existing_part.id, + ).first() + ) + + if existing_inventory is not None: + raise ValueError( + "This part is already assigned " + "to the selected station" + ) + + part = existing_part + else: + part = get_or_create_part( + normalized_name, + normalized_unit, + ) + + inventory = StationInventory( + station_id=station_id, + part_id=part.id, + quantity=initial_quantity, + ) + + db.session.add(inventory) + db.session.commit() + + return part, inventory def get_or_create_station_inventory(station_id, part_id): inventory = StationInventory.query.filter_by( station_id=station_id, diff --git a/app/templates/admin_inventory.html b/app/templates/admin_inventory.html index f073cb5..583040a 100644 --- a/app/templates/admin_inventory.html +++ b/app/templates/admin_inventory.html @@ -14,26 +14,106 @@

- Production Jobs + + Production Jobs + | Warehouse Requests | - Worker Kiosk + Worker Kiosk

Station Inventory

+

Add Part to Station Inventory

+ +
+ + +

+
+ +

+ +

+
+ +

+ +

+
+ +

+ +

+
+ +

+ + +
+

Update Station Inventory

{% if parts %}
+ +

-
- {% for station in stations %}

-
- {% for part in parts %} {% endfor %}

-
+

- +
{% else %}

- No parts exist yet. Create a production job and add a step first. + No parts exist yet. Use the form above to add + the first part.

{% endif %} @@ -78,6 +168,7 @@ Station Part + Unit Quantity @@ -85,6 +176,7 @@ {{ inventory.station.name }} {{ inventory.part.name }} + {{ inventory.part.unit }} {{ inventory.quantity }} {% endfor %} diff --git a/app/templates/job_detail.html b/app/templates/job_detail.html index 4fcace8..b87293c 100644 --- a/app/templates/job_detail.html +++ b/app/templates/job_detail.html @@ -153,8 +153,28 @@

-
- +
+ + + + {% for part in parts %} + + {% endfor %} + + + + Select an existing part or enter a new name. +

diff --git a/tests/test_part_management.py b/tests/test_part_management.py new file mode 100644 index 0000000..7884f1f --- /dev/null +++ b/tests/test_part_management.py @@ -0,0 +1,305 @@ +import pytest + +from app import create_app, db +from app.models import ( + Job, + JobStep, + Part, + StationInventory, + StepPart, +) + + +@pytest.fixture +def app(): + test_app = create_app( + { + "TESTING": True, + "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", + } + ) + + yield test_app + + +def create_draft_job(name="Part Management Test"): + job = Job( + name=name, + quantity=1, + threshold_percent=20.0, + status="Draft", + ) + + db.session.add(job) + db.session.commit() + + return job.id + + +def test_tc19_create_part_directly_with_zero_quantity(app): + client = app.test_client() + + response = client.post( + "/admin/inventory", + data={ + "action": "create_part", + "part_name": "Direct Inventory Part", + "part_unit": "pcs", + "station_id": "ST-04", + "initial_quantity": "0", + }, + ) + + assert response.status_code == 302 + + with app.app_context(): + part = Part.query.filter_by( + name="Direct Inventory Part", + ).one() + + inventory = StationInventory.query.filter_by( + station_id="ST-04", + part_id=part.id, + ).one() + + assert part.unit == "pcs" + assert inventory.quantity == 0 + + +def test_tc20_existing_part_can_be_assigned_to_another_station(app): + with app.app_context(): + part = Part( + name="Reusable Part", + unit="kg", + ) + + db.session.add(part) + db.session.flush() + + db.session.add( + StationInventory( + station_id="ST-01", + part_id=part.id, + quantity=4, + ) + ) + + db.session.commit() + + part_id = part.id + + client = app.test_client() + + response = client.post( + "/admin/inventory", + data={ + "action": "create_part", + "part_name": " reusable PART ", + "part_unit": "KG", + "station_id": "ST-05", + "initial_quantity": "2", + }, + ) + + assert response.status_code == 302 + + with app.app_context(): + assert Part.query.count() == 1 + + original_inventory = ( + StationInventory.query.filter_by( + station_id="ST-01", + part_id=part_id, + ).one() + ) + + new_inventory = ( + StationInventory.query.filter_by( + station_id="ST-05", + part_id=part_id, + ).one() + ) + + assert original_inventory.quantity == 4 + assert new_inventory.quantity == 2 + + +def test_tc21_duplicate_station_part_is_rejected(app): + with app.app_context(): + part = Part( + name="Protected Part", + unit="pcs", + ) + + db.session.add(part) + db.session.flush() + + inventory = StationInventory( + station_id="ST-02", + part_id=part.id, + quantity=7, + ) + + db.session.add(inventory) + db.session.commit() + + part_id = part.id + + client = app.test_client() + + response = client.post( + "/admin/inventory", + data={ + "action": "create_part", + "part_name": "protected part", + "part_unit": "pcs", + "station_id": "ST-02", + "initial_quantity": "0", + }, + ) + + assert response.status_code == 400 + + with app.app_context(): + assert Part.query.count() == 1 + assert StationInventory.query.count() == 1 + + inventory = StationInventory.query.filter_by( + station_id="ST-02", + part_id=part_id, + ).one() + + assert inventory.quantity == 7 + + +def test_tc22_job_step_reuses_existing_part(app): + with app.app_context(): + part = Part( + name="Catalogue Part", + unit="kg", + ) + + db.session.add(part) + db.session.commit() + + part_id = part.id + job_id = create_draft_job( + "Existing Catalogue Part Test" + ) + + client = app.test_client() + + response = client.post( + f"/admin/jobs/{job_id}", + data={ + "station_id": "ST-03", + "instruction": "Install catalogue part", + "photo_url": "", + "part_name": " catalogue PART ", + "part_quantity": "1", + }, + ) + + assert response.status_code == 302 + + with app.app_context(): + assert Part.query.count() == 1 + + step = JobStep.query.one() + usage = StepPart.query.one() + + assert usage.step_id == step.id + assert usage.part_id == part_id + + inventory = StationInventory.query.filter_by( + station_id="ST-03", + part_id=part_id, + ).one() + + assert inventory.quantity == 0 + + +def test_tc23_job_step_creates_new_part_with_zero_stock(app): + with app.app_context(): + job_id = create_draft_job( + "New Job Part Test" + ) + + client = app.test_client() + + response = client.post( + f"/admin/jobs/{job_id}", + data={ + "station_id": "ST-04", + "instruction": "Install new part", + "photo_url": "", + "part_name": "New Step Part", + "part_quantity": "1", + }, + ) + + assert response.status_code == 302 + + with app.app_context(): + part = Part.query.filter_by( + name="New Step Part", + ).one() + + inventory = StationInventory.query.filter_by( + station_id="ST-04", + part_id=part.id, + ).one() + + assert part.unit == "pcs" + assert inventory.quantity == 0 + + +def test_tc24_job_step_form_contains_part_suggestions(app): + with app.app_context(): + db.session.add_all( + [ + Part(name="PA1", unit="pcs"), + Part(name="PA2", unit="kg"), + ] + ) + + db.session.commit() + + job_id = create_draft_job( + "Part Suggestion Test" + ) + + client = app.test_client() + + response = client.get( + f"/admin/jobs/{job_id}" + ) + + assert response.status_code == 200 + assert b'list="part_catalogue"' in response.data + assert b'value="PA1"' in response.data + assert b'value="PA2"' in response.data + assert b"Select an existing part or enter a new name." in ( + response.data + ) + + +def test_tc25_negative_initial_quantity_is_rejected(app): + client = app.test_client() + + response = client.post( + "/admin/inventory", + data={ + "action": "create_part", + "part_name": "Invalid Negative Part", + "part_unit": "pcs", + "station_id": "ST-01", + "initial_quantity": "-1", + }, + ) + + assert response.status_code == 400 + + with app.app_context(): + assert Part.query.count() == 0 + assert StationInventory.query.count() == 0 \ No newline at end of file -- GitLab