diff --git a/app/models.py b/app/models.py index a20f49ecdc88904ee66da70ab188b40ce2d1bcad..1c173720d8df51fee070c4bf6ba9a73c6f9558e0 100644 --- a/app/models.py +++ b/app/models.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from app import db @@ -33,6 +34,7 @@ class Job(db.Model): quantity = db.Column(db.Integer, nullable=False) threshold_percent = db.Column(db.Float, nullable=False, default=20.0) status = db.Column(db.String(20), nullable=False, default="Draft") + barcode = db.Column(db.String(30), unique=True) steps = db.relationship( "JobStep", @@ -95,10 +97,97 @@ class StepPart(db.Model): step = db.relationship("JobStep", back_populates="parts") part = db.relationship("Part", back_populates="step_usages") - __table_args__ = ( + _table_args__ = ( db.UniqueConstraint( "step_id", "part_id", name="uq_step_part", ), + ) +class StationInventory(db.Model): + __tablename__ = "station_inventory" + + id = db.Column(db.Integer, primary_key=True) + station_id = db.Column( + db.String(5), + db.ForeignKey("stations.id"), + nullable=False, + ) + part_id = db.Column( + db.Integer, + db.ForeignKey("parts.id"), + nullable=False, + ) + quantity = db.Column(db.Integer, nullable=False, default=0) + + station = db.relationship("Station") + part = db.relationship("Part") + + __table_args__ = ( + db.UniqueConstraint( + "station_id", + "part_id", + name="uq_station_part_inventory", + ), + ) + + +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, + ) + station_id = db.Column( + db.String(5), + db.ForeignKey("stations.id"), + nullable=False, + ) + part_id = db.Column( + db.Integer, + db.ForeignKey("parts.id"), + nullable=False, + ) + + requested_quantity = db.Column(db.Integer, nullable=False) + transferred_quantity = db.Column(db.Integer, nullable=False, default=0) + status = db.Column(db.String(30), nullable=False, default="Pending") + + created_at = db.Column( + db.DateTime, + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + completed_at = db.Column(db.DateTime) + + job = db.relationship("Job") + station = db.relationship("Station") + part = db.relationship("Part") + + +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, + ) + unit_number = db.Column(db.Integer, nullable=False) + + + status = db.Column(db.String(20), nullable=False, default="Pending") + + job = db.relationship("Job") + + __table_args__ = ( + db.UniqueConstraint( + "job_id", + "unit_number", + name="uq_job_unit_number", + ), ) \ No newline at end of file diff --git a/app/routes.py b/app/routes.py index b8be6dcdab801a1d430af4a3a59edd7893b34723..6769c7ac099f1f7488b16639836e8bb78f918a4c 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,7 +1,21 @@ from flask import Blueprint, abort, redirect, render_template, request, url_for from app import db -from app.models import Job, JobStep, Part, Station, StepPart +from app.models import ( + Job, + JobStep, + Part, + ProductUnit, + Station, + StationInventory, + StepPart, + TransferRequest, +) +from app.services import ( + activate_job, + fulfill_transfer_request, + set_station_inventory, +) bp = Blueprint("main", __name__) @@ -18,8 +32,16 @@ def admin_jobs(): name = request.form["name"].strip() quantity = int(request.form["quantity"]) - threshold_value = request.form.get("threshold_percent", "").strip() - threshold_percent = float(threshold_value) if threshold_value else 20.0 + threshold_value = request.form.get( + "threshold_percent", + "", + ).strip() + + threshold_percent = ( + float(threshold_value) + if threshold_value + else 20.0 + ) job = Job( name=name, @@ -31,10 +53,16 @@ def admin_jobs(): db.session.add(job) db.session.commit() - return redirect(url_for("main.job_detail", job_id=job.id)) + return redirect( + url_for("main.job_detail", job_id=job.id) + ) jobs = Job.query.order_by(Job.id.desc()).all() - return render_template("admin_jobs.html", jobs=jobs) + + return render_template( + "admin_jobs.html", + jobs=jobs, + ) @bp.route("/admin/jobs/", methods=["GET", "POST"]) @@ -68,7 +96,10 @@ def job_detail(job_id): step_number=next_step_number, instruction=request.form["instruction"].strip(), station=station, - photo_url=request.form.get("photo_url", "").strip() or None, + photo_url=request.form.get( + "photo_url", + "", + ).strip() or None, ) db.session.add(step) @@ -82,10 +113,100 @@ def job_detail(job_id): db.session.add(step_part) db.session.commit() - return redirect(url_for("main.job_detail", job_id=job.id)) + return redirect( + url_for("main.job_detail", job_id=job.id) + ) + + product_units = ProductUnit.query.filter_by( + job_id=job.id + ).order_by(ProductUnit.unit_number).all() + + transfer_requests = TransferRequest.query.filter_by( + job_id=job.id + ).order_by(TransferRequest.id.desc()).all() return render_template( "job_detail.html", job=job, stations=stations, + product_units=product_units, + transfer_requests=transfer_requests, + ) + + +@bp.route( + "/admin/jobs//activate", + methods=["POST"], +) +def activate_job_route(job_id): + job = db.get_or_404(Job, job_id) + + activate_job(job) + + return redirect( + url_for("main.job_detail", job_id=job.id) + ) + + +@bp.route("/admin/inventory", methods=["GET", "POST"]) +def admin_inventory(): + if request.method == "POST": + station_id = request.form["station_id"] + part_id = int(request.form["part_id"]) + quantity = int(request.form["quantity"]) + + set_station_inventory( + station_id, + part_id, + quantity, + ) + + return redirect( + url_for("main.admin_inventory") + ) + + stations = Station.query.order_by(Station.id).all() + parts = Part.query.order_by(Part.name).all() + + inventory_rows = StationInventory.query.order_by( + StationInventory.station_id, + StationInventory.part_id, + ).all() + + return render_template( + "admin_inventory.html", + stations=stations, + parts=parts, + inventory_rows=inventory_rows, + ) + + +@bp.route("/warehouse/requests") +def warehouse_requests(): + transfer_requests = TransferRequest.query.order_by( + TransferRequest.id.desc() + ).all() + + return render_template( + "transfer_requests.html", + transfer_requests=transfer_requests, + ) + + +@bp.route( + "/warehouse/requests//fulfill", + methods=["POST"], +) +def fulfill_request_route(request_id): + transferred_quantity = int( + request.form["transferred_quantity"] + ) + + fulfill_transfer_request( + request_id, + transferred_quantity, + ) + + return redirect( + url_for("main.warehouse_requests") ) \ No newline at end of file diff --git a/app/services.py b/app/services.py new file mode 100644 index 0000000000000000000000000000000000000000..43e27732a4d5154ffe6d9b44bbe71d74b68da50a --- /dev/null +++ b/app/services.py @@ -0,0 +1,161 @@ +from datetime import datetime, timezone +import uuid + +from app import db +from app.models import ( + Job, + ProductUnit, + StationInventory, + TransferRequest, +) + + +def calculate_requirements(job): + requirements = {} + + for step in job.steps: + for usage in step.parts: + key = (step.station_id, usage.part_id) + + total_required = usage.quantity * job.quantity + + requirements[key] = ( + requirements.get(key, 0) + total_required + ) + + return requirements + + +def get_or_create_station_inventory(station_id, part_id): + inventory = StationInventory.query.filter_by( + station_id=station_id, + part_id=part_id, + ).first() + + if inventory is None: + inventory = StationInventory( + station_id=station_id, + part_id=part_id, + quantity=0, + ) + db.session.add(inventory) + + return inventory + + +def set_station_inventory(station_id, part_id, quantity): + inventory = get_or_create_station_inventory( + station_id, + part_id, + ) + + inventory.quantity = quantity + db.session.commit() + + return inventory + + +def activate_job(job): + requirements = calculate_requirements(job) + + stock_missing = False + + for (station_id, part_id), required_quantity in requirements.items(): + inventory = get_or_create_station_inventory( + station_id, + part_id, + ) + + threshold_quantity = ( + required_quantity * job.threshold_percent / 100 + ) + + if inventory.quantity < threshold_quantity: + stock_missing = True + + 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 None: + requested_quantity = ( + required_quantity - inventory.quantity + ) + + if requested_quantity > 0: + db.session.add( + TransferRequest( + job_id=job.id, + station_id=station_id, + part_id=part_id, + requested_quantity=requested_quantity, + status="Pending", + ) + ) + if stock_missing: + db.session.commit() + return False + + if job.barcode is None: + job.barcode = f"MES-{uuid.uuid4().hex[:12].upper()}" + + job.status = "Active" + + existing_unit_numbers = { + unit.unit_number + for unit in ProductUnit.query.filter_by(job_id=job.id).all() + } + + for unit_number in range(1, job.quantity + 1): + if unit_number not in existing_unit_numbers: + db.session.add( + ProductUnit( + job_id=job.id, + unit_number=unit_number, + ) + ) + + db.session.commit() + return True + + +def fulfill_transfer_request(request_id, transferred_quantity): + transfer_request = db.session.get( + TransferRequest, + request_id, + ) + + if transfer_request is None: + raise ValueError("Transfer request not found") + + if transferred_quantity <= 0: + raise ValueError("Transferred quantity must be greater than zero") + + inventory = get_or_create_station_inventory( + transfer_request.station_id, + transfer_request.part_id, + ) + + inventory.quantity += transferred_quantity + transfer_request.transferred_quantity += transferred_quantity + + if ( + transfer_request.transferred_quantity + >= transfer_request.requested_quantity + ): + transfer_request.status = "Fulfilled" + transfer_request.completed_at = datetime.now(timezone.utc) + else: + transfer_request.status = "Partially Fulfilled" + + if transfer_request.job.status == "Draft": + activate_job(transfer_request.job) + else: + db.session.commit() + + return transfer_request \ No newline at end of file diff --git a/app/templates/admin_inventory.html b/app/templates/admin_inventory.html new file mode 100644 index 0000000000000000000000000000000000000000..7c1e63cc7b100eae9d7546a7b429340419f577c5 --- /dev/null +++ b/app/templates/admin_inventory.html @@ -0,0 +1,84 @@ + + + + + MES - Station Inventory + + +

+ Production Jobs + | + + Warehouse Requests + +

+ +

Station Inventory

+ +

Update Station Inventory

+ + {% if parts %} +
+

+
+ +

+ +

+
+ +

+ +

+
+ +

+ + +
+ {% else %} +

+ No parts exist yet. Create a production job and add a step first. +

+ {% endif %} + +

Current Station Inventory

+ + {% if inventory_rows %} + + + + + + + + {% for inventory in inventory_rows %} + + + + + + {% endfor %} +
StationPartQuantity
{{ inventory.station.name }}{{ inventory.part.name }}{{ inventory.quantity }}
+ {% else %} +

No station inventory entered yet.

+ {% endif %} + + \ No newline at end of file diff --git a/app/templates/admin_jobs.html b/app/templates/admin_jobs.html index 02a4421df170b19760d97d2baecc355bf9e62507..6ea3800ece44dd606fee9ed2ada02f5c7765d737 100644 --- a/app/templates/admin_jobs.html +++ b/app/templates/admin_jobs.html @@ -5,6 +5,14 @@ MES - Production Jobs +

+ Production Jobs + | + Station Inventory + | + Warehouse Requests +

+

Production Jobs

Create Production Job

diff --git a/app/templates/job_detail.html b/app/templates/job_detail.html index fd25713228055c1b7d08cf0f4452ae6459e2f3f4..9440225fe5bf83b52dfad48e7a80b1a55f897c44 100644 --- a/app/templates/job_detail.html +++ b/app/templates/job_detail.html @@ -6,7 +6,11 @@

- Back to Jobs + Production Jobs + | + Station Inventory + | + Warehouse Requests

{{ job.name }}

@@ -15,6 +19,63 @@

Production Quantity: {{ job.quantity }}

Low-stock Threshold: {{ job.threshold_percent }}%

+ {% if job.status == "Draft" %} +
+ +
+ {% endif %} + + {% if transfer_requests %} +

Transfer Requests

+ + + + + + + + + + + {% for transfer in transfer_requests %} + + + + + + + + {% endfor %} +
RequestStationPartRequestedStatus
#{{ transfer.id }}{{ transfer.station.name }}{{ transfer.part.name }}{{ transfer.requested_quantity }}{{ transfer.status }}
+ {% endif %} + + {% if job.barcode %} +

Job Barcode

+

+ {{ job.barcode }} +

+ {% endif %} + + {% if product_units %} +

Product Units

+ + + + + + + + {% for unit in product_units %} + + + + + {% endfor %} +
UnitStatus
{{ unit.unit_number }}{{ unit.status }}
+ {% endif %}

Production Steps

{% if job.steps %} @@ -22,14 +83,25 @@

Step {{ step.step_number }}

-

Station: {{ step.station.name }}

-

Instruction: {{ step.instruction }}

+

+ Station: + {{ step.station.name }} +

+ +

+ Instruction: + {{ step.instruction }} +

{% if step.photo_url %} -

Photo: {{ step.photo_url }}

+

+ Photo: + {{ step.photo_url }} +

{% endif %}

Required Parts:

+
    {% for usage in step.parts %}
  • @@ -43,43 +115,55 @@

    No steps added yet.

    {% endif %} -
    + {% if job.status == "Draft" %} +
    -

    Add Production Step

    +

    Add Production Step

    -
    -

    -
    - -

    + +

    +
    -

    -
    - -

    + +

    -

    -
    - -

    +

    +
    + +

    -

    -
    - -

    +

    +
    + +

    + +

    +
    + +

    + +

    +
    + +

    + +
    + {% else %}

    -
    - + Production configuration is locked because this job is active.

    - - - + {% endif %} \ No newline at end of file diff --git a/app/templates/transfer_requests.html b/app/templates/transfer_requests.html new file mode 100644 index 0000000000000000000000000000000000000000..e8c675351524b125868c561d1ffde9f6f1ad5482 --- /dev/null +++ b/app/templates/transfer_requests.html @@ -0,0 +1,74 @@ + + + + + MES - Warehouse Requests + + +

    + Production Jobs + | + + Station Inventory + +

    + +

    Warehouse Transfer Requests

    + +

    Manage station replenishment requests.

    + + {% if transfer_requests %} + + + + + + + + + + + + + {% for transfer in transfer_requests %} + + + + + + + + + + + + {% endfor %} +
    RequestJobStationPartRequestedTransferredStatusAction
    #{{ transfer.id }}{{ transfer.job.name }}{{ transfer.station.name }}{{ transfer.part.name }}{{ transfer.requested_quantity }}{{ transfer.transferred_quantity }}{{ transfer.status }} + {% if transfer.status in ["Pending", "Partially Fulfilled"] %} +
    + + +
    + {% else %} + Completed + {% endif %} +
    + {% else %} +

    No transfer requests.

    + {% endif %} + + \ No newline at end of file diff --git a/tests/test_inventory_activation.py b/tests/test_inventory_activation.py new file mode 100644 index 0000000000000000000000000000000000000000..66e477f8bd0e925973f9ba4ae44718b2b0ced8b0 --- /dev/null +++ b/tests/test_inventory_activation.py @@ -0,0 +1,184 @@ +import pytest + +from app import create_app, db +from app.models import ( + Job, + JobStep, + Part, + ProductUnit, + Station, + StationInventory, + StepPart, + TransferRequest, +) +from app.services import ( + activate_job, + fulfill_transfer_request, + set_station_inventory, +) + + +@pytest.fixture +def app(): + return create_app( + { + "TESTING": True, + "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", + } + ) + + +def create_test_job(app, threshold_percent=20.0): + with app.app_context(): + station = db.session.get(Station, "ST-01") + + part = Part(name="Part A") + + job_data = { + "name": "Test Production Job", + "quantity": 10, + "status": "Draft", + } + + if threshold_percent is not None: + job_data["threshold_percent"] = threshold_percent + + job = Job(**job_data) + + step = JobStep( + job=job, + step_number=1, + instruction="Attach Part A", + station=station, + ) + + usage = StepPart( + step=step, + part=part, + quantity=1, + ) + + db.session.add_all([job, part, step, usage]) + db.session.commit() + + return job.id, part.id + + +def test_tc02_activate_job_with_sufficient_station_stock(app): + job_id, part_id = create_test_job(app) + + with app.app_context(): + set_station_inventory("ST-01", part_id, 10) + + job = db.session.get(Job, job_id) + + result = activate_job(job) + + assert result is True + assert job.status == "Active" + + assert TransferRequest.query.count() == 0 + + units = ProductUnit.query.filter_by(job_id=job_id).all() + + assert job.barcode is not None + assert job.barcode.startswith("MES-") + assert len(units) == 10 + + +def test_tc03_default_threshold_is_twenty_percent(app): + job_id, part_id = create_test_job( + app, + threshold_percent=None, + ) + + with app.app_context(): + job = db.session.get(Job, job_id) + + assert job.threshold_percent == 20.0 + + set_station_inventory("ST-01", part_id, 2) + + result = activate_job(job) + + assert result is True + assert job.status == "Active" + assert TransferRequest.query.count() == 0 + + +def test_tc04_duplicate_transfer_request_is_not_created(app): + job_id, part_id = create_test_job(app) + + with app.app_context(): + set_station_inventory("ST-01", part_id, 0) + + job = db.session.get(Job, job_id) + + assert activate_job(job) is False + assert activate_job(job) is False + + requests = TransferRequest.query.filter_by( + job_id=job_id, + station_id="ST-01", + part_id=part_id, + ).all() + + assert len(requests) == 1 + assert requests[0].status == "Pending" + + +def test_tc05_insufficient_station_stock_creates_transfer(app): + job_id, part_id = create_test_job(app) + + with app.app_context(): + set_station_inventory("ST-01", part_id, 1) + + job = db.session.get(Job, job_id) + + result = activate_job(job) + + assert result is False + assert job.status == "Draft" + + transfer = TransferRequest.query.one() + + assert transfer.station_id == "ST-01" + assert transfer.part_id == part_id + assert transfer.requested_quantity == 9 + assert transfer.status == "Pending" + + +def test_tc06_fulfill_station_transfer(app): + job_id, part_id = create_test_job(app) + + with app.app_context(): + set_station_inventory("ST-01", part_id, 1) + + job = db.session.get(Job, job_id) + + activate_job(job) + + transfer = TransferRequest.query.one() + + fulfill_transfer_request( + transfer.id, + transferred_quantity=9, + ) + + inventory = StationInventory.query.filter_by( + station_id="ST-01", + part_id=part_id, + ).one() + + transfer = db.session.get( + TransferRequest, + transfer.id, + ) + + job = db.session.get(Job, job_id) + + assert inventory.quantity == 10 + assert transfer.transferred_quantity == 9 + assert transfer.status == "Fulfilled" + assert transfer.completed_at is not None + assert job.status == "Active" \ No newline at end of file