|
@@ -0,0 +1,833 @@
|
|
|
|
|
+"""Integration tests for batch orders — per-plate targets and staged dispatch (#342).
|
|
|
|
|
+
|
|
|
|
|
+The behaviour these lock down that the pre-#342 batch could not express: a
|
|
|
|
|
+failed or cancelled run does not satisfy a target, so the order keeps saying it
|
|
|
|
|
+owes a print until one actually completes.
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+from datetime import datetime
|
|
|
|
|
+
|
|
|
|
|
+import pytest
|
|
|
|
|
+from httpx import AsyncClient
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.fixture
|
|
|
|
|
+async def printer_factory(db_session):
|
|
|
|
|
+ _counter = [0]
|
|
|
|
|
+
|
|
|
|
|
+ async def _create_printer(**kwargs):
|
|
|
|
|
+ from backend.app.models.printer import Printer
|
|
|
|
|
+
|
|
|
|
|
+ _counter[0] += 1
|
|
|
|
|
+ counter = _counter[0]
|
|
|
|
|
+ defaults = {
|
|
|
|
|
+ "name": f"Batch Printer {counter}",
|
|
|
|
|
+ "ip_address": f"192.168.9.{100 + counter}",
|
|
|
|
|
+ "serial_number": f"BATCHSERIAL{counter:04d}",
|
|
|
|
|
+ "access_code": "12345678",
|
|
|
|
|
+ "model": "X1C",
|
|
|
|
|
+ }
|
|
|
|
|
+ defaults.update(kwargs)
|
|
|
|
|
+ printer = Printer(**defaults)
|
|
|
|
|
+ db_session.add(printer)
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+ await db_session.refresh(printer)
|
|
|
|
|
+ return printer
|
|
|
|
|
+
|
|
|
|
|
+ return _create_printer
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.fixture
|
|
|
|
|
+async def archive_factory(db_session):
|
|
|
|
|
+ _counter = [0]
|
|
|
|
|
+
|
|
|
|
|
+ async def _create_archive(**kwargs):
|
|
|
|
|
+ from backend.app.models.archive import PrintArchive
|
|
|
|
|
+
|
|
|
|
|
+ _counter[0] += 1
|
|
|
|
|
+ counter = _counter[0]
|
|
|
|
|
+ defaults = {
|
|
|
|
|
+ "filename": f"batch_order_{counter}.3mf",
|
|
|
|
|
+ "print_name": f"Batch Order {counter}",
|
|
|
|
|
+ "file_path": f"/tmp/batch_order_{counter}.3mf",
|
|
|
|
|
+ "file_size": 2048,
|
|
|
|
|
+ "content_hash": f"batchhash{counter:08d}",
|
|
|
|
|
+ "status": "completed",
|
|
|
|
|
+ }
|
|
|
|
|
+ defaults.update(kwargs)
|
|
|
|
|
+ archive = PrintArchive(**defaults)
|
|
|
|
|
+ db_session.add(archive)
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+ await db_session.refresh(archive)
|
|
|
|
|
+ return archive
|
|
|
|
|
+
|
|
|
|
|
+ return _create_archive
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _create_order(async_client: AsyncClient, archive_id: int, plates: list[dict], **extra):
|
|
|
|
|
+ payload = {"name": "Test Order", "archive_id": archive_id, "plates": plates}
|
|
|
|
|
+ payload.update(extra)
|
|
|
|
|
+ response = await async_client.post("/api/v1/queue/batches", json=payload)
|
|
|
|
|
+ assert response.status_code == 200, response.text
|
|
|
|
|
+ return response.json()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _queue_item(async_client: AsyncClient, printer_id: int, archive_id: int, batch_id: int, **extra):
|
|
|
|
|
+ payload = {"printer_id": printer_id, "archive_id": archive_id, "batch_id": batch_id}
|
|
|
|
|
+ payload.update(extra)
|
|
|
|
|
+ response = await async_client.post("/api/v1/queue/", json=payload)
|
|
|
|
|
+ assert response.status_code == 200, response.text
|
|
|
|
|
+ return response.json()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _set_status(db_session, item_id: int, status: str):
|
|
|
|
|
+ from backend.app.models.print_queue import PrintQueueItem
|
|
|
|
|
+
|
|
|
|
|
+ item = await db_session.get(PrintQueueItem, item_id)
|
|
|
|
|
+ item.status = status
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+@pytest.mark.integration
|
|
|
|
|
+class TestBatchOrderTargets:
|
|
|
|
|
+ async def test_order_reports_per_plate_targets(self, async_client, archive_factory):
|
|
|
|
|
+ """The reporter's own example: plate 1 once, plate 2 twice, plate 3 three times."""
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(
|
|
|
|
|
+ async_client,
|
|
|
|
|
+ archive.id,
|
|
|
|
|
+ [
|
|
|
|
|
+ {"plate_id": 1, "plate_name": "Base", "quantity_target": 1, "sort_order": 0},
|
|
|
|
|
+ {"plate_id": 2, "quantity_target": 2, "sort_order": 1},
|
|
|
|
|
+ {"plate_id": 3, "quantity_target": 3, "sort_order": 2},
|
|
|
|
|
+ ],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert order["has_targets"] is True
|
|
|
|
|
+ assert order["target_count"] == 6
|
|
|
|
|
+ assert order["remaining_count"] == 6
|
|
|
|
|
+ assert [p["plate_id"] for p in order["plates"]] == [1, 2, 3]
|
|
|
|
|
+ assert [p["quantity_target"] for p in order["plates"]] == [1, 2, 3]
|
|
|
|
|
+ assert order["plates"][0]["plate_name"] == "Base"
|
|
|
|
|
+ # Nothing dispatched yet, so nothing has been consumed.
|
|
|
|
|
+ assert all(p["dispatched"] == 0 for p in order["plates"])
|
|
|
|
|
+
|
|
|
|
|
+ async def test_zero_target_plate_is_allowed(self, async_client, archive_factory):
|
|
|
|
|
+ """ "Plate 3 not required" keeps its row so it can be raised later."""
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(
|
|
|
|
|
+ async_client,
|
|
|
|
|
+ archive.id,
|
|
|
|
|
+ [{"plate_id": 1, "quantity_target": 2}, {"plate_id": 2, "quantity_target": 0}],
|
|
|
|
|
+ )
|
|
|
|
|
+ assert order["target_count"] == 2
|
|
|
|
|
+ plate_two = next(p for p in order["plates"] if p["plate_id"] == 2)
|
|
|
|
|
+ assert plate_two["quantity_target"] == 0
|
|
|
|
|
+ assert plate_two["remaining"] == 0
|
|
|
|
|
+
|
|
|
|
|
+ async def test_order_requesting_nothing_is_rejected(self, async_client, archive_factory):
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ response = await async_client.post(
|
|
|
|
|
+ "/api/v1/queue/batches",
|
|
|
|
|
+ json={
|
|
|
|
|
+ "name": "Empty",
|
|
|
|
|
+ "archive_id": archive.id,
|
|
|
|
|
+ "plates": [{"plate_id": 1, "quantity_target": 0}],
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ assert response.status_code == 400
|
|
|
|
|
+ assert "at least one print" in response.json()["detail"]
|
|
|
|
|
+
|
|
|
|
|
+ async def test_duplicate_plate_is_rejected(self, async_client, archive_factory):
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ response = await async_client.post(
|
|
|
|
|
+ "/api/v1/queue/batches",
|
|
|
|
|
+ json={
|
|
|
|
|
+ "name": "Dupes",
|
|
|
|
|
+ "archive_id": archive.id,
|
|
|
|
|
+ "plates": [{"plate_id": 1, "quantity_target": 1}, {"plate_id": 1, "quantity_target": 2}],
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ assert response.status_code == 400
|
|
|
|
|
+ assert "Duplicate plate" in response.json()["detail"]
|
|
|
|
|
+
|
|
|
|
|
+ async def test_duplicate_whole_file_plate_is_rejected(self, async_client, archive_factory):
|
|
|
|
|
+ """NULL plate_id slips past the DB unique constraint, so the route must catch it."""
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ response = await async_client.post(
|
|
|
|
|
+ "/api/v1/queue/batches",
|
|
|
|
|
+ json={
|
|
|
|
|
+ "name": "Dupes",
|
|
|
|
|
+ "archive_id": archive.id,
|
|
|
|
|
+ "plates": [{"quantity_target": 1}, {"quantity_target": 2}],
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ assert response.status_code == 400
|
|
|
|
|
+ assert "whole file" in response.json()["detail"]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+@pytest.mark.integration
|
|
|
|
|
+class TestBatchOrderProgress:
|
|
|
|
|
+ async def test_failed_run_leaves_the_work_owed(self, async_client, printer_factory, archive_factory, db_session):
|
|
|
|
|
+ """The whole point of storing targets: a burned print is still owed."""
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
|
|
|
|
|
+
|
|
|
|
|
+ first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
|
|
|
|
|
+ second = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
|
|
|
|
|
+
|
|
|
|
|
+ await _set_status(db_session, first["id"], "completed")
|
|
|
|
|
+ await _set_status(db_session, second["id"], "failed")
|
|
|
|
|
+
|
|
|
|
|
+ response = await async_client.get(f"/api/v1/queue/batches/{order['id']}")
|
|
|
|
|
+ result = response.json()
|
|
|
|
|
+ assert result["completed_count"] == 1
|
|
|
|
|
+ assert result["failed_count"] == 1
|
|
|
|
|
+ # One completed, one burned — the order still owes a print.
|
|
|
|
|
+ assert result["remaining_count"] == 1
|
|
|
|
|
+ assert result["status"] == "active"
|
|
|
|
|
+
|
|
|
|
|
+ async def test_cancelled_run_also_leaves_the_work_owed(
|
|
|
|
|
+ self, async_client, printer_factory, archive_factory, db_session
|
|
|
|
|
+ ):
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 1}])
|
|
|
|
|
+ item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
|
|
|
|
|
+ await _set_status(db_session, item["id"], "cancelled")
|
|
|
|
|
+
|
|
|
|
|
+ result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
|
|
|
|
|
+ assert result["cancelled_count"] == 1
|
|
|
|
|
+ assert result["remaining_count"] == 1
|
|
|
|
|
+
|
|
|
|
|
+ async def test_pending_and_printing_consume_the_target(
|
|
|
|
|
+ self, async_client, printer_factory, archive_factory, db_session
|
|
|
|
|
+ ):
|
|
|
|
|
+ """In-flight work must not be re-dispatched — that would double-print."""
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
|
|
|
|
|
+ first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
|
|
|
|
|
+ await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
|
|
|
|
|
+ await _set_status(db_session, first["id"], "printing")
|
|
|
|
|
+
|
|
|
|
|
+ result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
|
|
|
|
|
+ assert result["printing_count"] == 1
|
|
|
|
|
+ assert result["pending_count"] == 1
|
|
|
|
|
+ assert result["remaining_count"] == 0
|
|
|
|
|
+
|
|
|
|
|
+ async def test_legacy_batch_without_targets_owes_nothing(self, async_client, printer_factory, archive_factory):
|
|
|
|
|
+ """Batches created before #342 keep working and report has_targets=false."""
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ response = await async_client.post(
|
|
|
|
|
+ "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 3}
|
|
|
|
|
+ )
|
|
|
|
|
+ batch_id = response.json()["batch_id"]
|
|
|
|
|
+
|
|
|
|
|
+ result = (await async_client.get(f"/api/v1/queue/batches/{batch_id}")).json()
|
|
|
|
|
+ assert result["has_targets"] is False
|
|
|
|
|
+ assert result["pending_count"] == 3
|
|
|
|
|
+ assert result["remaining_count"] == 0
|
|
|
|
|
+ assert result["target_count"] == 3
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+@pytest.mark.integration
|
|
|
|
|
+class TestBatchOrderCompletion:
|
|
|
|
|
+ async def test_status_flips_to_completed_when_targets_met(
|
|
|
|
|
+ self, async_client, printer_factory, archive_factory, db_session
|
|
|
|
|
+ ):
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 1}])
|
|
|
|
|
+ item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
|
|
|
|
|
+ await _set_status(db_session, item["id"], "completed")
|
|
|
|
|
+
|
|
|
|
|
+ # Reading the order re-evaluates it; the PATCH path and the print
|
|
|
|
|
+ # completion hook do the same.
|
|
|
|
|
+ patched = await async_client.patch(f"/api/v1/queue/batches/{order['id']}", json={})
|
|
|
|
|
+ assert patched.status_code == 200
|
|
|
|
|
+ assert patched.json()["status"] == "completed"
|
|
|
|
|
+ assert patched.json()["completed_at"] is not None
|
|
|
|
|
+
|
|
|
|
|
+ async def test_raising_a_target_reopens_a_completed_order(
|
|
|
|
|
+ self, async_client, printer_factory, archive_factory, db_session
|
|
|
|
|
+ ):
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 1}])
|
|
|
|
|
+ item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
|
|
|
|
|
+ await _set_status(db_session, item["id"], "completed")
|
|
|
|
|
+ assert (await async_client.patch(f"/api/v1/queue/batches/{order['id']}", json={})).json()[
|
|
|
|
|
+ "status"
|
|
|
|
|
+ ] == "completed"
|
|
|
|
|
+
|
|
|
|
|
+ reopened = await async_client.patch(
|
|
|
|
|
+ f"/api/v1/queue/batches/{order['id']}",
|
|
|
|
|
+ json={"plates": [{"plate_id": 1, "quantity_target": 3}]},
|
|
|
|
|
+ )
|
|
|
|
|
+ assert reopened.status_code == 200
|
|
|
|
|
+ assert reopened.json()["status"] == "active"
|
|
|
|
|
+ assert reopened.json()["completed_at"] is None
|
|
|
|
|
+ assert reopened.json()["remaining_count"] == 2
|
|
|
|
|
+
|
|
|
|
|
+ async def test_legacy_batch_with_everything_cancelled_reads_as_cancelled(
|
|
|
|
|
+ self, async_client, printer_factory, archive_factory, db_session
|
|
|
|
|
+ ):
|
|
|
|
|
+ """Cancelling every item of a grouping finishes it, but produces nothing.
|
|
|
|
|
+
|
|
|
|
|
+ "Completed" would be a lie — its derived target is zero — and leaving it
|
|
|
|
|
+ active would strand it on the Batches tab forever. Cancelled is what it
|
|
|
|
|
+ is, and is what the batch-level Cancel action would have set.
|
|
|
|
|
+ """
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ response = await async_client.post(
|
|
|
|
|
+ "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2}
|
|
|
|
|
+ )
|
|
|
|
|
+ batch_id = response.json()["batch_id"]
|
|
|
|
|
+
|
|
|
|
|
+ from sqlalchemy import select
|
|
|
|
|
+
|
|
|
|
|
+ from backend.app.models.print_queue import PrintQueueItem
|
|
|
|
|
+
|
|
|
|
|
+ items = (
|
|
|
|
|
+ (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.batch_id == batch_id)))
|
|
|
|
|
+ .scalars()
|
|
|
|
|
+ .all()
|
|
|
|
|
+ )
|
|
|
|
|
+ for item in items:
|
|
|
|
|
+ item.status = "cancelled"
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+
|
|
|
|
|
+ patched = await async_client.patch(f"/api/v1/queue/batches/{batch_id}", json={})
|
|
|
|
|
+ assert patched.status_code == 200
|
|
|
|
|
+ assert patched.json()["status"] == "cancelled"
|
|
|
|
|
+ assert patched.json()["completed_at"] is None
|
|
|
|
|
+
|
|
|
|
|
+ async def test_an_order_with_every_run_cancelled_still_owes_them(
|
|
|
|
|
+ self, async_client, printer_factory, archive_factory, db_session
|
|
|
|
|
+ ):
|
|
|
|
|
+ """The grouping rule must not leak into orders.
|
|
|
|
|
+
|
|
|
|
|
+ An order states its intent independently of its runs, so cancelling
|
|
|
|
|
+ them all leaves it owing the work and offering to re-queue.
|
|
|
|
|
+ """
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
|
|
|
|
|
+ first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
|
|
|
|
|
+ second = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
|
|
|
|
|
+ await _set_status(db_session, first["id"], "cancelled")
|
|
|
|
|
+ await _set_status(db_session, second["id"], "cancelled")
|
|
|
|
|
+
|
|
|
|
|
+ patched = (await async_client.patch(f"/api/v1/queue/batches/{order['id']}", json={})).json()
|
|
|
|
|
+ assert patched["status"] == "active"
|
|
|
|
|
+ assert patched["remaining_count"] == 2
|
|
|
|
|
+
|
|
|
|
|
+ async def test_cancelled_order_is_never_resurrected(
|
|
|
|
|
+ self, async_client, printer_factory, archive_factory, db_session
|
|
|
|
|
+ ):
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 1}])
|
|
|
|
|
+ item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
|
|
|
|
|
+ await async_client.delete(f"/api/v1/queue/batches/{order['id']}")
|
|
|
|
|
+ await _set_status(db_session, item["id"], "completed")
|
|
|
|
|
+
|
|
|
|
|
+ result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
|
|
|
|
|
+ assert result["status"] == "cancelled"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+@pytest.mark.integration
|
|
|
|
|
+class TestBatchBacklog:
|
|
|
|
|
+ """The Batches tab must not open on months of stale rows.
|
|
|
|
|
+
|
|
|
|
|
+ `completed` only became a reachable status with #342, so every batch
|
|
|
|
|
+ created since the feature shipped is still `active` however long ago its
|
|
|
|
|
+ last run finished.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ async def test_startup_backfill_closes_finished_batches(
|
|
|
|
|
+ self, async_client, printer_factory, archive_factory, db_session
|
|
|
|
|
+ ):
|
|
|
|
|
+ from backend.app.services.print_batch import backfill_batch_statuses
|
|
|
|
|
+
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ response = await async_client.post(
|
|
|
|
|
+ "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2}
|
|
|
|
|
+ )
|
|
|
|
|
+ batch_id = response.json()["batch_id"]
|
|
|
|
|
+
|
|
|
|
|
+ from sqlalchemy import select
|
|
|
|
|
+
|
|
|
|
|
+ from backend.app.models.print_queue import PrintQueueItem
|
|
|
|
|
+
|
|
|
|
|
+ items = (
|
|
|
|
|
+ (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.batch_id == batch_id)))
|
|
|
|
|
+ .scalars()
|
|
|
|
|
+ .all()
|
|
|
|
|
+ )
|
|
|
|
|
+ for item in items:
|
|
|
|
|
+ item.status = "completed"
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+
|
|
|
|
|
+ assert (await async_client.get(f"/api/v1/queue/batches/{batch_id}")).json()["status"] == "active"
|
|
|
|
|
+
|
|
|
|
|
+ changed = await backfill_batch_statuses(db_session)
|
|
|
|
|
+ assert changed >= 1
|
|
|
|
|
+ assert (await async_client.get(f"/api/v1/queue/batches/{batch_id}")).json()["status"] == "completed"
|
|
|
|
|
+
|
|
|
|
|
+ async def test_backfill_leaves_in_flight_batches_alone(
|
|
|
|
|
+ self, async_client, printer_factory, archive_factory, db_session
|
|
|
|
|
+ ):
|
|
|
|
|
+ from backend.app.services.print_batch import backfill_batch_statuses
|
|
|
|
|
+
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ response = await async_client.post(
|
|
|
|
|
+ "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2}
|
|
|
|
|
+ )
|
|
|
|
|
+ batch_id = response.json()["batch_id"]
|
|
|
|
|
+
|
|
|
|
|
+ await backfill_batch_statuses(db_session)
|
|
|
|
|
+ assert (await async_client.get(f"/api/v1/queue/batches/{batch_id}")).json()["status"] == "active"
|
|
|
|
|
+
|
|
|
|
|
+ async def test_backfill_is_idempotent(self, async_client, printer_factory, archive_factory, db_session):
|
|
|
|
|
+ from backend.app.services.print_batch import backfill_batch_statuses
|
|
|
|
|
+
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ response = await async_client.post(
|
|
|
|
|
+ "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 1}
|
|
|
|
|
+ )
|
|
|
|
|
+ item_id = response.json()["id"]
|
|
|
|
|
+ await _set_status(db_session, item_id, "completed")
|
|
|
|
|
+ order = await _create_order(async_client, archive.id, [{"plate_id": 9, "quantity_target": 1}])
|
|
|
|
|
+
|
|
|
|
|
+ first = await backfill_batch_statuses(db_session)
|
|
|
|
|
+ second = await backfill_batch_statuses(db_session)
|
|
|
|
|
+ assert second == 0, "a second pass must have nothing left to change"
|
|
|
|
|
+ assert first >= 0
|
|
|
|
|
+ # The untouched order owes work and stays active across both passes.
|
|
|
|
|
+ assert (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()["status"] == "active"
|
|
|
|
|
+
|
|
|
|
|
+ async def test_empty_shell_batches_are_not_listed(self, async_client, db_session):
|
|
|
|
|
+ """A grouping whose items went with their archive has nothing to show."""
|
|
|
|
|
+ from backend.app.models.print_batch import PrintBatch
|
|
|
|
|
+
|
|
|
|
|
+ shell = PrintBatch(name="Orphaned grouping", quantity=1, status="active")
|
|
|
|
|
+ db_session.add(shell)
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+ await db_session.refresh(shell)
|
|
|
|
|
+
|
|
|
|
|
+ listed = (await async_client.get("/api/v1/queue/batches")).json()
|
|
|
|
|
+ assert all(b["id"] != shell.id for b in listed)
|
|
|
|
|
+ # Still addressable directly — only the list hides it.
|
|
|
|
|
+ assert (await async_client.get(f"/api/v1/queue/batches/{shell.id}")).status_code == 200
|
|
|
|
|
+
|
|
|
|
|
+ async def test_a_new_order_is_listed_before_its_first_dispatch(self, async_client, archive_factory):
|
|
|
|
|
+ """Targets are enough to be worth showing — that is what it owes."""
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 3}])
|
|
|
|
|
+
|
|
|
|
|
+ listed = (await async_client.get("/api/v1/queue/batches")).json()
|
|
|
|
|
+ assert any(b["id"] == order["id"] for b in listed)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+@pytest.mark.integration
|
|
|
|
|
+class TestBatchOrderDispatch:
|
|
|
|
|
+ async def test_dispatch_clones_the_print_configuration(
|
|
|
|
|
+ self, async_client, printer_factory, archive_factory, db_session
|
|
|
|
|
+ ):
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(async_client, archive.id, [{"plate_id": 2, "quantity_target": 3}])
|
|
|
|
|
+ source = await _queue_item(
|
|
|
|
|
+ async_client,
|
|
|
|
|
+ printer.id,
|
|
|
|
|
+ archive.id,
|
|
|
|
|
+ order["id"],
|
|
|
|
|
+ plate_id=2,
|
|
|
|
|
+ timelapse=True,
|
|
|
|
|
+ use_ams=False,
|
|
|
|
|
+ bed_levelling="off",
|
|
|
|
|
+ ams_mapping=[3, -1],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
|
|
|
|
|
+ assert response.status_code == 200
|
|
|
|
|
+ assert response.json()["remaining_count"] == 0
|
|
|
|
|
+ assert response.json()["pending_count"] == 3
|
|
|
|
|
+
|
|
|
|
|
+ from sqlalchemy import select
|
|
|
|
|
+
|
|
|
|
|
+ from backend.app.models.print_queue import PrintQueueItem
|
|
|
|
|
+
|
|
|
|
|
+ rows = (
|
|
|
|
|
+ (
|
|
|
|
|
+ await db_session.execute(
|
|
|
|
|
+ select(PrintQueueItem).where(PrintQueueItem.batch_id == order["id"]).order_by(PrintQueueItem.id)
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ .scalars()
|
|
|
|
|
+ .all()
|
|
|
|
|
+ )
|
|
|
|
|
+ assert len(rows) == 3
|
|
|
|
|
+ clones = [r for r in rows if r.id != source["id"]]
|
|
|
|
|
+ for clone in clones:
|
|
|
|
|
+ assert clone.plate_id == 2
|
|
|
|
|
+ assert clone.printer_id == printer.id
|
|
|
|
|
+ assert clone.timelapse is True
|
|
|
|
|
+ assert clone.use_ams is False
|
|
|
|
|
+ assert clone.bed_levelling == "off"
|
|
|
|
|
+ assert clone.ams_mapping == "[3, -1]"
|
|
|
|
|
+ assert clone.status == "pending"
|
|
|
|
|
+ # Lifecycle state is not copied.
|
|
|
|
|
+ assert clone.started_at is None
|
|
|
|
|
+ assert clone.completed_at is None
|
|
|
|
|
+ assert clone.dispatch_attempts == 0
|
|
|
|
|
+ # Never replayed onto a clone: it would delete the source file out
|
|
|
|
|
+ # from under the rest of the order.
|
|
|
|
|
+ assert clone.cleanup_library_after_dispatch is False
|
|
|
|
|
+
|
|
|
|
|
+ async def test_clones_land_in_their_own_printer_queue(
|
|
|
|
|
+ self, async_client, printer_factory, archive_factory, db_session
|
|
|
|
|
+ ):
|
|
|
|
|
+ """Positions are per-printer sequences — a global MAX would scramble them."""
|
|
|
|
|
+ printer_a = await printer_factory()
|
|
|
|
|
+ printer_b = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(
|
|
|
|
|
+ async_client,
|
|
|
|
|
+ archive.id,
|
|
|
|
|
+ [{"plate_id": 1, "quantity_target": 3}, {"plate_id": 2, "quantity_target": 2}],
|
|
|
|
|
+ )
|
|
|
|
|
+ # Pad printer B's queue so a global MAX would push plate 1's clones
|
|
|
|
|
+ # past the end of printer A's much shorter queue.
|
|
|
|
|
+ for _ in range(5):
|
|
|
|
|
+ await async_client.post("/api/v1/queue/", json={"printer_id": printer_b.id, "archive_id": archive.id})
|
|
|
|
|
+ await _queue_item(async_client, printer_a.id, archive.id, order["id"], plate_id=1)
|
|
|
|
|
+ await _queue_item(async_client, printer_b.id, archive.id, order["id"], plate_id=2)
|
|
|
|
|
+
|
|
|
|
|
+ response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
|
|
|
|
|
+ assert response.status_code == 200
|
|
|
|
|
+
|
|
|
|
|
+ from sqlalchemy import select
|
|
|
|
|
+
|
|
|
|
|
+ from backend.app.models.print_queue import PrintQueueItem
|
|
|
|
|
+
|
|
|
|
|
+ for printer in (printer_a, printer_b):
|
|
|
|
|
+ rows = (
|
|
|
|
|
+ (
|
|
|
|
|
+ await db_session.execute(
|
|
|
|
|
+ select(PrintQueueItem)
|
|
|
|
|
+ .where(PrintQueueItem.printer_id == printer.id)
|
|
|
|
|
+ .where(PrintQueueItem.status == "pending")
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ .scalars()
|
|
|
|
|
+ .all()
|
|
|
|
|
+ )
|
|
|
|
|
+ positions = sorted(r.position for r in rows)
|
|
|
|
|
+ assert len(positions) == len(set(positions)), f"duplicate positions on printer {printer.id}"
|
|
|
|
|
+ assert positions == list(range(1, len(rows) + 1)), f"gap in printer {printer.id} queue"
|
|
|
|
|
+
|
|
|
|
|
+ async def test_clone_differs_from_its_source_only_in_lifecycle_state(
|
|
|
|
|
+ self, async_client, printer_factory, archive_factory, db_session
|
|
|
|
|
+ ):
|
|
|
|
|
+ """Guard for future columns.
|
|
|
|
|
+
|
|
|
|
|
+ A clone must carry every *setting* of the item it copies and reset
|
|
|
|
|
+ every piece of *lifecycle* state. Adding a new setting column to
|
|
|
|
|
+ PrintQueueItem without listing it in CLONED_SETTING_COLUMNS would make
|
|
|
|
|
+ the second run of a plate behave differently from the first — silently,
|
|
|
|
|
+ and on real hardware. This fails when that happens.
|
|
|
|
|
+ """
|
|
|
|
|
+ from sqlalchemy import inspect, select
|
|
|
|
|
+
|
|
|
|
|
+ from backend.app.models.print_queue import PrintQueueItem
|
|
|
|
|
+
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
|
|
|
|
|
+ source_id = (
|
|
|
|
|
+ await _queue_item(
|
|
|
|
|
+ async_client,
|
|
|
|
|
+ printer.id,
|
|
|
|
|
+ archive.id,
|
|
|
|
|
+ order["id"],
|
|
|
|
|
+ plate_id=1,
|
|
|
|
|
+ timelapse=True,
|
|
|
|
|
+ use_ams=False,
|
|
|
|
|
+ bed_levelling="off",
|
|
|
|
|
+ flow_cali="on",
|
|
|
|
|
+ vibration_cali=False,
|
|
|
|
|
+ layer_inspect=True,
|
|
|
|
|
+ gcode_injection=True,
|
|
|
|
|
+ auto_off_after=True,
|
|
|
|
|
+ require_previous_success=True,
|
|
|
|
|
+ )
|
|
|
|
|
+ )["id"]
|
|
|
|
|
+
|
|
|
|
|
+ # Dirty the source with scheduler state that must not be inherited.
|
|
|
|
|
+ source = await db_session.get(PrintQueueItem, source_id)
|
|
|
|
|
+ source.dispatch_attempts = 3
|
|
|
|
|
+ source.been_jumped = True
|
|
|
|
|
+ source.gate_acknowledged = True
|
|
|
|
|
+ source.filament_short = True
|
|
|
|
|
+ source.waiting_reason = "no idle printer"
|
|
|
|
|
+ source.error_message = "previous failure"
|
|
|
|
|
+ source.scheduled_time = datetime(2026, 1, 1, 12, 0, 0)
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+
|
|
|
|
|
+ assert (await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})).status_code == 200
|
|
|
|
|
+
|
|
|
|
|
+ clone = (
|
|
|
|
|
+ (
|
|
|
|
|
+ await db_session.execute(
|
|
|
|
|
+ select(PrintQueueItem)
|
|
|
|
|
+ .where(PrintQueueItem.batch_id == order["id"])
|
|
|
|
|
+ .where(PrintQueueItem.id != source_id)
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ .scalars()
|
|
|
|
|
+ .one()
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # Every column that is neither identity, ordering, nor deliberately reset
|
|
|
|
|
+ # must match the source exactly.
|
|
|
|
|
+ reset_on_clone = {
|
|
|
|
|
+ "status",
|
|
|
|
|
+ "waiting_reason",
|
|
|
|
|
+ "been_jumped",
|
|
|
|
|
+ "dispatch_attempts",
|
|
|
|
|
+ "dispatching_at",
|
|
|
|
|
+ "gate_acknowledged",
|
|
|
|
|
+ "filament_short",
|
|
|
|
|
+ "error_message",
|
|
|
|
|
+ "started_at",
|
|
|
|
|
+ "completed_at",
|
|
|
|
|
+ "scheduled_time",
|
|
|
|
|
+ "cleanup_library_after_dispatch",
|
|
|
|
|
+ }
|
|
|
|
|
+ identity = {"id", "created_at", "position"}
|
|
|
|
|
+
|
|
|
|
|
+ await db_session.refresh(source)
|
|
|
|
|
+ for column in (c.key for c in inspect(PrintQueueItem).mapper.column_attrs):
|
|
|
|
|
+ if column in identity or column in reset_on_clone:
|
|
|
|
|
+ continue
|
|
|
|
|
+ assert getattr(clone, column) == getattr(source, column), (
|
|
|
|
|
+ f"{column} was not carried onto the clone — a new setting column probably needs adding to "
|
|
|
|
|
+ "CLONED_SETTING_COLUMNS"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert clone.status == "pending"
|
|
|
|
|
+ assert clone.dispatch_attempts == 0
|
|
|
|
|
+ assert clone.been_jumped is False
|
|
|
|
|
+ assert clone.gate_acknowledged is False
|
|
|
|
|
+ assert clone.filament_short is False
|
|
|
|
|
+ assert clone.waiting_reason is None
|
|
|
|
|
+ assert clone.error_message is None
|
|
|
|
|
+ assert clone.started_at is None and clone.completed_at is None
|
|
|
|
|
+ # "Queue the rest now" must not replay a moment chosen for a different print.
|
|
|
|
|
+ assert clone.scheduled_time is None
|
|
|
|
|
+ # Would delete the source file out from under the rest of the order.
|
|
|
|
|
+ assert clone.cleanup_library_after_dispatch is False
|
|
|
|
|
+
|
|
|
|
|
+ async def test_dispatch_respects_limit(self, async_client, printer_factory, archive_factory):
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 10}])
|
|
|
|
|
+ await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
|
|
|
|
|
+
|
|
|
|
|
+ result = (await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={"limit": 4})).json()
|
|
|
|
|
+ assert result["pending_count"] == 5 # the original plus four
|
|
|
|
|
+ assert result["remaining_count"] == 5
|
|
|
|
|
+
|
|
|
|
|
+ async def test_dispatch_can_target_a_single_plate(self, async_client, printer_factory, archive_factory):
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(
|
|
|
|
|
+ async_client,
|
|
|
|
|
+ archive.id,
|
|
|
|
|
+ [{"plate_id": 1, "quantity_target": 2}, {"plate_id": 2, "quantity_target": 2}],
|
|
|
|
|
+ )
|
|
|
|
|
+ await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
|
|
|
|
|
+ await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=2)
|
|
|
|
|
+
|
|
|
|
|
+ result = (
|
|
|
|
|
+ await async_client.post(
|
|
|
|
|
+ f"/api/v1/queue/batches/{order['id']}/dispatch",
|
|
|
|
|
+ json={"plate_id": 2, "only_plate": True},
|
|
|
|
|
+ )
|
|
|
|
|
+ ).json()
|
|
|
|
|
+ plate_one = next(p for p in result["plates"] if p["plate_id"] == 1)
|
|
|
|
|
+ plate_two = next(p for p in result["plates"] if p["plate_id"] == 2)
|
|
|
|
|
+ assert plate_one["remaining"] == 1
|
|
|
|
|
+ assert plate_two["remaining"] == 0
|
|
|
|
|
+
|
|
|
|
|
+ async def test_dispatch_without_a_reference_item_is_rejected(self, async_client, archive_factory):
|
|
|
|
|
+ """Nothing to clone means no configuration to copy — say so, don't guess."""
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(async_client, archive.id, [{"plate_id": 4, "quantity_target": 2}])
|
|
|
|
|
+
|
|
|
|
|
+ response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
|
|
|
|
|
+ assert response.status_code == 400
|
|
|
|
|
+ assert "no queued or finished run" in response.json()["detail"]
|
|
|
|
|
+
|
|
|
|
|
+ async def test_dispatch_on_legacy_batch_is_a_noop(self, async_client, printer_factory, archive_factory):
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ response = await async_client.post(
|
|
|
|
|
+ "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2}
|
|
|
|
|
+ )
|
|
|
|
|
+ batch_id = response.json()["batch_id"]
|
|
|
|
|
+
|
|
|
|
|
+ result = (await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", json={})).json()
|
|
|
|
|
+ assert result["pending_count"] == 2
|
|
|
|
|
+
|
|
|
|
|
+ async def test_cannot_dispatch_a_cancelled_order(self, async_client, printer_factory, archive_factory):
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 3}])
|
|
|
|
|
+ await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
|
|
|
|
|
+ await async_client.delete(f"/api/v1/queue/batches/{order['id']}")
|
|
|
|
|
+
|
|
|
|
|
+ response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
|
|
|
|
|
+ assert response.status_code == 400
|
|
|
|
|
+ assert "cancelled" in response.json()["detail"]
|
|
|
|
|
+
|
|
|
|
|
+ async def test_redispatch_after_failure_replaces_the_burned_run(
|
|
|
|
|
+ self, async_client, printer_factory, archive_factory, db_session
|
|
|
|
|
+ ):
|
|
|
|
|
+ """End to end: 2 wanted, 1 completes, 1 fails, dispatch queues the replacement."""
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
|
|
|
|
|
+ first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
|
|
|
|
|
+ second = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
|
|
|
|
|
+ await _set_status(db_session, first["id"], "completed")
|
|
|
|
|
+ await _set_status(db_session, second["id"], "failed")
|
|
|
|
|
+
|
|
|
|
|
+ result = (await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})).json()
|
|
|
|
|
+ assert result["pending_count"] == 1
|
|
|
|
|
+ assert result["remaining_count"] == 0
|
|
|
|
|
+ assert result["status"] == "active"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+@pytest.mark.integration
|
|
|
|
|
+class TestBatchOrderHeader:
|
|
|
|
|
+ async def test_header_fields_round_trip(self, async_client, archive_factory):
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(
|
|
|
|
|
+ async_client,
|
|
|
|
|
+ archive.id,
|
|
|
|
|
+ [{"plate_id": 1, "quantity_target": 1}],
|
|
|
|
|
+ due_date="2026-09-01T12:00:00",
|
|
|
|
|
+ notes="Rush job",
|
|
|
|
|
+ )
|
|
|
|
|
+ assert order["notes"] == "Rush job"
|
|
|
|
|
+ assert order["due_date"].startswith("2026-09-01T12:00:00")
|
|
|
|
|
+
|
|
|
|
|
+ patched = (
|
|
|
|
|
+ await async_client.patch(
|
|
|
|
|
+ f"/api/v1/queue/batches/{order['id']}", json={"name": "Renamed", "notes": "Updated"}
|
|
|
|
|
+ )
|
|
|
|
|
+ ).json()
|
|
|
|
|
+ assert patched["name"] == "Renamed"
|
|
|
|
|
+ assert patched["notes"] == "Updated"
|
|
|
|
|
+
|
|
|
|
|
+ async def test_unknown_project_is_rejected(self, async_client, archive_factory):
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ response = await async_client.post(
|
|
|
|
|
+ "/api/v1/queue/batches",
|
|
|
|
|
+ json={
|
|
|
|
|
+ "name": "Order",
|
|
|
|
|
+ "archive_id": archive.id,
|
|
|
|
|
+ "project_id": 999999,
|
|
|
|
|
+ "plates": [{"plate_id": 1, "quantity_target": 1}],
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ assert response.status_code == 404
|
|
|
|
|
+
|
|
|
|
|
+ async def test_patch_replaces_the_target_set(self, async_client, archive_factory):
|
|
|
|
|
+ """A plate omitted from the payload has its target row removed."""
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(
|
|
|
|
|
+ async_client,
|
|
|
|
|
+ archive.id,
|
|
|
|
|
+ [{"plate_id": 1, "quantity_target": 1}, {"plate_id": 2, "quantity_target": 1}],
|
|
|
|
|
+ )
|
|
|
|
|
+ patched = (
|
|
|
|
|
+ await async_client.patch(
|
|
|
|
|
+ f"/api/v1/queue/batches/{order['id']}",
|
|
|
|
|
+ json={"plates": [{"plate_id": 1, "quantity_target": 5}]},
|
|
|
|
|
+ )
|
|
|
|
|
+ ).json()
|
|
|
|
|
+ assert [p["plate_id"] for p in patched["plates"]] == [1]
|
|
|
|
|
+ assert patched["target_count"] == 5
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+@pytest.mark.integration
|
|
|
|
|
+class TestBatchOrderCost:
|
|
|
|
|
+ async def test_cost_rolls_up_from_logged_runs(self, async_client, printer_factory, archive_factory, db_session):
|
|
|
|
|
+ """Cost is attributed through queue_item_id, not guessed from the archive."""
|
|
|
|
|
+ from backend.app.models.print_log import PrintLogEntry
|
|
|
|
|
+
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 4}])
|
|
|
|
|
+ first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
|
|
|
|
|
+ second = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
|
|
|
|
|
+ await _set_status(db_session, first["id"], "completed")
|
|
|
|
|
+ await _set_status(db_session, second["id"], "completed")
|
|
|
|
|
+
|
|
|
|
|
+ db_session.add(
|
|
|
|
|
+ PrintLogEntry(
|
|
|
|
|
+ archive_id=archive.id,
|
|
|
|
|
+ queue_item_id=first["id"],
|
|
|
|
|
+ status="completed",
|
|
|
|
|
+ cost=2.0,
|
|
|
|
|
+ energy_cost=0.5,
|
|
|
|
|
+ filament_used_grams=40.0,
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ db_session.add(
|
|
|
|
|
+ PrintLogEntry(
|
|
|
|
|
+ archive_id=archive.id,
|
|
|
|
|
+ queue_item_id=second["id"],
|
|
|
|
|
+ status="completed",
|
|
|
|
|
+ cost=3.0,
|
|
|
|
|
+ energy_cost=0.5,
|
|
|
|
|
+ filament_used_grams=60.0,
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ # A run of the same archive that has nothing to do with this order.
|
|
|
|
|
+ db_session.add(PrintLogEntry(archive_id=archive.id, queue_item_id=None, status="completed", cost=99.0))
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+
|
|
|
|
|
+ result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
|
|
|
|
|
+ assert result["actual_cost"] == pytest.approx(6.0)
|
|
|
|
|
+ assert result["filament_used_grams"] == pytest.approx(100.0)
|
|
|
|
|
+ # Two completed at 3.00 each, two still owed.
|
|
|
|
|
+ assert result["estimated_remaining_cost"] == pytest.approx(6.0)
|
|
|
|
|
+
|
|
|
|
|
+ async def test_cost_is_unknown_not_zero_before_the_first_run(self, async_client, printer_factory, archive_factory):
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory()
|
|
|
|
|
+ order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
|
|
|
|
|
+ await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
|
|
|
|
|
+
|
|
|
|
|
+ result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
|
|
|
|
|
+ assert result["actual_cost"] is None
|
|
|
|
|
+ assert result["estimated_remaining_cost"] is None
|