Ver código fonte

Keep the last run a batch order can re-queue a plate from

An order produces what it still owes by cloning an existing queue item for
the same plate. That row is the only record of the printer target, AMS
mapping and print options the user chose, so deleting the last one left the
order reporting work outstanding that nothing could produce, and no way to
close it out: Cancel was hidden unless there were pending items to cancel,
which by then there were none.

Deleting an order's last surviving run for a plate now cancels it instead.
A cancelled run does not satisfy a target, so the order still owes the print
and can still make it. A completed run is exempt and still deletes outright
-- rewriting it as cancelled would falsify what the order produced.

Also: the response reports per plate whether anything is left to clone, so
the card explains a stranded plate rather than offering a button that can
only fail; dispatch skips a stranded plate instead of aborting the whole
order; and Cancel is offered for any active order.
maziggy 1 semana atrás
pai
commit
196fcaf15b

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
CHANGELOG.md


+ 89 - 3
backend/app/api/routes/print_queue.py

@@ -50,6 +50,7 @@ from backend.app.services.print_batch import (
     dispatch_remaining,
     dispatch_remaining,
     load_progress,
     load_progress,
     refresh_batch_status,
     refresh_batch_status,
+    refresh_batch_status_for_item,
 )
 )
 from backend.app.services.print_cost_estimate import estimate_queue_source_cost
 from backend.app.services.print_cost_estimate import estimate_queue_source_cost
 from backend.app.utils.printer_models import (
 from backend.app.utils.printer_models import (
@@ -201,6 +202,65 @@ def _assert_can_queue_library_file(library_file: LibraryFile, current_user: User
         raise HTTPException(404, "Library file not found")
         raise HTTPException(404, "Library file not found")
 
 
 
 
+async def _is_orders_last_source(db: AsyncSession, item: PrintQueueItem) -> bool:
+    """True when deleting *item* would leave an order owing runs it can't queue.
+
+    Dispatch produces the runs an order still owes by cloning an existing
+    queue item for the same plate — that row is the only record of the printer
+    target, AMS mapping and print options the user chose. Delete the last one
+    while the plate still has a target and the order is stranded: it goes on
+    reporting work outstanding with no way left to produce it (#2960).
+    """
+    if item.batch_id is None:
+        return False
+
+    # A cancelled order can never dispatch again, so nothing about it can be
+    # stranded and its leftover rows must stay deletable — tidying up after a
+    # cancel is the most likely reason anyone deletes them.
+    status = (await db.execute(select(PrintBatch.status).where(PrintBatch.id == item.batch_id))).scalar_one_or_none()
+    if status == "cancelled":
+        return False
+
+    plate_scope = (
+        PrintBatchPlate.plate_id == item.plate_id if item.plate_id is not None else PrintBatchPlate.plate_id.is_(None)
+    )
+    # first(), not scalar_one_or_none(): a UNIQUE(batch_id, plate_id) does not
+    # constrain NULL plate_ids on either dialect, and a duplicate whole-file
+    # row must not turn a delete into a 500.
+    target = (
+        (
+            await db.execute(
+                select(PrintBatchPlate.quantity_target)
+                .where(PrintBatchPlate.batch_id == item.batch_id)
+                .where(plate_scope)
+                .order_by(PrintBatchPlate.quantity_target.desc())
+                .limit(1)
+            )
+        )
+        .scalars()
+        .first()
+    )
+    # No target row: a grouping, or a plate assigned into the order by hand.
+    # A target of 0 is legal and means the plate is not wanted. Neither owes
+    # anything, so neither can be stranded.
+    if not target:
+        return False
+
+    item_scope = (
+        PrintQueueItem.plate_id == item.plate_id if item.plate_id is not None else PrintQueueItem.plate_id.is_(None)
+    )
+    survivor = (
+        await db.execute(
+            select(PrintQueueItem.id)
+            .where(PrintQueueItem.batch_id == item.batch_id)
+            .where(item_scope)
+            .where(PrintQueueItem.id != item.id)
+            .limit(1)
+        )
+    ).scalar_one_or_none()
+    return survivor is None
+
+
 async def _assert_can_dispatch_batch_sources(db: AsyncSession, batch_id: int, current_user: User | None) -> None:
 async def _assert_can_dispatch_batch_sources(db: AsyncSession, batch_id: int, current_user: User | None) -> None:
     """Apply the ``POST /queue/`` source-file gates to everything a dispatch would print.
     """Apply the ``POST /queue/`` source-file gates to everything a dispatch would print.
 
 
@@ -1743,6 +1803,7 @@ async def _build_batch_response(
         has_targets=progress.has_targets,
         has_targets=progress.has_targets,
         target_count=progress.target,
         target_count=progress.target,
         remaining_count=progress.remaining,
         remaining_count=progress.remaining,
+        dispatchable_count=progress.dispatchable_remaining,
         actual_cost=progress.actual_cost,
         actual_cost=progress.actual_cost,
         estimated_remaining_cost=progress.estimated_remaining_cost,
         estimated_remaining_cost=progress.estimated_remaining_cost,
         filament_used_grams=progress.filament_used_grams,
         filament_used_grams=progress.filament_used_grams,
@@ -1764,6 +1825,7 @@ async def _build_batch_response(
                 estimated_remaining_cost=plate.estimated_remaining_cost,
                 estimated_remaining_cost=plate.estimated_remaining_cost,
                 filament_used_grams=plate.filament_used_grams,
                 filament_used_grams=plate.filament_used_grams,
                 print_time_seconds=plate.print_time_seconds,
                 print_time_seconds=plate.print_time_seconds,
+                can_dispatch=plate.can_dispatch,
             )
             )
             for plate in progress.plates
             for plate in progress.plates
         ],
         ],
@@ -1992,7 +2054,15 @@ async def delete_queue_item(
         )
         )
     ),
     ),
 ):
 ):
-    """Remove an item from the queue."""
+    """Remove an item from the queue.
+
+    An order's last surviving run for a plate is cancelled instead of deleted
+    (#2960). The row is what a later dispatch clones, so removing it would
+    leave the order reporting work outstanding that nothing could ever
+    produce. A completed run is exempt: it is the record of something that was
+    actually made, and rewriting it as cancelled would falsify the order's
+    progress.
+    """
     user, can_modify_all = auth_result
     user, can_modify_all = auth_result
 
 
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
@@ -2008,13 +2078,22 @@ async def delete_queue_item(
     if item.status == "printing":
     if item.status == "printing":
         raise HTTPException(400, "Cannot delete item that is currently printing")
         raise HTTPException(400, "Cannot delete item that is currently printing")
 
 
+    keep_as_cancelled = item.status != "completed" and await _is_orders_last_source(db, item)
+
     await release_budget_reservation(
     await release_budget_reservation(
         db,
         db,
         source_type="print_queue",
         source_type="print_queue",
         source_id=item.id,
         source_id=item.id,
         status="released",
         status="released",
     )
     )
-    await db.delete(item)
+    if keep_as_cancelled:
+        item.status = "cancelled"
+        await db.flush()
+        # The order may have been sitting on "completed" if this run's target
+        # was met by it; cancelling reopens it.
+        await refresh_batch_status_for_item(db, item.id)
+    else:
+        await db.delete(item)
     await db.commit()
     await db.commit()
 
 
     # Stop an in-flight preheat for this item: the dispatch coroutine is
     # Stop an in-flight preheat for this item: the dispatch coroutine is
@@ -2023,8 +2102,15 @@ async def delete_queue_item(
 
 
     _scheduler.notify_dispatch_cancelled(item_id)
     _scheduler.notify_dispatch_cancelled(item_id)
 
 
+    if keep_as_cancelled:
+        logger.info("Kept queue item %s as cancelled — last source for its batch order plate", item_id)
+        return {
+            "message": "Item cancelled rather than deleted: it is the only run the order can re-queue this plate from",
+            "deleted": False,
+        }
+
     logger.info("Deleted queue item %s", item_id)
     logger.info("Deleted queue item %s", item_id)
-    return {"message": "Queue item deleted"}
+    return {"message": "Queue item deleted", "deleted": True}
 
 
 
 
 @router.post("/reorder")
 @router.post("/reorder")

+ 6 - 0
backend/app/schemas/print_queue.py

@@ -444,6 +444,9 @@ class PrintBatchPlateProgress(BaseModel):
     estimated_remaining_cost: float | None = None
     estimated_remaining_cost: float | None = None
     filament_used_grams: float | None = None
     filament_used_grams: float | None = None
     print_time_seconds: int = 0
     print_time_seconds: int = 0
+    # False when this plate owes runs but has no queue item left to clone
+    # their configuration from, so offering to queue it would only fail.
+    can_dispatch: bool = False
 
 
 
 
 class PrintBatchResponse(BaseModel):
 class PrintBatchResponse(BaseModel):
@@ -475,6 +478,9 @@ class PrintBatchResponse(BaseModel):
     has_targets: bool = False
     has_targets: bool = False
     target_count: int = 0
     target_count: int = 0
     remaining_count: int = 0
     remaining_count: int = 0
+    # Of ``remaining_count``, how many runs can actually be queued. Lower when
+    # a plate's last queue item was deleted (#2960).
+    dispatchable_count: int = 0
     actual_cost: float | None = None
     actual_cost: float | None = None
     estimated_remaining_cost: float | None = None
     estimated_remaining_cost: float | None = None
     filament_used_grams: float | None = None
     filament_used_grams: float | None = None

+ 54 - 7
backend/app/services/print_batch.py

@@ -114,6 +114,10 @@ class PlateProgress:
     actual_cost: float | None = None
     actual_cost: float | None = None
     filament_used_grams: float | None = None
     filament_used_grams: float | None = None
     print_time_seconds: int = 0
     print_time_seconds: int = 0
+    # Whether any queue item for this plate still exists, in any status.
+    # Dispatch clones one to inherit the print configuration, so a plate with
+    # none cannot be queued however many runs it still owes.
+    has_source: bool = False
 
 
     @property
     @property
     def dispatched(self) -> int:
     def dispatched(self) -> int:
@@ -123,6 +127,11 @@ class PlateProgress:
     def remaining(self) -> int:
     def remaining(self) -> int:
         return max(0, self.quantity_target - self.dispatched)
         return max(0, self.quantity_target - self.dispatched)
 
 
+    @property
+    def can_dispatch(self) -> bool:
+        """True when this plate owes runs *and* something can produce them."""
+        return self.remaining > 0 and self.has_source
+
     @property
     @property
     def cost_per_run(self) -> float | None:
     def cost_per_run(self) -> float | None:
         """Observed mean cost of this plate's completed runs, or None.
         """Observed mean cost of this plate's completed runs, or None.
@@ -185,6 +194,16 @@ class BatchProgress:
     def remaining(self) -> int:
     def remaining(self) -> int:
         return self._sum("remaining")
         return self._sum("remaining")
 
 
+    @property
+    def dispatchable_remaining(self) -> int:
+        """Of the runs still owed, how many can actually be queued.
+
+        Lower than ``remaining`` when a plate's last queue item was deleted:
+        the order still owes the run, but nothing survives to clone its
+        printer target, AMS mapping and print options from.
+        """
+        return sum(p.remaining for p in self.plates if p.has_source)
+
     @property
     @property
     def actual_cost(self) -> float | None:
     def actual_cost(self) -> float | None:
         costs = [p.actual_cost for p in self.plates if p.actual_cost is not None]
         costs = [p.actual_cost for p in self.plates if p.actual_cost is not None]
@@ -282,6 +301,11 @@ async def load_progress(db: AsyncSession, batch: PrintBatch) -> BatchProgress:
                 plate.quantity_target += count
                 plate.quantity_target += count
         elif not progress.has_targets and status in CONSUMING_STATUSES:
         elif not progress.has_targets and status in CONSUMING_STATUSES:
             plate.quantity_target += count
             plate.quantity_target += count
+        # Any surviving row is a clone source, whatever its status — a
+        # cancelled or failed run still carries the configuration a re-queue
+        # needs. Set before the status filter below so a status this module
+        # has no counter for still marks the plate dispatchable.
+        plate.has_source = True
         if status in COUNTED_STATUSES:
         if status in COUNTED_STATUSES:
             setattr(plate, status, getattr(plate, status) + count)
             setattr(plate, status, getattr(plate, status) + count)
         else:
         else:
@@ -462,6 +486,18 @@ def _clone_queue_item(source: PrintQueueItem, *, position: int, created_by_id: i
     return clone
     return clone
 
 
 
 
+def _plate_label(plate: PlateProgress) -> str:
+    """How a plate is named in an error the user reads.
+
+    Prefers the plate name the order stored, because that is what the Batches
+    tab shows; falls back to the plate number for orders created before names
+    were recorded.
+    """
+    if plate.plate_name:
+        return plate.plate_name
+    return f"Plate {plate.plate_id if plate.plate_id is not None else 1}"
+
+
 async def dispatch_remaining(
 async def dispatch_remaining(
     db: AsyncSession,
     db: AsyncSession,
     batch: PrintBatch,
     batch: PrintBatch,
@@ -478,9 +514,12 @@ async def dispatch_remaining(
     otherwise every plate with work outstanding is dispatched in plate order.
     otherwise every plate with work outstanding is dispatched in plate order.
     ``limit`` caps the total number of items created across all plates.
     ``limit`` caps the total number of items created across all plates.
 
 
-    Raises :class:`BatchDispatchError` when a plate owes runs but has no
-    existing item to clone — the order can describe work it has never once
-    dispatched, and there is no configuration to copy in that case.
+    Raises :class:`BatchDispatchError` when nothing at all can be produced
+    because no plate that owes runs has an item to clone — the order can
+    describe work whose every run has since been deleted, and there is no
+    configuration to copy in that case. A plate in that state is skipped
+    rather than aborting the whole order: one unrecoverable plate must not
+    block the plates that are still perfectly dispatchable.
     """
     """
     progress = await load_progress(db, batch)
     progress = await load_progress(db, batch)
     if not progress.has_targets:
     if not progress.has_targets:
@@ -491,6 +530,7 @@ async def dispatch_remaining(
         targets = [p for p in targets if p.plate_id == plate_id]
         targets = [p for p in targets if p.plate_id == plate_id]
 
 
     created: list[PrintQueueItem] = []
     created: list[PrintQueueItem] = []
+    stranded: list[PlateProgress] = []
 
 
     for plate in targets:
     for plate in targets:
         if limit is not None and len(created) >= limit:
         if limit is not None and len(created) >= limit:
@@ -508,10 +548,8 @@ async def dispatch_remaining(
         ).scalar_one_or_none()
         ).scalar_one_or_none()
 
 
         if source is None:
         if source is None:
-            raise BatchDispatchError(
-                f"Plate {plate.plate_id if plate.plate_id is not None else 1} has no queued or finished run to "
-                "copy settings from. Queue it once from the file, then dispatch the rest from here."
-            )
+            stranded.append(plate)
+            continue
 
 
         wanted = plate.remaining
         wanted = plate.remaining
         if limit is not None:
         if limit is not None:
@@ -533,11 +571,20 @@ async def dispatch_remaining(
                 db.add(cloned_variant)
                 db.add(cloned_variant)
             created.append(clone)
             created.append(clone)
 
 
+    if not created and stranded:
+        names = ", ".join(_plate_label(p) for p in stranded)
+        raise BatchDispatchError(
+            f"{names} {'have' if len(stranded) > 1 else 'has'} no queued or finished run to copy settings from. "
+            "Queue the plate once from the file, then dispatch the rest from here."
+        )
+
     if created:
     if created:
         # Dispatching more work can only ever un-fulfil an order, but run the
         # Dispatching more work can only ever un-fulfil an order, but run the
         # check anyway so a reopened batch flips back from completed.
         # check anyway so a reopened batch flips back from completed.
         await db.flush()
         await db.flush()
         await refresh_batch_status(db, batch)
         await refresh_batch_status(db, batch)
 
 
+    if stranded:
+        logger.info("Batch %s: skipped %d plate(s) with no item to clone", batch.id, len(stranded))
     logger.info("Dispatched %d item(s) for batch %s", len(created), batch.id)
     logger.info("Dispatched %d item(s) for batch %s", len(created), batch.id)
     return created
     return created

+ 197 - 0
backend/tests/integration/test_print_batch_orders.py

@@ -683,6 +683,70 @@ class TestBatchOrderDispatch:
         assert response.status_code == 400
         assert response.status_code == 400
         assert "no queued or finished run" in response.json()["detail"]
         assert "no queued or finished run" in response.json()["detail"]
 
 
+    async def test_one_stranded_plate_does_not_block_the_others(self, async_client, printer_factory, archive_factory):
+        """A plate with nothing to clone is skipped, not an abort for the whole order (#2960)."""
+        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}],
+        )
+        # Plate 2 never gets an item, so only plate 1 can be cloned.
+        await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+
+        response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
+        assert response.status_code == 200, response.text
+        result = response.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"] == 0
+        assert plate_two["remaining"] == 2
+        # The order still owes plate 2 but reports that none of it is queueable.
+        assert result["remaining_count"] == 2
+        assert result["dispatchable_count"] == 0
+        assert plate_one["can_dispatch"] is False  # nothing left owing
+        assert plate_two["can_dispatch"] is False  # owing, but nothing to clone
+
+    async def test_dispatching_a_stranded_plate_by_name_still_fails_loudly(
+        self, async_client, printer_factory, archive_factory
+    ):
+        """An explicit single-plate request must not silently do nothing."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+        order = await _create_order(
+            async_client,
+            archive.id,
+            [
+                {"plate_id": 1, "quantity_target": 1},
+                {"plate_id": 2, "plate_name": "Side rail.stl", "quantity_target": 1},
+            ],
+        )
+        await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+
+        response = await async_client.post(
+            f"/api/v1/queue/batches/{order['id']}/dispatch",
+            json={"plate_id": 2, "only_plate": True},
+        )
+        assert response.status_code == 400
+        # Named by the label the Batches tab shows, not by a bare index.
+        assert "Side rail.stl" in response.json()["detail"]
+
+    async def test_can_dispatch_is_true_while_a_source_survives(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        """A cancelled run is still a clone source — the plate stays queueable."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+        order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
+        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["remaining_count"] == 2
+        assert result["dispatchable_count"] == 2
+        assert result["plates"][0]["can_dispatch"] is True
+
     async def test_dispatch_on_legacy_batch_is_a_noop(self, async_client, printer_factory, archive_factory):
     async def test_dispatch_on_legacy_batch_is_a_noop(self, async_client, printer_factory, archive_factory):
         printer = await printer_factory()
         printer = await printer_factory()
         archive = await archive_factory()
         archive = await archive_factory()
@@ -831,3 +895,136 @@ class TestBatchOrderCost:
         result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
         result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
         assert result["actual_cost"] is None
         assert result["actual_cost"] is None
         assert result["estimated_remaining_cost"] is None
         assert result["estimated_remaining_cost"] is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestOrderSourcePreservation:
+    """Deleting an order's last run for a plate must not strand it (#2960).
+
+    Dispatch produces what an order still owes by cloning an existing queue
+    item. Hard-deleting the last one left the order reporting work outstanding
+    with nothing able to produce it, and no way to close it out either.
+    """
+
+    async def test_last_run_for_a_plate_is_cancelled_not_deleted(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        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": 3}])
+        item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+
+        response = await async_client.delete(f"/api/v1/queue/{item['id']}")
+        assert response.status_code == 200
+        assert response.json()["deleted"] is False
+
+        db_session.expire_all()
+        survivor = await db_session.get(PrintQueueItem, item["id"])
+        assert survivor is not None
+        assert survivor.status == "cancelled"
+
+    async def test_the_order_can_still_be_dispatched_afterwards(self, async_client, printer_factory, archive_factory):
+        """The whole point: #2960's stuck card, end to end."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+        order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
+        item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+        await async_client.delete(f"/api/v1/queue/{item['id']}")
+
+        # A cancelled run does not consume a target, so the order owes both.
+        state = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
+        assert state["remaining_count"] == 2
+        assert state["dispatchable_count"] == 2
+
+        result = (await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})).json()
+        assert result["pending_count"] == 2
+        assert result["remaining_count"] == 0
+
+    async def test_a_run_with_a_sibling_is_really_deleted(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        """Only the *last* source is protected — nothing else changes."""
+        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": 3}])
+        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)
+
+        response = await async_client.delete(f"/api/v1/queue/{first['id']}")
+        assert response.json()["deleted"] is True
+        db_session.expire_all()
+        assert await db_session.get(PrintQueueItem, first["id"]) is None
+
+    async def test_a_completed_run_is_never_rewritten_as_cancelled(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        """Deleting history is the user's call; falsifying it is not."""
+        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": 3}])
+        item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+        await _set_status(db_session, item["id"], "completed")
+
+        response = await async_client.delete(f"/api/v1/queue/{item['id']}")
+        assert response.json()["deleted"] is True
+        db_session.expire_all()
+        assert await db_session.get(PrintQueueItem, item["id"]) is None
+
+    async def test_a_grouping_without_targets_deletes_as_before(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        """A grouping owes nothing, so nothing about it can be stranded."""
+        from backend.app.models.print_queue import PrintQueueItem
+
+        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}
+        )
+        items = response.json()
+        first = items[0] if isinstance(items, list) else items
+
+        deleted = await async_client.delete(f"/api/v1/queue/{first['id']}")
+        assert deleted.json()["deleted"] is True
+        db_session.expire_all()
+        assert await db_session.get(PrintQueueItem, first["id"]) is None
+
+    async def test_a_cancelled_order_lets_its_leftover_rows_be_deleted(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        """Nothing to protect once the order is closed — and tidying up is why you delete."""
+        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": 3}])
+        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']}")
+
+        deleted = await async_client.delete(f"/api/v1/queue/{item['id']}")
+        assert deleted.json()["deleted"] is True
+        db_session.expire_all()
+        assert await db_session.get(PrintQueueItem, item["id"]) is None
+
+    async def test_a_plate_the_order_has_no_target_for_deletes_as_before(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        """Grouped in by hand after the fact: it owes nothing, so delete means delete."""
+        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": 1}])
+        stray = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=7)
+
+        deleted = await async_client.delete(f"/api/v1/queue/{stray['id']}")
+        assert deleted.json()["deleted"] is True
+        db_session.expire_all()
+        assert await db_session.get(PrintQueueItem, stray["id"]) is None

+ 81 - 20
frontend/src/__tests__/components/BatchOrdersView.test.tsx

@@ -15,26 +15,33 @@ import { server } from '../mocks/server';
 import { BatchOrdersView } from '../../components/BatchOrdersView';
 import { BatchOrdersView } from '../../components/BatchOrdersView';
 import type { PrintBatch, PrintBatchPlateProgress } from '../../api/client';
 import type { PrintBatch, PrintBatchPlateProgress } from '../../api/client';
 
 
-const plate = (over: Partial<PrintBatchPlateProgress> = {}): PrintBatchPlateProgress => ({
-  plate_id: 1,
-  plate_name: null,
-  quantity_target: 1,
-  dispatched: 1,
-  remaining: 0,
-  pending_count: 0,
-  printing_count: 0,
-  completed_count: 1,
-  failed_count: 0,
-  cancelled_count: 0,
-  skipped_count: 0,
-  actual_cost: null,
-  estimated_remaining_cost: null,
-  filament_used_grams: null,
-  print_time_seconds: 0,
-  ...over,
-});
+const plate = (over: Partial<PrintBatchPlateProgress> = {}): PrintBatchPlateProgress => {
+  const merged = {
+    plate_id: 1,
+    plate_name: null,
+    quantity_target: 1,
+    dispatched: 1,
+    remaining: 0,
+    pending_count: 0,
+    printing_count: 0,
+    completed_count: 1,
+    failed_count: 0,
+    cancelled_count: 0,
+    skipped_count: 0,
+    actual_cost: null,
+    estimated_remaining_cost: null,
+    filament_used_grams: null,
+    print_time_seconds: 0,
+    can_dispatch: false,
+    ...over,
+  } satisfies PrintBatchPlateProgress;
+  // A plate keeps something to clone unless a test says otherwise, so by
+  // default anything still owed is queueable (#2960).
+  return { ...merged, can_dispatch: over.can_dispatch ?? merged.remaining > 0 };
+};
 
 
-const batch = (over: Partial<PrintBatch> = {}): PrintBatch => ({
+const batch = (over: Partial<PrintBatch> = {}): PrintBatch => {
+  const merged = {
   id: 1,
   id: 1,
   name: 'Widget run',
   name: 'Widget run',
   archive_id: 7,
   archive_id: 7,
@@ -62,8 +69,11 @@ const batch = (over: Partial<PrintBatch> = {}): PrintBatch => ({
   filament_used_grams: null,
   filament_used_grams: null,
   print_time_seconds: 0,
   print_time_seconds: 0,
   plates: [],
   plates: [],
+  dispatchable_count: 0,
   ...over,
   ...over,
-});
+  } satisfies PrintBatch;
+  return { ...merged, dispatchable_count: over.dispatchable_count ?? merged.remaining_count };
+};
 
 
 const allow = () => true;
 const allow = () => true;
 const deny = () => false;
 const deny = () => false;
@@ -226,4 +236,55 @@ describe('BatchOrdersView (#342)', () => {
     // One cost line, belonging to the priced order — never a fabricated 0.00.
     // One cost line, belonging to the priced order — never a fabricated 0.00.
     expect(screen.getAllByText('queue.batchOrders.costSoFar')).toHaveLength(1);
     expect(screen.getAllByText('queue.batchOrders.costSoFar')).toHaveLength(1);
   });
   });
+
+  it('explains a plate that cannot be queued instead of offering a button that fails', async () => {
+    // #2960: the plate's last queue item was deleted, so there is nothing left
+    // to clone its configuration from. The card used to offer "Queue remaining"
+    // anyway and answer with an error toast.
+    server.use(
+      http.get('/api/v1/queue/batches', () =>
+        HttpResponse.json([
+          batch({
+            target_count: 3,
+            remaining_count: 3,
+            dispatchable_count: 0,
+            plates: [
+              plate({ plate_id: 1, quantity_target: 3, dispatched: 0, completed_count: 0, remaining: 3, can_dispatch: false }),
+            ],
+          }),
+        ]),
+      ),
+    );
+    render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
+
+    await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
+    expect(screen.getByText('queue.batchOrders.strandedNotice')).toBeInTheDocument();
+    expect(screen.getByText('queue.batchOrders.strandedPlate')).toBeInTheDocument();
+    expect(
+      screen.queryByRole('button', { name: /queue.batchOrders.dispatchRemaining/ }),
+    ).not.toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: 'queue.batchOrders.dispatchPlate' })).not.toBeInTheDocument();
+  });
+
+  it('still offers to close an order with nothing pending to cancel', async () => {
+    // #2960: cancel is the only way out of an order whose runs were all
+    // deleted, and that order has no pending items by definition.
+    server.use(
+      http.get('/api/v1/queue/batches', () =>
+        HttpResponse.json([
+          batch({
+            pending_count: 0,
+            target_count: 3,
+            remaining_count: 3,
+            dispatchable_count: 0,
+            plates: [plate({ quantity_target: 3, dispatched: 0, completed_count: 0, remaining: 3, can_dispatch: false })],
+          }),
+        ]),
+      ),
+    );
+    render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
+
+    await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
+    expect(screen.getByRole('button', { name: 'queue.cancelBatch' })).toBeInTheDocument();
+  });
 });
 });

+ 8 - 1
frontend/src/api/client.ts

@@ -2578,6 +2578,9 @@ export interface PrintBatchPlateProgress {
   estimated_remaining_cost: number | null;
   estimated_remaining_cost: number | null;
   filament_used_grams: number | null;
   filament_used_grams: number | null;
   print_time_seconds: number;
   print_time_seconds: number;
+  /** False when this plate owes runs but has no queue item left to clone
+   *  their configuration from, so queueing it could only fail (#2960). */
+  can_dispatch: boolean;
 }
 }
 
 
 export interface PrintBatch {
 export interface PrintBatch {
@@ -2605,6 +2608,8 @@ export interface PrintBatch {
   has_targets: boolean;
   has_targets: boolean;
   target_count: number;
   target_count: number;
   remaining_count: number;
   remaining_count: number;
+  /** Of remaining_count, how many runs can actually be queued (#2960). */
+  dispatchable_count: number;
   actual_cost: number | null;
   actual_cost: number | null;
   estimated_remaining_cost: number | null;
   estimated_remaining_cost: number | null;
   filament_used_grams: number | null;
   filament_used_grams: number | null;
@@ -5855,8 +5860,10 @@ export const api = {
       method: 'PATCH',
       method: 'PATCH',
       body: JSON.stringify(data),
       body: JSON.stringify(data),
     }),
     }),
+  /** `deleted` is false when the item was kept as cancelled instead: it is the
+   *  last run a batch order could re-queue its plate from (#2960). */
   removeFromQueue: (id: number) =>
   removeFromQueue: (id: number) =>
-    request<{ message: string }>(`/queue/${id}`, { method: 'DELETE' }),
+    request<{ message: string; deleted: boolean }>(`/queue/${id}`, { method: 'DELETE' }),
   reorderQueue: (items: { id: number; position: number }[]) =>
   reorderQueue: (items: { id: number; position: number }[]) =>
     request<{ message: string }>('/queue/reorder', {
     request<{ message: string }>('/queue/reorder', {
       method: 'POST',
       method: 'POST',

+ 32 - 12
frontend/src/components/BatchOrdersView.tsx

@@ -163,6 +163,9 @@ function BatchOrderCard({
   const denominator = batch.has_targets ? batch.target_count : batch.completed_count + batch.pending_count
   const denominator = batch.has_targets ? batch.target_count : batch.completed_count + batch.pending_count
     + batch.printing_count + batch.failed_count;
     + batch.printing_count + batch.failed_count;
   const percent = denominator > 0 ? Math.round((batch.completed_count / denominator) * 100) : 0;
   const percent = denominator > 0 ? Math.round((batch.completed_count / denominator) * 100) : 0;
+  // Runs the order owes that nothing can produce any more: their plate's last
+  // queue item was deleted, so there is no configuration left to clone (#2960).
+  const strandedCount = batch.has_targets ? batch.remaining_count - batch.dispatchable_count : 0;
   const dueDate = batch.due_date ? parseUTCDate(batch.due_date) : null;
   const dueDate = batch.due_date ? parseUTCDate(batch.due_date) : null;
   const isOverdue = dueDate != null && batch.status === 'active' && dueDate.getTime() < Date.now();
   const isOverdue = dueDate != null && batch.status === 'active' && dueDate.getTime() < Date.now();
 
 
@@ -199,13 +202,17 @@ function BatchOrderCard({
         </div>
         </div>
 
 
         <div className="flex items-center gap-2 flex-shrink-0">
         <div className="flex items-center gap-2 flex-shrink-0">
-          {batch.has_targets && batch.remaining_count > 0 && batch.status !== 'cancelled' && canDispatch && (
+          {batch.has_targets && batch.dispatchable_count > 0 && batch.status !== 'cancelled' && canDispatch && (
             <Button variant="primary" size="sm" onClick={() => onDispatch()} disabled={isDispatching}>
             <Button variant="primary" size="sm" onClick={() => onDispatch()} disabled={isDispatching}>
               <PlayCircle className="w-4 h-4 mr-1" />
               <PlayCircle className="w-4 h-4 mr-1" />
-              {t('queue.batchOrders.dispatchRemaining', { count: batch.remaining_count })}
+              {t('queue.batchOrders.dispatchRemaining', { count: batch.dispatchable_count })}
             </Button>
             </Button>
           )}
           )}
-          {batch.status === 'active' && batch.pending_count > 0 && canCancel && (
+          {/* Cancel is the only way to close an order out, so it must not be
+              gated on there being pending items to cancel: an order whose runs
+              were all deleted has none, and is exactly the one that needs
+              closing (#2960). */}
+          {batch.status === 'active' && canCancel && (
             <Button variant="ghost" size="sm" onClick={onCancel}>
             <Button variant="ghost" size="sm" onClick={onCancel}>
               <XCircle className="w-4 h-4 mr-1" />
               <XCircle className="w-4 h-4 mr-1" />
               {t('queue.cancelBatch')}
               {t('queue.cancelBatch')}
@@ -265,6 +272,13 @@ function BatchOrderCard({
         )}
         )}
       </div>
       </div>
 
 
+      {strandedCount > 0 && batch.status !== 'cancelled' && (
+        <p className="flex items-start gap-1.5 text-xs text-orange-600 dark:text-orange-400 mb-1">
+          <AlertTriangle className="w-3 h-3 mt-0.5 flex-shrink-0" />
+          <span>{t('queue.batchOrders.strandedNotice', { runs: strandedCount, owed: batch.remaining_count })}</span>
+        </p>
+      )}
+
       {batch.has_targets && batch.plates.length > 0 && (
       {batch.has_targets && batch.plates.length > 0 && (
         <div className="mt-3 border-t border-bambu-dark-tertiary pt-3 space-y-1.5">
         <div className="mt-3 border-t border-bambu-dark-tertiary pt-3 space-y-1.5">
           {batch.plates.map((plate) => (
           {batch.plates.map((plate) => (
@@ -328,15 +342,21 @@ function PlateRow({
           <span className="text-bambu-gray">
           <span className="text-bambu-gray">
             {t('queue.batchOrders.remaining', { count: plate.remaining })}
             {t('queue.batchOrders.remaining', { count: plate.remaining })}
           </span>
           </span>
-          {canDispatch && (
-            <button
-              type="button"
-              onClick={onDispatch}
-              disabled={isDispatching}
-              className="text-bambu-green hover:underline disabled:opacity-50"
-            >
-              {t('queue.batchOrders.dispatchPlate')}
-            </button>
+          {!plate.can_dispatch ? (
+            <span className="text-orange-600 dark:text-orange-400">
+              {t('queue.batchOrders.strandedPlate')}
+            </span>
+          ) : (
+            canDispatch && (
+              <button
+                type="button"
+                onClick={onDispatch}
+                disabled={isDispatching}
+                className="text-bambu-green hover:underline disabled:opacity-50"
+              >
+                {t('queue.batchOrders.dispatchPlate')}
+              </button>
+            )
           )}
           )}
         </span>
         </span>
       )}
       )}

+ 5 - 1
frontend/src/i18n/locales/de.ts

@@ -1364,6 +1364,8 @@ export default {
       noTargetsHint: 'Vor der Einführung von Zielmengen pro Platte erstellt; verfolgt daher die eingereihten Durchläufe statt einer Zielmenge.',
       noTargetsHint: 'Vor der Einführung von Zielmengen pro Platte erstellt; verfolgt daher die eingereihten Durchläufe statt einer Zielmenge.',
       dispatchRemaining: '{{count}} verbleibende einreihen',
       dispatchRemaining: '{{count}} verbleibende einreihen',
       dispatchPlate: 'Rest einreihen',
       dispatchPlate: 'Rest einreihen',
+      strandedPlate: 'Keine Vorlage mehr zum Kopieren',
+      strandedNotice: 'Von den {{owed}} noch offenen Durchläufen lassen sich {{runs}} nicht einreihen: Für diese Platte gibt es keinen eingereihten oder abgeschlossenen Durchlauf mehr, von dem sich die Einstellungen kopieren lassen. Reihe die Platte einmal aus der Datei ein oder brich den Auftrag ab.',
       dispatched: 'Verbleibende Durchläufe für {{name}} eingereiht',
       dispatched: 'Verbleibende Durchläufe für {{name}} eingereiht',
       plateProgress: '{{completed}} von {{target}} fertig',
       plateProgress: '{{completed}} von {{target}} fertig',
       remaining: '{{count}} noch offen',
       remaining: '{{count}} noch offen',
@@ -1379,7 +1381,7 @@ export default {
     cancelBatch: 'Verbleibende abbrechen',
     cancelBatch: 'Verbleibende abbrechen',
     batchCancelled: 'Verbleibende Stapeleinträge abgebrochen',
     batchCancelled: 'Verbleibende Stapeleinträge abgebrochen',
     cancelBatchConfirmTitle: 'Stapel abbrechen',
     cancelBatchConfirmTitle: 'Stapel abbrechen',
-    cancelBatchConfirmMessage: 'Alle verbleibenden ausstehenden Einträge in diesem Stapel abbrechen?',
+    cancelBatchConfirmMessage: 'Diesen Auftrag schließen? Noch eingereihte Durchläufe werden abgebrochen, und er fordert nicht länger, was er nicht produziert hat.',
     batch: {
     batch: {
       defaultName: 'Stapel',
       defaultName: 'Stapel',
       label: '{{count}} Eintrag',
       label: '{{count}} Eintrag',
@@ -1538,6 +1540,8 @@ export default {
       cancelled: 'Warteschlangenelement abgebrochen',
       cancelled: 'Warteschlangenelement abgebrochen',
       cancelFailed: 'Element konnte nicht abgebrochen werden',
       cancelFailed: 'Element konnte nicht abgebrochen werden',
       removed: 'Warteschlangenelement entfernt',
       removed: 'Warteschlangenelement entfernt',
+      keptForOrder: 'Abgebrochen statt entfernt: Nur aus diesem Durchlauf kann der Stapelauftrag diese Platte erneut einreihen',
+      historyKeptForOrders: '{{kept}} behalten, die Stapelaufträge noch brauchen.',
       removeFailed: 'Element konnte nicht entfernt werden',
       removeFailed: 'Element konnte nicht entfernt werden',
       stopped: 'Druck gestoppt',
       stopped: 'Druck gestoppt',
       stopFailed: 'Druck konnte nicht gestoppt werden',
       stopFailed: 'Druck konnte nicht gestoppt werden',

+ 5 - 1
frontend/src/i18n/locales/en.ts

@@ -1374,6 +1374,8 @@ export default {
       noTargetsHint: 'Created before per-plate targets existed, so it tracks the runs it queued rather than a target.',
       noTargetsHint: 'Created before per-plate targets existed, so it tracks the runs it queued rather than a target.',
       dispatchRemaining: 'Queue {{count}} remaining',
       dispatchRemaining: 'Queue {{count}} remaining',
       dispatchPlate: 'Queue remaining',
       dispatchPlate: 'Queue remaining',
+      strandedPlate: 'Nothing left to copy settings from',
+      strandedNotice: 'Cannot queue {{runs}} of the {{owed}} runs still owed: that plate has no queued or finished run left to copy settings from. Queue the plate once from the file, or cancel the order.',
       dispatched: 'Queued the remaining runs for {{name}}',
       dispatched: 'Queued the remaining runs for {{name}}',
       plateProgress: '{{completed}} of {{target}} done',
       plateProgress: '{{completed}} of {{target}} done',
       remaining: '{{count}} still owed',
       remaining: '{{count}} still owed',
@@ -1389,7 +1391,7 @@ export default {
     cancelBatch: 'Cancel Remaining',
     cancelBatch: 'Cancel Remaining',
     batchCancelled: 'Remaining batch items cancelled',
     batchCancelled: 'Remaining batch items cancelled',
     cancelBatchConfirmTitle: 'Cancel Batch',
     cancelBatchConfirmTitle: 'Cancel Batch',
-    cancelBatchConfirmMessage: 'Cancel all remaining pending items in this batch?',
+    cancelBatchConfirmMessage: 'Close this order? Any runs still queued are cancelled, and it stops asking for what it has not produced.',
     batch: {
     batch: {
       defaultName: 'Batch',
       defaultName: 'Batch',
       label: '{{count}} item',
       label: '{{count}} item',
@@ -1554,6 +1556,8 @@ export default {
       cancelled: 'Queue item cancelled',
       cancelled: 'Queue item cancelled',
       cancelFailed: 'Failed to cancel item',
       cancelFailed: 'Failed to cancel item',
       removed: 'Queue item removed',
       removed: 'Queue item removed',
+      keptForOrder: 'Cancelled instead of removed: this is the only run its batch order can re-queue this plate from',
+      historyKeptForOrders: 'Kept {{kept}} that batch orders still need.',
       removeFailed: 'Failed to remove item',
       removeFailed: 'Failed to remove item',
       stopped: 'Print stopped',
       stopped: 'Print stopped',
       stopFailed: 'Failed to stop print',
       stopFailed: 'Failed to stop print',

+ 5 - 1
frontend/src/i18n/locales/es.ts

@@ -1364,6 +1364,8 @@ export default {
       noTargetsHint: 'Creado antes de que existieran las cantidades por bandeja, así que sigue las impresiones en cola en lugar de un objetivo.',
       noTargetsHint: 'Creado antes de que existieran las cantidades por bandeja, así que sigue las impresiones en cola en lugar de un objetivo.',
       dispatchRemaining: 'Encolar {{count}} restantes',
       dispatchRemaining: 'Encolar {{count}} restantes',
       dispatchPlate: 'Encolar restantes',
       dispatchPlate: 'Encolar restantes',
+      strandedPlate: 'No queda de dónde copiar los ajustes',
+      strandedNotice: 'No se pueden encolar {{runs}} de las {{owed}} impresiones aún pendientes: esa cama ya no tiene ninguna impresión encolada o terminada de la que copiar los ajustes. Encola la cama una vez desde el archivo o cancela el pedido.',
       dispatched: 'Impresiones restantes encoladas para {{name}}',
       dispatched: 'Impresiones restantes encoladas para {{name}}',
       plateProgress: '{{completed}} de {{target}} hechas',
       plateProgress: '{{completed}} de {{target}} hechas',
       remaining: '{{count}} pendientes',
       remaining: '{{count}} pendientes',
@@ -1379,7 +1381,7 @@ export default {
     cancelBatch: 'Cancelar los restantes',
     cancelBatch: 'Cancelar los restantes',
     batchCancelled: 'Elementos restantes del lote cancelados',
     batchCancelled: 'Elementos restantes del lote cancelados',
     cancelBatchConfirmTitle: 'Cancelar lote',
     cancelBatchConfirmTitle: 'Cancelar lote',
-    cancelBatchConfirmMessage: '¿Cancelar todos los elementos pendientes restantes de este lote?',
+    cancelBatchConfirmMessage: '¿Cerrar este pedido? Las impresiones que sigan en la cola se cancelan y deja de reclamar lo que no ha producido.',
     batch: {
     batch: {
       defaultName: 'Lote',
       defaultName: 'Lote',
       label: '{{count}} elemento',
       label: '{{count}} elemento',
@@ -1538,6 +1540,8 @@ export default {
       cancelled: 'Elemento de la cola cancelado',
       cancelled: 'Elemento de la cola cancelado',
       cancelFailed: 'Error al cancelar el elemento',
       cancelFailed: 'Error al cancelar el elemento',
       removed: 'Elemento de la cola eliminado',
       removed: 'Elemento de la cola eliminado',
+      keptForOrder: 'Cancelado en lugar de eliminado: es la única impresión desde la que el pedido puede volver a encolar esta cama',
+      historyKeptForOrders: 'Se conservan {{kept}} que los pedidos todavía necesitan.',
       removeFailed: 'Error al eliminar el elemento',
       removeFailed: 'Error al eliminar el elemento',
       stopped: 'Impresión detenida',
       stopped: 'Impresión detenida',
       stopFailed: 'Error al detener la impresión',
       stopFailed: 'Error al detener la impresión',

+ 5 - 1
frontend/src/i18n/locales/fr.ts

@@ -1364,6 +1364,8 @@ export default {
       noTargetsHint: 'Créée avant les quantités par plateau : elle suit les impressions mises en file plutôt qu\'un objectif.',
       noTargetsHint: 'Créée avant les quantités par plateau : elle suit les impressions mises en file plutôt qu\'un objectif.',
       dispatchRemaining: 'Mettre en file les {{count}} restantes',
       dispatchRemaining: 'Mettre en file les {{count}} restantes',
       dispatchPlate: 'Mettre le reste en file',
       dispatchPlate: 'Mettre le reste en file',
+      strandedPlate: 'Aucun réglage à copier',
+      strandedNotice: 'Impossible de mettre en file {{runs}} des {{owed}} impressions encore dues : aucune impression en file ou terminée ne subsiste pour cette plaque, donc plus aucun réglage à copier. Mettez la plaque en file une fois depuis le fichier, ou annulez la commande.',
       dispatched: 'Impressions restantes mises en file pour {{name}}',
       dispatched: 'Impressions restantes mises en file pour {{name}}',
       plateProgress: '{{completed}} sur {{target}} faites',
       plateProgress: '{{completed}} sur {{target}} faites',
       remaining: '{{count}} encore dues',
       remaining: '{{count}} encore dues',
@@ -1379,7 +1381,7 @@ export default {
     cancelBatch: 'Annuler les restants',
     cancelBatch: 'Annuler les restants',
     batchCancelled: 'Éléments restants du lot annulés',
     batchCancelled: 'Éléments restants du lot annulés',
     cancelBatchConfirmTitle: 'Annuler le lot',
     cancelBatchConfirmTitle: 'Annuler le lot',
-    cancelBatchConfirmMessage: 'Annuler tous les éléments en attente restants dans ce lot ?',
+    cancelBatchConfirmMessage: 'Clôturer cette commande ? Les impressions encore en file sont annulées, et elle cesse de réclamer ce qui n\'a pas été produit.',
     batch: {
     batch: {
       defaultName: 'Lot',
       defaultName: 'Lot',
       label: '{{count}} élément',
       label: '{{count}} élément',
@@ -1538,6 +1540,8 @@ export default {
       cancelled: 'Élément annulé',
       cancelled: 'Élément annulé',
       cancelFailed: 'Échec annulation',
       cancelFailed: 'Échec annulation',
       removed: 'Élément retiré',
       removed: 'Élément retiré',
+      keptForOrder: 'Annulé au lieu d\'être retiré : cette impression est la seule dont la commande peut remettre cette plaque en file',
+      historyKeptForOrders: '{{kept}} conservées car des commandes en ont encore besoin.',
       removeFailed: 'Échec retrait',
       removeFailed: 'Échec retrait',
       stopped: 'Impression arrêtée',
       stopped: 'Impression arrêtée',
       stopFailed: 'Échec arrêt',
       stopFailed: 'Échec arrêt',

+ 5 - 1
frontend/src/i18n/locales/it.ts

@@ -1364,6 +1364,8 @@ export default {
       noTargetsHint: 'Creato prima delle quantità per piatto: tiene traccia delle stampe in coda anziché di un obiettivo.',
       noTargetsHint: 'Creato prima delle quantità per piatto: tiene traccia delle stampe in coda anziché di un obiettivo.',
       dispatchRemaining: 'Accoda {{count}} rimanenti',
       dispatchRemaining: 'Accoda {{count}} rimanenti',
       dispatchPlate: 'Accoda le rimanenti',
       dispatchPlate: 'Accoda le rimanenti',
+      strandedPlate: 'Nessuna impostazione da copiare',
+      strandedNotice: 'Impossibile accodare {{runs}} delle {{owed}} stampe ancora dovute: per quella piastra non resta alcuna stampa in coda o completata da cui copiare le impostazioni. Accoda la piastra una volta dal file oppure annulla la commessa.',
       dispatched: 'Stampe rimanenti accodate per {{name}}',
       dispatched: 'Stampe rimanenti accodate per {{name}}',
       plateProgress: '{{completed}} di {{target}} fatte',
       plateProgress: '{{completed}} di {{target}} fatte',
       remaining: '{{count}} ancora dovute',
       remaining: '{{count}} ancora dovute',
@@ -1379,7 +1381,7 @@ export default {
     cancelBatch: 'Annulla rimanenti',
     cancelBatch: 'Annulla rimanenti',
     batchCancelled: 'Elementi rimanenti del lotto annullati',
     batchCancelled: 'Elementi rimanenti del lotto annullati',
     cancelBatchConfirmTitle: 'Annulla lotto',
     cancelBatchConfirmTitle: 'Annulla lotto',
-    cancelBatchConfirmMessage: 'Annullare tutti gli elementi in sospeso rimanenti in questo lotto?',
+    cancelBatchConfirmMessage: 'Chiudere questa commessa? Le stampe ancora in coda vengono annullate e la commessa smette di richiedere ciò che non ha prodotto.',
     batch: {
     batch: {
       defaultName: 'Lotto',
       defaultName: 'Lotto',
       label: '{{count}} elemento',
       label: '{{count}} elemento',
@@ -1538,6 +1540,8 @@ export default {
       cancelled: 'Elemento coda annullato',
       cancelled: 'Elemento coda annullato',
       cancelFailed: 'Annullamento non riuscito',
       cancelFailed: 'Annullamento non riuscito',
       removed: 'Elemento coda rimosso',
       removed: 'Elemento coda rimosso',
+      keptForOrder: 'Annullato anziché rimosso: è l\'unica stampa da cui la commessa può riaccodare questa piastra',
+      historyKeptForOrders: '{{kept}} conservate perché servono ancora alle commesse.',
       removeFailed: 'Rimozione non riuscita',
       removeFailed: 'Rimozione non riuscita',
       stopped: 'Stampa fermata',
       stopped: 'Stampa fermata',
       stopFailed: 'Impossibile fermare stampa',
       stopFailed: 'Impossibile fermare stampa',

+ 5 - 1
frontend/src/i18n/locales/ja.ts

@@ -1363,6 +1363,8 @@ export default {
       noTargetsHint: 'プレートごとの目標数が導入される前に作成されたため、目標ではなくキューに入れた実行を追跡します。',
       noTargetsHint: 'プレートごとの目標数が導入される前に作成されたため、目標ではなくキューに入れた実行を追跡します。',
       dispatchRemaining: '残り{{count}}件をキューに追加',
       dispatchRemaining: '残り{{count}}件をキューに追加',
       dispatchPlate: '残りをキューに追加',
       dispatchPlate: '残りをキューに追加',
+      strandedPlate: '設定をコピーできる実行がありません',
+      strandedNotice: '未実行の {{owed}} 件のうち {{runs}} 件をキューに追加できません。このプレートには、設定をコピーできるキュー内または完了済みの実行が残っていません。ファイルからこのプレートを一度キューに追加するか、オーダーをキャンセルしてください。',
       dispatched: '{{name}} の残りの実行をキューに追加しました',
       dispatched: '{{name}} の残りの実行をキューに追加しました',
       plateProgress: '{{target}}件中{{completed}}件完了',
       plateProgress: '{{target}}件中{{completed}}件完了',
       remaining: '残り{{count}}件',
       remaining: '残り{{count}}件',
@@ -1378,7 +1380,7 @@ export default {
     cancelBatch: '残りをキャンセル',
     cancelBatch: '残りをキャンセル',
     batchCancelled: '残りのバッチアイテムをキャンセルしました',
     batchCancelled: '残りのバッチアイテムをキャンセルしました',
     cancelBatchConfirmTitle: 'バッチをキャンセル',
     cancelBatchConfirmTitle: 'バッチをキャンセル',
-    cancelBatchConfirmMessage: 'このバッチの残りの保留中アイテムをすべてキャンセルしますか?',
+    cancelBatchConfirmMessage: 'このオーダーを終了しますか?キューに残っている実行はキャンセルされ、未生産分の要求も止まります。',
     batch: {
     batch: {
       defaultName: 'バッチ',
       defaultName: 'バッチ',
       label: '{{count}}件',
       label: '{{count}}件',
@@ -1537,6 +1539,8 @@ export default {
       cancelled: 'キャンセル済み',
       cancelled: 'キャンセル済み',
       cancelFailed: 'アイテムのキャンセルに失敗しました',
       cancelFailed: 'アイテムのキャンセルに失敗しました',
       removed: 'キューアイテムを削除しました',
       removed: 'キューアイテムを削除しました',
+      keptForOrder: '削除ではなくキャンセルしました: バッチオーダーがこのプレートを再度キューに追加できる唯一の実行です',
+      historyKeptForOrders: 'バッチオーダーがまだ必要とする {{kept}} 件は残しました。',
       removeFailed: 'プロジェクトからのアーカイブ削除に失敗しました',
       removeFailed: 'プロジェクトからのアーカイブ削除に失敗しました',
       stopped: '印刷を停止しました',
       stopped: '印刷を停止しました',
       stopFailed: '印刷の停止に失敗しました',
       stopFailed: '印刷の停止に失敗しました',

+ 5 - 1
frontend/src/i18n/locales/ko.ts

@@ -1301,6 +1301,8 @@ export default {
       noTargetsHint: '플레이트별 목표 수량이 도입되기 전에 생성되어 목표가 아닌 대기열에 넣은 실행을 추적합니다.',
       noTargetsHint: '플레이트별 목표 수량이 도입되기 전에 생성되어 목표가 아닌 대기열에 넣은 실행을 추적합니다.',
       dispatchRemaining: '남은 {{count}}개 대기열 추가',
       dispatchRemaining: '남은 {{count}}개 대기열 추가',
       dispatchPlate: '남은 항목 추가',
       dispatchPlate: '남은 항목 추가',
+      strandedPlate: '설정을 복사할 실행이 없습니다',
+      strandedNotice: '아직 남은 {{owed}}건 중 {{runs}}건을 대기열에 추가할 수 없습니다. 해당 플레이트에는 설정을 복사할 대기 중이거나 완료된 실행이 남아 있지 않습니다. 파일에서 해당 플레이트를 한 번 대기열에 추가하거나 주문을 취소하세요.',
       dispatched: '{{name}}의 남은 실행을 대기열에 추가했습니다',
       dispatched: '{{name}}의 남은 실행을 대기열에 추가했습니다',
       plateProgress: '{{target}}개 중 {{completed}}개 완료',
       plateProgress: '{{target}}개 중 {{completed}}개 완료',
       remaining: '{{count}}개 남음',
       remaining: '{{count}}개 남음',
@@ -1316,7 +1318,7 @@ export default {
     cancelBatch: '나머지 취소',
     cancelBatch: '나머지 취소',
     batchCancelled: '남은 배치 항목이 취소되었습니다',
     batchCancelled: '남은 배치 항목이 취소되었습니다',
     cancelBatchConfirmTitle: '배치 취소',
     cancelBatchConfirmTitle: '배치 취소',
-    cancelBatchConfirmMessage: '이 배치의 남은 대기 항목을 모두 취소하시겠습니까?',
+    cancelBatchConfirmMessage: '이 주문을 종료할까요? 대기열에 남은 실행은 취소되고, 생산하지 못한 분량도 더 이상 요구하지 않습니다.',
     batch: {
     batch: {
       defaultName: '배치',
       defaultName: '배치',
       label: '{{count}}개 항목',
       label: '{{count}}개 항목',
@@ -1463,6 +1465,8 @@ export default {
       cancelled: '대기열 항목이 취소되었습니다',
       cancelled: '대기열 항목이 취소되었습니다',
       cancelFailed: '항목 취소 실패',
       cancelFailed: '항목 취소 실패',
       removed: '대기열 항목이 제거되었습니다',
       removed: '대기열 항목이 제거되었습니다',
+      keptForOrder: '제거 대신 취소했습니다: 배치 주문이 이 플레이트를 다시 대기열에 넣을 수 있는 유일한 실행입니다',
+      historyKeptForOrders: '배치 주문에 아직 필요한 {{kept}}건은 남겨 두었습니다.',
       removeFailed: '항목 제거 실패',
       removeFailed: '항목 제거 실패',
       stopped: '인쇄가 정지되었습니다',
       stopped: '인쇄가 정지되었습니다',
       stopFailed: '인쇄 정지 실패',
       stopFailed: '인쇄 정지 실패',

+ 5 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -1364,6 +1364,8 @@ export default {
       noTargetsHint: 'Criado antes das quantidades por mesa, então acompanha as impressões enfileiradas em vez de uma meta.',
       noTargetsHint: 'Criado antes das quantidades por mesa, então acompanha as impressões enfileiradas em vez de uma meta.',
       dispatchRemaining: 'Enfileirar {{count}} restantes',
       dispatchRemaining: 'Enfileirar {{count}} restantes',
       dispatchPlate: 'Enfileirar restantes',
       dispatchPlate: 'Enfileirar restantes',
+      strandedPlate: 'Nada de onde copiar as configurações',
+      strandedNotice: 'Não é possível enfileirar {{runs}} das {{owed}} impressões ainda devidas: essa placa não tem mais nenhuma impressão na fila ou concluída de onde copiar as configurações. Enfileire a placa uma vez a partir do arquivo ou cancele o pedido.',
       dispatched: 'Impressões restantes enfileiradas para {{name}}',
       dispatched: 'Impressões restantes enfileiradas para {{name}}',
       plateProgress: '{{completed}} de {{target}} prontas',
       plateProgress: '{{completed}} de {{target}} prontas',
       remaining: '{{count}} ainda devidas',
       remaining: '{{count}} ainda devidas',
@@ -1379,7 +1381,7 @@ export default {
     cancelBatch: 'Cancelar restantes',
     cancelBatch: 'Cancelar restantes',
     batchCancelled: 'Itens restantes do lote cancelados',
     batchCancelled: 'Itens restantes do lote cancelados',
     cancelBatchConfirmTitle: 'Cancelar lote',
     cancelBatchConfirmTitle: 'Cancelar lote',
-    cancelBatchConfirmMessage: 'Cancelar todos os itens pendentes restantes neste lote?',
+    cancelBatchConfirmMessage: 'Encerrar este pedido? As impressões ainda na fila são canceladas e ele para de cobrar o que não produziu.',
     batch: {
     batch: {
       defaultName: 'Lote',
       defaultName: 'Lote',
       label: '{{count}} item',
       label: '{{count}} item',
@@ -1538,6 +1540,8 @@ export default {
       cancelled: 'Item da fila cancelado',
       cancelled: 'Item da fila cancelado',
       cancelFailed: 'Falha ao cancelar item',
       cancelFailed: 'Falha ao cancelar item',
       removed: 'Item da fila removido',
       removed: 'Item da fila removido',
+      keptForOrder: 'Cancelado em vez de removido: é a única impressão de onde o pedido pode reenfileirar esta placa',
+      historyKeptForOrders: '{{kept}} mantidas porque pedidos ainda precisam delas.',
       removeFailed: 'Falha ao remover item',
       removeFailed: 'Falha ao remover item',
       stopped: 'Impressão parada',
       stopped: 'Impressão parada',
       stopFailed: 'Falha ao parar impressão',
       stopFailed: 'Falha ao parar impressão',

+ 5 - 1
frontend/src/i18n/locales/ru.ts

@@ -1311,6 +1311,8 @@ export default {
       noTargetsHint: "Создан до появления целевых количеств по столам, поэтому отслеживает поставленные в очередь запуски, а не цель.",
       noTargetsHint: "Создан до появления целевых количеств по столам, поэтому отслеживает поставленные в очередь запуски, а не цель.",
       dispatchRemaining: "Поставить в очередь: {{count}}",
       dispatchRemaining: "Поставить в очередь: {{count}}",
       dispatchPlate: "Поставить остаток",
       dispatchPlate: "Поставить остаток",
+      strandedPlate: "Нет запуска для копирования настроек",
+      strandedNotice: "Не удаётся поставить в очередь {{runs}} из {{owed}} ещё не выполненных запусков: для этой пластины не осталось запуска в очереди или завершённого, из которого можно скопировать настройки. Поставьте пластину в очередь один раз из файла или отмените заказ.",
       dispatched: "Оставшиеся запуски для «{{name}}» поставлены в очередь",
       dispatched: "Оставшиеся запуски для «{{name}}» поставлены в очередь",
       plateProgress: "Готово {{completed}} из {{target}}",
       plateProgress: "Готово {{completed}} из {{target}}",
       remaining: "Осталось: {{count}}",
       remaining: "Осталось: {{count}}",
@@ -1326,7 +1328,7 @@ export default {
     cancelBatch: "Отменить оставшиеся",
     cancelBatch: "Отменить оставшиеся",
     batchCancelled: "Оставшиеся задания партии отменены",
     batchCancelled: "Оставшиеся задания партии отменены",
     cancelBatchConfirmTitle: "Отменить партию",
     cancelBatchConfirmTitle: "Отменить партию",
-    cancelBatchConfirmMessage: "Отменить все оставшиеся задания этой партии?",
+    cancelBatchConfirmMessage: "Закрыть этот заказ? Запуски, ещё стоящие в очереди, будут отменены, и заказ перестанет требовать то, что не произведено.",
     batch: {
     batch: {
       defaultName: "Партия",
       defaultName: "Партия",
       label: "{{count}} задание",
       label: "{{count}} задание",
@@ -1473,6 +1475,8 @@ export default {
       cancelled: "Задание отменено",
       cancelled: "Задание отменено",
       cancelFailed: "Не удалось отменить задание",
       cancelFailed: "Не удалось отменить задание",
       removed: "Задание удалено",
       removed: "Задание удалено",
+      keptForOrder: "Отменено, а не удалено: только из этого запуска заказ может снова поставить эту пластину в очередь",
+      historyKeptForOrders: "Сохранено {{kept}}, они ещё нужны заказам.",
       removeFailed: "Не удалось удалить задание",
       removeFailed: "Не удалось удалить задание",
       stopped: "Печать остановлена",
       stopped: "Печать остановлена",
       stopFailed: "Не удалось остановить печать",
       stopFailed: "Не удалось остановить печать",

+ 5 - 1
frontend/src/i18n/locales/tr.ts

@@ -1364,6 +1364,8 @@ export default {
       noTargetsHint: 'Tabla başına hedef adetler gelmeden önce oluşturuldu; hedef yerine kuyruğa alınan baskıları izler.',
       noTargetsHint: 'Tabla başına hedef adetler gelmeden önce oluşturuldu; hedef yerine kuyruğa alınan baskıları izler.',
       dispatchRemaining: 'Kalan {{count}} baskıyı kuyruğa al',
       dispatchRemaining: 'Kalan {{count}} baskıyı kuyruğa al',
       dispatchPlate: 'Kalanları kuyruğa al',
       dispatchPlate: 'Kalanları kuyruğa al',
+      strandedPlate: 'Ayarların kopyalanacağı çalışma yok',
+      strandedNotice: 'Hâlâ beklenen {{owed}} çalışmadan {{runs}} tanesi kuyruğa alınamıyor: o plaka için ayarların kopyalanabileceği kuyrukta veya tamamlanmış bir çalışma kalmadı. Plakayı dosyadan bir kez kuyruğa alın veya siparişi iptal edin.',
       dispatched: '{{name}} için kalan baskılar kuyruğa alındı',
       dispatched: '{{name}} için kalan baskılar kuyruğa alındı',
       plateProgress: '{{target}} baskıdan {{completed}} tamam',
       plateProgress: '{{target}} baskıdan {{completed}} tamam',
       remaining: '{{count}} baskı bekliyor',
       remaining: '{{count}} baskı bekliyor',
@@ -1379,7 +1381,7 @@ export default {
     cancelBatch: 'Kalanları İptal Et',
     cancelBatch: 'Kalanları İptal Et',
     batchCancelled: 'Kalan yığın öğeleri iptal edildi',
     batchCancelled: 'Kalan yığın öğeleri iptal edildi',
     cancelBatchConfirmTitle: 'Yığını İptal Et',
     cancelBatchConfirmTitle: 'Yığını İptal Et',
-    cancelBatchConfirmMessage: 'Bu yığındaki tüm bekleyen öğeler iptal edilsin mi?',
+    cancelBatchConfirmMessage: 'Bu sipariş kapatılsın mı? Kuyrukta kalan çalışmalar iptal edilir ve sipariş üretmediklerini artık istemez.',
     batch: {
     batch: {
       defaultName: 'Yığın',
       defaultName: 'Yığın',
       label: '{{count}} öğe',
       label: '{{count}} öğe',
@@ -1538,6 +1540,8 @@ export default {
       cancelled: 'Kuyruk öğesi iptal edildi',
       cancelled: 'Kuyruk öğesi iptal edildi',
       cancelFailed: 'Öğe iptal edilemedi',
       cancelFailed: 'Öğe iptal edilemedi',
       removed: 'Kuyruk öğesi kaldırıldı',
       removed: 'Kuyruk öğesi kaldırıldı',
+      keptForOrder: 'Kaldırılmak yerine iptal edildi: yığın siparişinin bu plakayı yeniden kuyruğa alabileceği tek çalışma bu',
+      historyKeptForOrders: 'Yığın siparişlerinin hâlâ ihtiyaç duyduğu {{kept}} tanesi korundu.',
       removeFailed: 'Öğe kaldırılamadı',
       removeFailed: 'Öğe kaldırılamadı',
       stopped: 'Baskı durduruldu',
       stopped: 'Baskı durduruldu',
       stopFailed: 'Baskı durdurulamadı',
       stopFailed: 'Baskı durdurulamadı',

+ 5 - 1
frontend/src/i18n/locales/uk.ts

@@ -1373,6 +1373,8 @@ export default {
       noTargetsHint: "Створено до появи цільових кількостей на стіл, тож відстежує поставлені в чергу запуски, а не ціль.",
       noTargetsHint: "Створено до появи цільових кількостей на стіл, тож відстежує поставлені в чергу запуски, а не ціль.",
       dispatchRemaining: "Поставити в чергу: {{count}}",
       dispatchRemaining: "Поставити в чергу: {{count}}",
       dispatchPlate: "Поставити решту",
       dispatchPlate: "Поставити решту",
+      strandedPlate: "Немає запуску для копіювання налаштувань",
+      strandedNotice: "Не вдається поставити в чергу {{runs}} із {{owed}} ще не виконаних запусків: для цієї пластини не залишилося запуску в черзі чи завершеного, з якого можна скопіювати налаштування. Поставте пластину в чергу один раз із файлу або скасуйте замовлення.",
       dispatched: "Решту запусків для «{{name}}» поставлено в чергу",
       dispatched: "Решту запусків для «{{name}}» поставлено в чергу",
       plateProgress: "Готово {{completed}} з {{target}}",
       plateProgress: "Готово {{completed}} з {{target}}",
       remaining: "Залишилось: {{count}}",
       remaining: "Залишилось: {{count}}",
@@ -1388,7 +1390,7 @@ export default {
     cancelBatch: "Скасувати решту",
     cancelBatch: "Скасувати решту",
     batchCancelled: "Решту елементів партії скасовано",
     batchCancelled: "Решту елементів партії скасовано",
     cancelBatchConfirmTitle: "Скасувати партію",
     cancelBatchConfirmTitle: "Скасувати партію",
-    cancelBatchConfirmMessage: "Скасувати всі елементи цієї партії, що ще очікують?",
+    cancelBatchConfirmMessage: "Закрити це замовлення? Запуски, що ще стоять у черзі, буде скасовано, і замовлення перестане вимагати те, чого не виготовило.",
     batch: {
     batch: {
       defaultName: "Партія",
       defaultName: "Партія",
       label: "Елементів: {{count}}",
       label: "Елементів: {{count}}",
@@ -1553,6 +1555,8 @@ export default {
       cancelled: "Пункт черги скасовано",
       cancelled: "Пункт черги скасовано",
       cancelFailed: "Не вдалося скасувати елемент",
       cancelFailed: "Не вдалося скасувати елемент",
       removed: "Елемент черги видалено",
       removed: "Елемент черги видалено",
+      keptForOrder: "Скасовано, а не видалено: лише з цього запуску замовлення може знову поставити цю пластину в чергу",
+      historyKeptForOrders: "Збережено {{kept}}, які ще потрібні замовленням.",
       removeFailed: "Не вдалося видалити елемент",
       removeFailed: "Не вдалося видалити елемент",
       stopped: "Друк зупинено",
       stopped: "Друк зупинено",
       stopFailed: "Не вдалося зупинити друк",
       stopFailed: "Не вдалося зупинити друк",

+ 5 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -1364,6 +1364,8 @@ export default {
       noTargetsHint: '创建于按盘目标数量之前,因此跟踪的是已入队的打印,而不是目标数量。',
       noTargetsHint: '创建于按盘目标数量之前,因此跟踪的是已入队的打印,而不是目标数量。',
       dispatchRemaining: '将剩余 {{count}} 个加入队列',
       dispatchRemaining: '将剩余 {{count}} 个加入队列',
       dispatchPlate: '将剩余加入队列',
       dispatchPlate: '将剩余加入队列',
+      strandedPlate: '没有可复制设置的记录',
+      strandedNotice: '仍欠的 {{owed}} 次打印中有 {{runs}} 次无法加入队列:该板已没有可复制设置的队列中或已完成的打印。请从文件中将该板加入队列一次,或取消该订单。',
       dispatched: '已将 {{name}} 的剩余打印加入队列',
       dispatched: '已将 {{name}} 的剩余打印加入队列',
       plateProgress: '已完成 {{completed}}/{{target}}',
       plateProgress: '已完成 {{completed}}/{{target}}',
       remaining: '还差 {{count}} 个',
       remaining: '还差 {{count}} 个',
@@ -1379,7 +1381,7 @@ export default {
     cancelBatch: '取消剩余',
     cancelBatch: '取消剩余',
     batchCancelled: '已取消剩余批次项目',
     batchCancelled: '已取消剩余批次项目',
     cancelBatchConfirmTitle: '取消批次',
     cancelBatchConfirmTitle: '取消批次',
-    cancelBatchConfirmMessage: '取消此批次中所有剩余的待处理项目?',
+    cancelBatchConfirmMessage: '结束此订单?仍在队列中的打印将被取消,订单也不再要求尚未产出的部分。',
     batch: {
     batch: {
       defaultName: '批次',
       defaultName: '批次',
       label: '{{count}} 项',
       label: '{{count}} 项',
@@ -1538,6 +1540,8 @@ export default {
       cancelled: '队列项目已取消',
       cancelled: '队列项目已取消',
       cancelFailed: '取消项目失败',
       cancelFailed: '取消项目失败',
       removed: '队列项目已移除',
       removed: '队列项目已移除',
+      keptForOrder: '已取消而非移除:这是批量订单可以重新排入该板的唯一一次打印',
+      historyKeptForOrders: '保留了批量订单仍需要的 {{kept}} 项。',
       removeFailed: '移除项目失败',
       removeFailed: '移除项目失败',
       stopped: '打印已停止',
       stopped: '打印已停止',
       stopFailed: '停止打印失败',
       stopFailed: '停止打印失败',

+ 5 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -1364,6 +1364,8 @@ export default {
       noTargetsHint: '建立於每盤目標數量之前,因此追蹤的是已加入佇列的列印,而不是目標數量。',
       noTargetsHint: '建立於每盤目標數量之前,因此追蹤的是已加入佇列的列印,而不是目標數量。',
       dispatchRemaining: '將剩餘 {{count}} 個加入佇列',
       dispatchRemaining: '將剩餘 {{count}} 個加入佇列',
       dispatchPlate: '將剩餘加入佇列',
       dispatchPlate: '將剩餘加入佇列',
+      strandedPlate: '沒有可複製設定的記錄',
+      strandedNotice: '仍欠的 {{owed}} 次列印中有 {{runs}} 次無法加入佇列:該板已沒有可複製設定的佇列中或已完成的列印。請從檔案中將該板加入佇列一次,或取消該訂單。',
       dispatched: '已將 {{name}} 的剩餘列印加入佇列',
       dispatched: '已將 {{name}} 的剩餘列印加入佇列',
       plateProgress: '已完成 {{completed}}/{{target}}',
       plateProgress: '已完成 {{completed}}/{{target}}',
       remaining: '還差 {{count}} 個',
       remaining: '還差 {{count}} 個',
@@ -1379,7 +1381,7 @@ export default {
     cancelBatch: '取消剩餘',
     cancelBatch: '取消剩餘',
     batchCancelled: '已取消剩餘批次項目',
     batchCancelled: '已取消剩餘批次項目',
     cancelBatchConfirmTitle: '取消批次',
     cancelBatchConfirmTitle: '取消批次',
-    cancelBatchConfirmMessage: '取消此批次中所有剩餘的待處理項目?',
+    cancelBatchConfirmMessage: '結束此訂單?仍在佇列中的列印將被取消,訂單也不再要求尚未產出的部分。',
     batch: {
     batch: {
       defaultName: '批次',
       defaultName: '批次',
       label: '{{count}} 項',
       label: '{{count}} 項',
@@ -1538,6 +1540,8 @@ export default {
       cancelled: '佇列項目已取消',
       cancelled: '佇列項目已取消',
       cancelFailed: '取消項目失敗',
       cancelFailed: '取消項目失敗',
       removed: '佇列項目已移除',
       removed: '佇列項目已移除',
+      keptForOrder: '已取消而非移除:這是批次訂單可以重新排入該板的唯一一次列印',
+      historyKeptForOrders: '保留了批次訂單仍需要的 {{kept}} 項。',
       removeFailed: '移除項目失敗',
       removeFailed: '移除項目失敗',
       stopped: '列印已停止',
       stopped: '列印已停止',
       stopFailed: '停止列印失敗',
       stopFailed: '停止列印失敗',

+ 21 - 6
frontend/src/pages/QueuePage.tsx

@@ -1569,9 +1569,13 @@ export function QueuePage() {
 
 
   const removeMutation = useMutation({
   const removeMutation = useMutation({
     mutationFn: (id: number) => api.removeFromQueue(id),
     mutationFn: (id: number) => api.removeFromQueue(id),
-    onSuccess: () => {
+    onSuccess: (result) => {
       queryClient.invalidateQueries({ queryKey: ['queue'] });
       queryClient.invalidateQueries({ queryKey: ['queue'] });
-      showToast(t('queue.toast.removed'));
+      queryClient.invalidateQueries({ queryKey: ['batches'] });
+      // The backend keeps an order's last run for a plate, cancelled rather
+      // than deleted, so the order can still re-queue it (#2960). Say so:
+      // the row stays on screen and silence would read as a failed delete.
+      showToast(result.deleted === false ? t('queue.toast.keptForOrder') : t('queue.toast.removed'));
     },
     },
     onError: () => showToast(t('queue.toast.removeFailed'), 'error'),
     onError: () => showToast(t('queue.toast.removeFailed'), 'error'),
   });
   });
@@ -1635,14 +1639,25 @@ export function QueuePage() {
       const historyItems = queue?.filter(i =>
       const historyItems = queue?.filter(i =>
         ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)
         ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)
       ) || [];
       ) || [];
+      let cleared = 0;
+      let kept = 0;
       for (const item of historyItems) {
       for (const item of historyItems) {
-        await api.removeFromQueue(item.id);
+        const result = await api.removeFromQueue(item.id);
+        // A row a batch order still needs is kept rather than deleted, so the
+        // count has to come from what the backend actually did (#2960).
+        if (result.deleted === false) kept += 1;
+        else cleared += 1;
       }
       }
-      return historyItems.length;
+      return { cleared, kept };
     },
     },
-    onSuccess: (count) => {
+    onSuccess: ({ cleared, kept }) => {
       queryClient.invalidateQueries({ queryKey: ['queue'] });
       queryClient.invalidateQueries({ queryKey: ['queue'] });
-      showToast(t('queue.toast.historyCleared', { count }));
+      queryClient.invalidateQueries({ queryKey: ['batches'] });
+      showToast(
+        kept > 0
+          ? `${t('queue.toast.historyCleared', { count: cleared })} ${t('queue.toast.historyKeptForOrders', { kept })}`
+          : t('queue.toast.historyCleared', { count: cleared })
+      );
     },
     },
     onError: () => showToast(t('queue.toast.clearHistoryFailed'), 'error'),
     onError: () => showToast(t('queue.toast.clearHistoryFailed'), 'error'),
   });
   });

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/index-9GYLTY6P.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Cd_oha8a.js"></script>
+    <script type="module" crossorigin src="/assets/index-9GYLTY6P.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BzJRM4M1.css">
     <link rel="stylesheet" crossorigin href="/assets/index-BzJRM4M1.css">
   </head>
   </head>
   <body>
   <body>

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff