Jelajahi Sumber

Add batch orders with a quantity per plate (#342)

Printing a multi-plate file in different quantities per plate meant
queueing each plate separately and tracking the counts by hand: one
shared Quantity field cannot say "plate 1 once, plate 2 twice, plate 3
three times". Each selected plate now carries its own quantity, and the
submission becomes an order on a new Batches tab.

The point is the distinction the old flat batch could not express.
print_batch_plates stores how many runs of each plate were wanted,
separately from what was queued, so a run that fails, is cancelled or is
skipped does not satisfy a target -- the order goes on saying it owes a
print instead of quietly under-delivering. Queue remaining re-queues
exactly what is missing, for the whole order or one plate, by cloning
the most recent item for that plate: that inherits the printer or model
target, AMS mapping, filament overrides and print options along with the
validation they already passed, rather than re-serialising twenty fields
through a template that would drift from the model the first time
someone adds a column. Clones append to the end of the relevant
printer's queue and take the same advisory lock the add-to-queue route
does; positions are per-printer sequences, not global.

Cost is measured, not estimated. print_log_entries gains queue_item_id,
set where the queue item is already in scope, so each run's material and
energy are attributed through the item that produced them -- an
unrelated reprint of the same archive never lands in an order's total,
and a multi-plate order gets each plate's own cost rather than the whole
file's via the plate-scoped estimate from #2614. Before any run has
completed there is no honest figure, so cost reads as unknown instead of
a fabricated 0.00.

The Batches tab wires up GET /queue/batches, which has been unreferenced
since the batch MVP shipped, along with six locale keys that were
translated and never used. It is a separate tab because an order
outlives the queue that produced it: once its runs finish they leave the
active queue, so Queue and History each hold half the picture.

completed was not a reachable status before now, so every batch created
since April is still marked active however long ago its last print
finished -- 73 of them on the development install. A startup pass closes
out the finished ones: those whose runs all completed become completed,
and groupings whose items were all cancelled become cancelled, which is
what they are. Not applied to orders, which state their intent
independently of their runs and still owe the work. Only batches with
nothing queued or printing are considered, and repeating the pass also
catches an order whose last run landed while the process was down.
Batches with neither items nor targets are no longer listed at all --
empty shells left when a grouping's items went with their source
archive.

Dispatch applies the same source-file gates as POST /queue/. It creates
queue items, so without them it would be a weaker door to the same
outcome; the archive and library-file checks move into shared helpers
so a third route cannot drift from them.
maziggy 1 bulan lalu
induk
melakukan
71a06f3638
35 mengubah file dengan 3461 tambahan dan 89 penghapusan
  1. 0 0
      CHANGELOG.md
  2. 397 62
      backend/app/api/routes/print_queue.py
  3. 23 0
      backend/app/core/database.py
  4. 27 0
      backend/app/main.py
  5. 2 1
      backend/app/models/__init__.py
  6. 58 2
      backend/app/models/print_batch.py
  7. 7 0
      backend/app/models/print_log.py
  8. 87 0
      backend/app/schemas/print_queue.py
  9. 541 0
      backend/app/services/print_batch.py
  10. 2 0
      backend/app/services/print_log.py
  11. 117 0
      backend/tests/integration/test_ownership_permissions.py
  12. 833 0
      backend/tests/integration/test_print_batch_orders.py
  13. 229 0
      frontend/src/__tests__/components/BatchOrdersView.test.tsx
  14. 162 1
      frontend/src/__tests__/components/PrintModal.test.tsx
  15. 73 0
      frontend/src/api/client.ts
  16. 345 0
      frontend/src/components/BatchOrdersView.tsx
  17. 41 4
      frontend/src/components/PrintModal/PlateSelector.tsx
  18. 59 13
      frontend/src/components/PrintModal/index.tsx
  19. 8 0
      frontend/src/components/PrintModal/types.ts
  20. 33 0
      frontend/src/i18n/locales/de.ts
  21. 33 0
      frontend/src/i18n/locales/en.ts
  22. 33 0
      frontend/src/i18n/locales/es.ts
  23. 33 0
      frontend/src/i18n/locales/fr.ts
  24. 33 0
      frontend/src/i18n/locales/it.ts
  25. 33 0
      frontend/src/i18n/locales/ja.ts
  26. 33 0
      frontend/src/i18n/locales/ko.ts
  27. 33 0
      frontend/src/i18n/locales/pt-BR.ts
  28. 33 0
      frontend/src/i18n/locales/ru.ts
  29. 33 0
      frontend/src/i18n/locales/tr.ts
  30. 33 0
      frontend/src/i18n/locales/uk.ts
  31. 33 0
      frontend/src/i18n/locales/zh-CN.ts
  32. 33 0
      frontend/src/i18n/locales/zh-TW.ts
  33. 20 5
      frontend/src/pages/QueuePage.tsx
  34. 0 0
      static/assets/index-Ds22o6-q.js
  35. 1 1
      static/index.html

File diff ditekan karena terlalu besar
+ 0 - 0
CHANGELOG.md


+ 397 - 62
backend/app/api/routes/print_queue.py

@@ -19,15 +19,19 @@ from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
-from backend.app.models.print_batch import PrintBatch
+from backend.app.models.print_batch import PrintBatch, PrintBatchPlate
 from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.user import User
 from backend.app.schemas.print_queue import (
     PrintBatchCreate,
+    PrintBatchDispatchRequest,
+    PrintBatchPlateProgress,
+    PrintBatchPlateTarget,
     PrintBatchResponse,
     PrintBatchUngroupResponse,
+    PrintBatchUpdate,
     PrintQueueBulkUpdate,
     PrintQueueBulkUpdateResponse,
     PrintQueueItemCreate,
@@ -40,6 +44,12 @@ from backend.app.schemas.print_queue import (
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.filament_requirements import overrides_for_plate
 from backend.app.services.notification_service import notification_service
+from backend.app.services.print_batch import (
+    BatchDispatchError,
+    dispatch_remaining,
+    load_progress,
+    refresh_batch_status,
+)
 from backend.app.utils.printer_models import (
     is_gcode_compatible,
 )
@@ -141,6 +151,117 @@ def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = Non
 _extract_print_time_from_3mf = extract_print_time_from_3mf
 
 
+def _assert_can_queue_archive(archive: PrintArchive, current_user: User | None) -> None:
+    """Gate turning *archive* into a print. Raises rather than returning a verdict.
+
+    Shared by every route that creates queue items from an archive, so a new
+    one can't quietly become a weaker door to the same action than
+    ``POST /queue/`` is.
+
+    Two separate checks:
+
+    * IDOR fix (maziggy/bambuddy-security #2): without this, a caller with
+      QUEUE_CREATE could queue any user's archive even without ARCHIVES_READ on
+      it — Landon's PoC enumerated this on admin's archives as operator1. Gate
+      on ARCHIVES_READ_ALL OR ownership. 404 (not 403) so we don't leak "this
+      id exists but you can't queue it" for enumeration.
+    * Reprint perm gate (#1625): the legacy ``/archives/{id}/reprint`` endpoint
+      required ARCHIVES_REPRINT_OWN/ALL, and every route that replaces it must
+      keep that gate or an operator with QUEUE_CREATE could reprint via a
+      direct API call even when explicitly denied reprint perm. Mirrors the
+      frontend ``canModify('archives', 'reprint', ...)`` helper: REPRINT_ALL
+      allows any archive, REPRINT_OWN allows own only, ownerless archives
+      require REPRINT_ALL (fail-closed).
+    """
+    if current_user is None:
+        return
+    if not current_user.has_permission(Permission.ARCHIVES_READ_ALL.value) and archive.created_by_id != current_user.id:
+        raise HTTPException(404, "Archive not found")
+    owns_archive = archive.created_by_id is not None and archive.created_by_id == current_user.id
+    has_reprint = current_user.has_permission(Permission.ARCHIVES_REPRINT_ALL.value) or (
+        owns_archive and current_user.has_permission(Permission.ARCHIVES_REPRINT_OWN.value)
+    )
+    if not has_reprint:
+        raise HTTPException(
+            status_code=403,
+            detail="Permission archives:reprint_own or archives:reprint_all required",
+        )
+
+
+def _assert_can_queue_library_file(library_file: LibraryFile, current_user: User | None) -> None:
+    """Gate turning *library_file* into a print — LIBRARY_READ_ALL or ownership."""
+    if current_user is None:
+        return
+    if (
+        not current_user.has_permission(Permission.LIBRARY_READ_ALL.value)
+        and library_file.created_by_id != current_user.id
+    ):
+        raise HTTPException(404, "Library file not found")
+
+
+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.
+
+    Dispatching clones existing queue items, so without this it would be a
+    weaker door to the same outcome: a caller holding QUEUE_CREATE and
+    QUEUE_UPDATE_ALL but explicitly denied ``archives:reprint_*`` could start
+    prints through an order that ``POST /queue/`` would have refused them.
+
+    Every distinct source among the batch's items is checked, including the
+    library files behind cross-model variants — those get cloned too, and any
+    one of them may be the file that actually runs.
+    """
+    archive_ids = set(
+        (
+            await db.execute(
+                select(PrintQueueItem.archive_id)
+                .where(PrintQueueItem.batch_id == batch_id)
+                .where(PrintQueueItem.archive_id.is_not(None))
+                .distinct()
+            )
+        )
+        .scalars()
+        .all()
+    )
+    library_file_ids = set(
+        (
+            await db.execute(
+                select(PrintQueueItem.library_file_id)
+                .where(PrintQueueItem.batch_id == batch_id)
+                .where(PrintQueueItem.library_file_id.is_not(None))
+                .distinct()
+            )
+        )
+        .scalars()
+        .all()
+    )
+    library_file_ids |= set(
+        (
+            await db.execute(
+                select(PrintQueueVariant.library_file_id)
+                .join(PrintQueueItem, PrintQueueVariant.queue_item_id == PrintQueueItem.id)
+                .where(PrintQueueItem.batch_id == batch_id)
+                .distinct()
+            )
+        )
+        .scalars()
+        .all()
+    )
+
+    for archive_id in archive_ids:
+        archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
+        # A deleted source can't be printed; dispatch will fail on it anyway.
+        if archive is not None:
+            _assert_can_queue_archive(archive, current_user)
+
+    for library_file_id in library_file_ids:
+        library_file = (
+            await db.execute(LibraryFile.active().where(LibraryFile.id == library_file_id))
+        ).scalar_one_or_none()
+        if library_file is not None:
+            _assert_can_queue_library_file(library_file, current_user)
+
+
 async def _resolve_source_path(db: AsyncSession, item: PrintQueueItem) -> Path | None:
     """Resolve an existing queue item's source 3MF on disk, or None."""
     if item.archive_id:
@@ -622,35 +743,7 @@ async def add_to_queue(
         archive = result.scalar_one_or_none()
         if not archive:
             raise HTTPException(400, "Archive not found")
-        # IDOR fix (maziggy/bambuddy-security #2): without this check, a
-        # caller with QUEUE_CREATE could queue any user's archive even
-        # without ARCHIVES_READ on it — Landon's PoC enumerated this on
-        # admin's archives as operator1. Gate on ARCHIVES_READ_ALL OR
-        # ownership of the archive. 404 (not 403) so we don't leak
-        # "this id exists but you can't queue it" for enumeration.
-        if (
-            current_user is not None
-            and not current_user.has_permission(Permission.ARCHIVES_READ_ALL.value)
-            and archive.created_by_id != current_user.id
-        ):
-            raise HTTPException(404, "Archive not found")
-        # Reprint perm gate (#1625): the legacy /archives/{id}/reprint endpoint
-        # required ARCHIVES_REPRINT_OWN/ALL; the unified queue route must keep
-        # that gate or an operator with QUEUE_CREATE could reprint via direct
-        # API call even if explicitly denied reprint perm. Mirrors the
-        # frontend `canModify('archives', 'reprint', ...)` helper:
-        # REPRINT_ALL allows any archive, REPRINT_OWN allows own only,
-        # ownerless archives require REPRINT_ALL (fail-closed).
-        if current_user is not None:
-            owns_archive = archive.created_by_id is not None and archive.created_by_id == current_user.id
-            has_reprint = current_user.has_permission(Permission.ARCHIVES_REPRINT_ALL.value) or (
-                owns_archive and current_user.has_permission(Permission.ARCHIVES_REPRINT_OWN.value)
-            )
-            if not has_reprint:
-                raise HTTPException(
-                    status_code=403,
-                    detail="Permission archives:reprint_own or archives:reprint_all required",
-                )
+        _assert_can_queue_archive(archive, current_user)
 
     # Validate library file exists (if provided) and get it for filament extraction
     library_file = None
@@ -659,13 +752,7 @@ async def add_to_queue(
         library_file = result.scalar_one_or_none()
         if not library_file:
             raise HTTPException(400, "Library file not found")
-        # Same shape: gate cross-user library-file queueing on LIBRARY_READ_ALL.
-        if (
-            current_user is not None
-            and not current_user.has_permission(Permission.LIBRARY_READ_ALL.value)
-            and library_file.created_by_id != current_user.id
-        ):
-            raise HTTPException(404, "Library file not found")
+        _assert_can_queue_library_file(library_file, current_user)
         # Bambu SD card is FAT32/exFAT — illegal filename chars would 553 at
         # FTP upload time (#1540). Reject at queue time so the user gets the
         # actionable error before waiting in queue.
@@ -1078,6 +1165,66 @@ async def bulk_update_queue_items(
 # --- Batch endpoints ---
 
 
+def _validate_plate_targets(
+    plates: list[PrintBatchPlateTarget] | None,
+) -> list[PrintBatchPlateTarget] | None:
+    """Reject duplicate plates and orders that ask for nothing at all.
+
+    A duplicate would violate the (batch_id, plate_id) unique constraint at
+    flush time — and on SQLite/PostgreSQL a NULL plate_id slips past that
+    constraint entirely, so the check has to happen here to catch two
+    "whole file" rows in one order.
+    """
+    if plates is None:
+        return None
+    if not plates:
+        raise HTTPException(400, "plates must contain at least one plate")
+
+    seen: set[int | None] = set()
+    for target in plates:
+        if target.plate_id in seen:
+            label = target.plate_id if target.plate_id is not None else "whole file"
+            raise HTTPException(400, f"Duplicate plate in order: {label}")
+        seen.add(target.plate_id)
+
+    if all(target.quantity_target == 0 for target in plates):
+        raise HTTPException(400, "Order must request at least one print")
+    return plates
+
+
+async def _validate_batch_project(db: AsyncSession, project_id: int | None, current_user: User | None) -> None:
+    """404 on a bogus project id rather than letting the FK blow up as a 500."""
+    if project_id is None:
+        return
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    if not result.scalar_one_or_none():
+        raise HTTPException(404, "Project not found")
+
+
+async def _load_batch_for_write(
+    db: AsyncSession, batch_id: int, current_user: User | None, permission: Permission
+) -> PrintBatch:
+    """Fetch a batch the caller is allowed to modify, or 404.
+
+    404 rather than 403 on the ownership miss, matching the rest of this
+    module: a 403 would confirm the id exists to someone enumerating.
+    """
+    result = await db.execute(
+        select(PrintBatch).options(selectinload(PrintBatch.plates)).where(PrintBatch.id == batch_id)
+    )
+    batch = result.scalar_one_or_none()
+    if not batch:
+        raise HTTPException(404, "Batch not found")
+    if (
+        current_user is not None
+        and batch.created_by_id is not None
+        and batch.created_by_id != current_user.id
+        and not current_user.has_permission(permission.value)
+    ):
+        raise HTTPException(404, "Batch not found")
+    return batch
+
+
 @router.post("/batches", response_model=PrintBatchResponse)
 async def create_batch(
     data: PrintBatchCreate,
@@ -1092,10 +1239,18 @@ async def create_batch(
     * ``item_ids`` omitted/empty: create an empty batch so the client can
       pass the returned ``id`` on subsequent ``POST /queue/`` calls. Used by
       the multi-plate auto-batch flow in PrintModal.
+
+    ``plates`` turns the batch into an order with per-plate targets (#342):
+    progress is then measured against what was asked for rather than against
+    what happened to be queued, so a failed run still counts as owed. Omitting
+    it keeps the pre-#342 behaviour exactly.
     """
     if not data.name or not data.name.strip():
         raise HTTPException(400, "Batch name is required")
 
+    plate_targets = _validate_plate_targets(data.plates)
+    await _validate_batch_project(db, data.project_id, current_user)
+
     batch = PrintBatch(
         name=data.name.strip()[:255],
         archive_id=data.archive_id,
@@ -1103,10 +1258,28 @@ async def create_batch(
         quantity=len(data.item_ids) if data.item_ids else 1,
         status="active",
         created_by_id=current_user.id if current_user else None,
+        project_id=data.project_id,
+        due_date=data.due_date,
+        notes=data.notes,
     )
     db.add(batch)
     await db.flush()  # Need batch.id before assigning to items
 
+    if plate_targets is not None:
+        for target in plate_targets:
+            db.add(
+                PrintBatchPlate(
+                    batch_id=batch.id,
+                    plate_id=target.plate_id,
+                    plate_name=target.plate_name,
+                    quantity_target=target.quantity_target,
+                    sort_order=target.sort_order,
+                )
+            )
+        # The legacy `quantity` column is display-only; keep it meaningful for
+        # anything still reading it by making it the order's total.
+        batch.quantity = max(1, sum(t.quantity_target for t in plate_targets))
+
     assigned = 0
     if data.item_ids:
         result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id.in_(data.item_ids)))
@@ -1133,6 +1306,116 @@ async def create_batch(
     return await _build_batch_response(db, batch)
 
 
+@router.patch("/batches/{batch_id}", response_model=PrintBatchResponse)
+async def update_batch(
+    batch_id: int,
+    data: PrintBatchUpdate,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
+):
+    """Edit an order's header or its per-plate targets while it runs (#342).
+
+    Production requirements change mid-run, so targets are editable. Lowering a
+    target below what has already been dispatched is allowed and simply leaves
+    ``remaining`` at zero — cancelling the surplus queue items is a separate,
+    explicit action, because silently deleting queued work on a number change
+    would be a nasty surprise.
+    """
+    batch = await _load_batch_for_write(db, batch_id, current_user, Permission.QUEUE_UPDATE_ALL)
+
+    plate_targets = _validate_plate_targets(data.plates)
+    if data.project_id is not None:
+        await _validate_batch_project(db, data.project_id, current_user)
+
+    if data.name is not None:
+        if not data.name.strip():
+            raise HTTPException(400, "Batch name is required")
+        batch.name = data.name.strip()[:255]
+    if data.project_id is not None:
+        batch.project_id = data.project_id
+    if data.due_date is not None:
+        batch.due_date = data.due_date
+    if data.notes is not None:
+        batch.notes = data.notes
+    if data.status is not None:
+        batch.status = data.status
+
+    if plate_targets is not None:
+        existing = {row.plate_id: row for row in batch.plates}
+        for target in plate_targets:
+            row = existing.pop(target.plate_id, None)
+            if row is None:
+                db.add(
+                    PrintBatchPlate(
+                        batch_id=batch.id,
+                        plate_id=target.plate_id,
+                        plate_name=target.plate_name,
+                        quantity_target=target.quantity_target,
+                        sort_order=target.sort_order,
+                    )
+                )
+            else:
+                row.quantity_target = target.quantity_target
+                row.sort_order = target.sort_order
+                if target.plate_name is not None:
+                    row.plate_name = target.plate_name
+        # Plates absent from the payload are dropped — the list is the order.
+        for orphan in existing.values():
+            await db.delete(orphan)
+        batch.quantity = max(1, sum(t.quantity_target for t in plate_targets))
+
+    await db.flush()
+    await db.refresh(batch)
+    # Raising a target on a finished order reopens it; lowering one on a
+    # running order can complete it.
+    await refresh_batch_status(db, batch)
+    await db.commit()
+    await db.refresh(batch)
+
+    logger.info("Updated batch %s", batch.id)
+    return await _build_batch_response(db, batch)
+
+
+@router.post("/batches/{batch_id}/dispatch", response_model=PrintBatchResponse)
+async def dispatch_batch(
+    batch_id: int,
+    data: PrintBatchDispatchRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
+):
+    """Queue the runs this order still owes (#342).
+
+    Each new item is cloned from the most recent item for the same plate in
+    this batch, so it inherits the printer/model target, AMS mapping, filament
+    overrides and print options the user already chose — and the validation
+    those went through at creation time.
+    """
+    batch = await _load_batch_for_write(db, batch_id, current_user, Permission.QUEUE_UPDATE_ALL)
+    if batch.status == "cancelled":
+        raise HTTPException(400, "Cannot dispatch a cancelled batch")
+
+    # Dispatch starts prints, so it must not be a weaker door than POST /queue/.
+    await _assert_can_dispatch_batch_sources(db, batch.id, current_user)
+
+    try:
+        created = await dispatch_remaining(
+            db,
+            batch,
+            plate_id=data.plate_id,
+            only_plate=data.only_plate,
+            limit=data.limit,
+            created_by_id=current_user.id if current_user else None,
+        )
+    except BatchDispatchError as exc:
+        raise HTTPException(400, str(exc)) from exc
+
+    await db.commit()
+    await db.refresh(batch)
+
+    logger.info("Batch %s dispatched %d item(s)", batch.id, len(created))
+    return await _build_batch_response(db, batch)
+
+
 @router.post("/batches/{batch_id}/ungroup", response_model=PrintBatchUngroupResponse)
 async def ungroup_batch(
     batch_id: int,
@@ -1189,9 +1472,23 @@ async def list_batches(
         )
     ),
 ):
-    """List all print batches with progress stats."""
+    """List print batches with progress stats.
+
+    Batches with neither queue items nor per-plate targets are omitted. Those
+    are empty shells — a grouping whose items were deleted with their source
+    archive, or a create that never got as far as adding any — and they carry
+    nothing to show, track or dispatch. A brand-new order is still listed
+    before its first dispatch, because its targets say what it owes.
+    """
     current_user, can_read_all = auth_result
-    query = select(PrintBatch).order_by(PrintBatch.created_at.desc())
+    query = (
+        select(PrintBatch)
+        .where(
+            select(PrintQueueItem.id).where(PrintQueueItem.batch_id == PrintBatch.id).exists()
+            | select(PrintBatchPlate.id).where(PrintBatchPlate.batch_id == PrintBatch.id).exists()
+        )
+        .order_by(PrintBatch.created_at.desc())
+    )
     if status:
         query = query.where(PrintBatch.status == status)
     if current_user is not None and not can_read_all:
@@ -1199,10 +1496,14 @@ async def list_batches(
     result = await db.execute(query)
     batches = result.scalars().all()
 
-    responses = []
-    for batch in batches:
-        responses.append(await _build_batch_response(db, batch))
-    return responses
+    # Resolve creator names in one query rather than one per batch.
+    creator_ids = {b.created_by_id for b in batches if b.created_by_id is not None}
+    usernames: dict[int, str] = {}
+    if creator_ids:
+        rows = await db.execute(select(User.id, User.username).where(User.id.in_(creator_ids)))
+        usernames = {row[0]: row[1] for row in rows.all()}
+
+    return [await _build_batch_response(db, batch, usernames=usernames) for batch in batches]
 
 
 @router.get("/batches/{batch_id}", response_model=PrintBatchResponse)
@@ -1259,23 +1560,25 @@ async def cancel_batch(
     return {"message": f"Batch cancelled, {cancelled_count} pending items cancelled"}
 
 
-async def _build_batch_response(db: AsyncSession, batch: PrintBatch) -> PrintBatchResponse:
-    """Build a batch response with derived counts from queue items."""
-    # Count queue items by status
-    result = await db.execute(
-        select(PrintQueueItem.status, func.count(PrintQueueItem.id))
-        .where(PrintQueueItem.batch_id == batch.id)
-        .group_by(PrintQueueItem.status)
-    )
-    status_counts = {row[0]: row[1] for row in result.fetchall()}
+async def _build_batch_response(
+    db: AsyncSession, batch: PrintBatch, *, usernames: dict[int, str] | None = None
+) -> PrintBatchResponse:
+    """Build a batch response with per-plate progress derived from queue items.
+
+    ``usernames`` lets the list endpoint resolve every creator in one query
+    instead of one per batch.
+    """
+    progress = await load_progress(db, batch)
 
-    # Load created_by for username
     created_by_username = None
     if batch.created_by_id:
-        result = await db.execute(select(User).where(User.id == batch.created_by_id))
-        user = result.scalar_one_or_none()
-        if user:
-            created_by_username = user.username
+        if usernames is not None:
+            created_by_username = usernames.get(batch.created_by_id)
+        else:
+            result = await db.execute(select(User).where(User.id == batch.created_by_id))
+            user = result.scalar_one_or_none()
+            if user:
+                created_by_username = user.username
 
     return PrintBatchResponse(
         id=batch.id,
@@ -1285,13 +1588,45 @@ async def _build_batch_response(db: AsyncSession, batch: PrintBatch) -> PrintBat
         quantity=batch.quantity,
         status=batch.status,
         created_at=batch.created_at,
+        completed_at=batch.completed_at,
         created_by_id=batch.created_by_id,
         created_by_username=created_by_username,
-        pending_count=status_counts.get("pending", 0),
-        printing_count=status_counts.get("printing", 0),
-        completed_count=status_counts.get("completed", 0),
-        failed_count=status_counts.get("failed", 0),
-        cancelled_count=status_counts.get("cancelled", 0),
+        project_id=batch.project_id,
+        due_date=batch.due_date,
+        notes=batch.notes,
+        pending_count=progress.pending,
+        printing_count=progress.printing,
+        completed_count=progress.completed,
+        failed_count=progress.failed,
+        cancelled_count=progress.cancelled,
+        skipped_count=progress.skipped,
+        has_targets=progress.has_targets,
+        target_count=progress.target,
+        remaining_count=progress.remaining,
+        actual_cost=progress.actual_cost,
+        estimated_remaining_cost=progress.estimated_remaining_cost,
+        filament_used_grams=progress.filament_used_grams,
+        print_time_seconds=progress.print_time_seconds,
+        plates=[
+            PrintBatchPlateProgress(
+                plate_id=plate.plate_id,
+                plate_name=plate.plate_name,
+                quantity_target=plate.quantity_target,
+                dispatched=plate.dispatched,
+                remaining=plate.remaining,
+                pending_count=plate.pending,
+                printing_count=plate.printing,
+                completed_count=plate.completed,
+                failed_count=plate.failed,
+                cancelled_count=plate.cancelled,
+                skipped_count=plate.skipped,
+                actual_cost=plate.actual_cost,
+                estimated_remaining_cost=plate.estimated_remaining_cost,
+                filament_used_grams=plate.filament_used_grams,
+                print_time_seconds=plate.print_time_seconds,
+            )
+            for plate in progress.plates
+        ],
     )
 
 

+ 23 - 0
backend/app/core/database.py

@@ -2618,6 +2618,29 @@ async def run_migrations(conn):
     except (OperationalError, ProgrammingError):
         pass
 
+    # Migration (#342): batch orders — planning metadata on print_batches. The
+    # per-plate target rows live in their own table, created by create_all().
+    await _safe_execute(
+        conn, "ALTER TABLE print_batches ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
+    )
+    await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN notes TEXT")
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN due_date DATETIME")
+        await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN completed_at DATETIME")
+    else:
+        await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN due_date TIMESTAMP")
+        await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN completed_at TIMESTAMP")
+
+    # Migration (#342): attribute a logged run to the queue item that produced
+    # it, so batch cost/energy can be summed without guessing from archive_id.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE print_log_entries ADD COLUMN queue_item_id INTEGER REFERENCES print_queue(id) ON DELETE SET NULL",
+    )
+    await _safe_execute(
+        conn, "CREATE INDEX IF NOT EXISTS ix_print_log_entries_queue_item_id ON print_log_entries (queue_item_id)"
+    )
+
     # Migration: Shortest-job-first scheduling columns on print_queue
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN print_time_seconds INTEGER")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN been_jumped BOOLEAN DEFAULT FALSE NOT NULL")

+ 27 - 0
backend/app/main.py

@@ -5070,6 +5070,18 @@ async def on_print_complete(printer_id: int, data: dict):
         # Post-commit side effects (notifications, MQTT relay, auto-off) use
         # their own sessions and have their own error handling — no retry needed.
         if queue_item_id is not None:
+            # Batch orders (#342): this run may have been the last one an order
+            # owed. Re-evaluate here rather than lazily on read, so a finished
+            # order reports itself complete without someone opening the page.
+            try:
+                from backend.app.services.print_batch import refresh_batch_status_for_item
+
+                async with async_session() as db:
+                    await refresh_batch_status_for_item(db, queue_item_id)
+                    await db.commit()
+            except Exception as e:
+                logger.warning("[BATCH] Failed to refresh batch status for queue item %s: %s", queue_item_id, e)
+
             # MQTT relay - publish queue job completed
             try:
                 printer_info = printer_manager.get_printer(printer_id)
@@ -5436,6 +5448,10 @@ async def on_print_complete(printer_id: int, data: dict):
                 await write_log_entry(
                     db,
                     archive_id=archive.id,
+                    # Captured by _update_queue_status above; None for
+                    # printer-initiated prints with no queue row. Batch
+                    # cost/energy roll-up joins on it (#342).
+                    queue_item_id=queue_item_id,
                     status=_run_status,
                     print_name=archive.print_name,
                     printer_name=p_info.name if p_info else None,
@@ -7004,6 +7020,17 @@ async def lifespan(app: FastAPI):
     async with async_session() as oidc_db:
         await apply_env_oidc_provider(oidc_db)
 
+    # Close out batches that finished before `completed` was a reachable status
+    # (#342). Without this the Batches tab opens on every batch created since
+    # the feature shipped, all still marked active. Never blocks startup.
+    try:
+        from backend.app.services.print_batch import backfill_batch_statuses
+
+        async with async_session() as batch_db:
+            await backfill_batch_statuses(batch_db)
+    except Exception as exc:
+        logging.warning("[BATCH] Startup status backfill failed: %s", exc)
+
     # Register an app-scoped httpx client for Bambu Cloud services so
     # per-request BambuCloudService instances reuse the same connection pool
     # (important for routes like /cloud/filament-info that chain many

+ 2 - 1
backend/app/models/__init__.py

@@ -19,7 +19,7 @@ from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
 from backend.app.models.orca_base_cache import OrcaBaseProfile
 from backend.app.models.pending_upload import PendingUpload
 from backend.app.models.pipeline_run import PipelineJob, PipelineRun
-from backend.app.models.print_batch import PrintBatch
+from backend.app.models.print_batch import PrintBatch, PrintBatchPlate
 from backend.app.models.printer import Printer
 from backend.app.models.printer_sensor_history import PrinterSensorHistory
 from backend.app.models.project import Project
@@ -59,6 +59,7 @@ __all__ = [
     "AmsLabel",
     "PendingUpload",
     "PrintBatch",
+    "PrintBatchPlate",
     "LibraryFolder",
     "LibraryFile",
     "FileVariantGroup",

+ 58 - 2
backend/app/models/print_batch.py

@@ -1,13 +1,24 @@
 from datetime import datetime
 
-from sqlalchemy import DateTime, ForeignKey, Integer, String, func
+from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
 
 
 class PrintBatch(Base):
-    """Batch grouping for multiple queue items created from the same file."""
+    """Batch grouping for multiple queue items created from the same file.
+
+    A batch carries the *intent* — how many of each plate are wanted — in its
+    :class:`PrintBatchPlate` rows, while the queue items it spawned carry what
+    was actually dispatched. Keeping the two apart is what lets a failed print
+    still count as owed work: the plate row's ``quantity_target`` stays put
+    while the failed item lands in the "failed" bucket, so ``remaining`` goes
+    back up instead of the order silently under-delivering (#342).
+
+    Batches created before plate rows existed simply have none; every consumer
+    falls back to deriving progress from the queue items alone.
+    """
 
     __tablename__ = "print_batches"
 
@@ -26,8 +37,17 @@ class PrintBatch(Base):
     # Status: active, completed, cancelled
     status: Mapped[str] = mapped_column(String(20), default="active")
 
+    # Optional link to a Project, which owns the heavier planning metadata
+    # (BOM, attachments, tags). The batch keeps only the two fields that are
+    # useless without it — a date and free text — so an order doesn't force
+    # the user to create a Project first.
+    project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
+    due_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
 
     # User tracking
     created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
@@ -37,6 +57,42 @@ class PrintBatch(Base):
     library_file: Mapped["LibraryFile | None"] = relationship()
     created_by: Mapped["User | None"] = relationship()
     queue_items: Mapped[list["PrintQueueItem"]] = relationship(back_populates="batch")
+    plates: Mapped[list["PrintBatchPlate"]] = relationship(
+        back_populates="batch",
+        cascade="all, delete-orphan",
+        order_by="PrintBatchPlate.sort_order",
+    )
+
+
+class PrintBatchPlate(Base):
+    """How many runs of one plate a batch still owes.
+
+    ``plate_id`` is the plate index within the source 3MF, or NULL for a
+    single-plate file / whole-file print — the same convention
+    ``PrintQueueItem.plate_id`` uses, so progress can be derived by grouping
+    the batch's items on that column.
+    """
+
+    __tablename__ = "print_batch_plates"
+    __table_args__ = (UniqueConstraint("batch_id", "plate_id", name="uq_batch_plate"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    batch_id: Mapped[int] = mapped_column(
+        ForeignKey("print_batches.id", ondelete="CASCADE"), nullable=False, index=True
+    )
+
+    plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
+    plate_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
+
+    # How many runs of this plate the order wants. Zero is legal — a plate the
+    # user explicitly marked "not required" keeps its row so it can be raised
+    # later without re-creating the order.
+    quantity_target: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
+
+    # Display order; mirrors the plate order in the source file.
+    sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
+
+    batch: Mapped["PrintBatch"] = relationship(back_populates="plates")
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402

+ 7 - 0
backend/app/models/print_log.py

@@ -24,6 +24,13 @@ class PrintLogEntry(Base):
     archive_id: Mapped[int | None] = mapped_column(
         ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True, index=True
     )
+    # Which queue item produced this run, when one did. Printer-initiated
+    # prints have none. Batch cost/energy roll-up joins on this (#342): the
+    # archive alone can't attribute a run to an order because several orders
+    # — and plain reprints — share one archive.
+    queue_item_id: Mapped[int | None] = mapped_column(
+        ForeignKey("print_queue.id", ondelete="SET NULL"), nullable=True, index=True
+    )
     print_name: Mapped[str | None] = mapped_column(String(255))
     printer_name: Mapped[str | None] = mapped_column(String(255))
     printer_id: Mapped[int | None] = mapped_column(Integer)

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

@@ -331,6 +331,20 @@ class PrintQueueBulkUpdateResponse(BaseModel):
     message: str
 
 
+class PrintBatchPlateTarget(BaseModel):
+    """How many runs of one plate an order wants (#342).
+
+    ``plate_id`` is the plate index inside the source 3MF, or null for a
+    single-plate file — matching ``PrintQueueItem.plate_id``. A target of 0 is
+    legal and means "this plate is not required (yet)".
+    """
+
+    plate_id: int | None = None
+    plate_name: str | None = None
+    quantity_target: int = Field(default=1, ge=0, le=999)
+    sort_order: int = 0
+
+
 class PrintBatchCreate(BaseModel):
     """Create a batch, either empty (multi-plate pre-batch flow) or by
     assigning existing pending queue items into it (manual "Group as batch")."""
@@ -342,6 +356,41 @@ class PrintBatchCreate(BaseModel):
     # the empty-batch flow (client passes the returned id on subsequent
     # addToQueue calls).
     item_ids: list[int] | None = None
+    # Per-plate targets. Omitted entirely by the pre-#342 flows, which produce
+    # a batch that reports progress but owes nothing.
+    plates: list[PrintBatchPlateTarget] | None = None
+    # Planning metadata. Projects own the heavier fields (BOM, attachments,
+    # tags); these two are the ones that are useless without a Project to
+    # hang them on, so the order carries them directly.
+    project_id: int | None = None
+    due_date: datetime | None = None
+    notes: str | None = None
+
+
+class PrintBatchUpdate(BaseModel):
+    """Edit an order's header or its per-plate targets while it runs.
+
+    Every field is optional; ``plates`` replaces the full target set when
+    given, so a plate omitted from the list has its target row removed.
+    """
+
+    name: str | None = None
+    status: Literal["active", "cancelled"] | None = None
+    plates: list[PrintBatchPlateTarget] | None = None
+    project_id: int | None = None
+    due_date: datetime | None = None
+    notes: str | None = None
+
+
+class PrintBatchDispatchRequest(BaseModel):
+    """Create queue items for the runs an order still owes."""
+
+    # Restrict to one plate. Null is a legitimate plate_id (single-plate file),
+    # so the caller opts in explicitly rather than us inferring from null.
+    plate_id: int | None = None
+    only_plate: bool = False
+    # Cap on how many items to create across all plates. None = everything owed.
+    limit: int | None = Field(default=None, ge=1, le=999)
 
 
 class PrintBatchUngroupResponse(BaseModel):
@@ -351,6 +400,28 @@ class PrintBatchUngroupResponse(BaseModel):
     message: str
 
 
+class PrintBatchPlateProgress(BaseModel):
+    """Per-plate progress within a batch."""
+
+    plate_id: int | None = None
+    plate_name: str | None = None
+    quantity_target: int = 0
+    dispatched: int = 0
+    remaining: int = 0
+    pending_count: int = 0
+    printing_count: int = 0
+    completed_count: int = 0
+    failed_count: int = 0
+    cancelled_count: int = 0
+    skipped_count: int = 0
+    # Measured from finished runs, never estimated from the file. Null until
+    # at least one run of this plate has produced a cost.
+    actual_cost: float | None = None
+    estimated_remaining_cost: float | None = None
+    filament_used_grams: float | None = None
+    print_time_seconds: int = 0
+
+
 class PrintBatchResponse(BaseModel):
     """Response for a print batch with progress stats."""
 
@@ -361,14 +432,30 @@ class PrintBatchResponse(BaseModel):
     quantity: int
     status: str
     created_at: UTCDatetime
+    completed_at: UTCDatetime | None = None
     created_by_id: int | None = None
     created_by_username: str | None = None
+    project_id: int | None = None
+    due_date: UTCDatetime | None = None
+    notes: str | None = None
     # Derived counts
     pending_count: int = 0
     printing_count: int = 0
     completed_count: int = 0
     failed_count: int = 0
     cancelled_count: int = 0
+    skipped_count: int = 0
+    # Planning roll-up. has_targets is false for batches created before
+    # per-plate targets existed: they report progress but owe nothing, and the
+    # dispatch endpoint is a no-op for them.
+    has_targets: bool = False
+    target_count: int = 0
+    remaining_count: int = 0
+    actual_cost: float | None = None
+    estimated_remaining_cost: float | None = None
+    filament_used_grams: float | None = None
+    print_time_seconds: int = 0
+    plates: list[PrintBatchPlateProgress] = []
 
     class Config:
         from_attributes = True

+ 541 - 0
backend/app/services/print_batch.py

@@ -0,0 +1,541 @@
+"""Batch order planning: per-plate targets, progress, and staged dispatch (#342).
+
+A batch stores *intent* in :class:`PrintBatchPlate` rows — "this order wants 3
+of plate 2" — while its queue items record what was actually dispatched.
+Everything here derives one from the other.
+
+The distinction matters for exactly one reason, and it is the reason the
+feature exists: a failed or cancelled run does not count towards the target, so
+``remaining`` goes back up and the order still says it owes a print. A design
+that only counted the items it created could not tell "the user cancelled this
+deliberately" apart from "this one burned and needs reprinting".
+
+Batches created before targets existed have no plate rows. They still report
+progress — the plate breakdown is derived from their queue items and every
+target simply equals the number of items dispatched, so ``remaining`` is zero
+and the dispatch endpoint has nothing to do. ``has_targets`` tells callers
+which kind of batch they are looking at.
+"""
+
+import logging
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+
+from sqlalchemy import func, select, text
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from backend.app.models.print_batch import PrintBatch, PrintBatchPlate
+from backend.app.models.print_log import PrintLogEntry
+from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
+
+logger = logging.getLogger(__name__)
+
+# Statuses that consume a unit of the target. "printing" counts because the
+# run is in flight — re-dispatching it would double-print. "failed",
+# "cancelled" and "skipped" deliberately do not.
+CONSUMING_STATUSES = ("pending", "printing", "completed")
+
+# Queue statuses the roll-up has a counter for. Anything else is ignored rather
+# than crashing the page — the queue's status vocabulary is allowed to grow
+# without this module having to be updated in lockstep.
+COUNTED_STATUSES = ("pending", "printing", "completed", "failed", "cancelled", "skipped")
+
+# Columns copied onto a clone when dispatching more of a plate. This is the
+# print *configuration* the user already chose and the API already validated —
+# copying the row is what keeps a second dispatch identical to the first
+# without re-serialising twenty fields through a template blob that would drift
+# from the model the first time someone adds a column.
+CLONED_SETTING_COLUMNS = (
+    "printer_id",
+    "target_model",
+    "target_location",
+    "required_filament_types",
+    "archive_id",
+    "library_file_id",
+    "project_id",
+    "batch_id",
+    "ams_mapping",
+    "filament_overrides",
+    "plate_id",
+    "print_time_seconds",
+    "gcode_injection",
+    "nozzle_mapping",
+    "require_previous_success",
+    "auto_off_after",
+    "manual_start",
+    "bed_levelling",
+    "flow_cali",
+    "vibration_cali",
+    "layer_inspect",
+    "timelapse",
+    "use_ams",
+    "nozzle_offset_cali",
+    "preheat_override",
+    "preheat_chamber_target_override",
+    "skip_filament_check",
+)
+
+CLONED_VARIANT_COLUMNS = (
+    "position",
+    "library_file_id",
+    "target_model",
+    "plate_id",
+    "ams_mapping",
+    "nozzle_mapping",
+    "filament_overrides",
+    "required_filament_types",
+    "print_time_seconds",
+)
+
+
+class BatchDispatchError(Exception):
+    """Raised when more runs are owed but nothing can be cloned to produce them."""
+
+
+@dataclass
+class PlateProgress:
+    """Per-plate roll-up for one batch."""
+
+    plate_id: int | None
+    plate_name: str | None
+    quantity_target: int
+    sort_order: int = 0
+    pending: int = 0
+    printing: int = 0
+    completed: int = 0
+    failed: int = 0
+    cancelled: int = 0
+    skipped: int = 0
+    # Actual material + energy cost of this plate's finished runs. None when no
+    # run has produced a cost yet — reported as "unknown", never as zero.
+    actual_cost: float | None = None
+    filament_used_grams: float | None = None
+    print_time_seconds: int = 0
+
+    @property
+    def dispatched(self) -> int:
+        return self.pending + self.printing + self.completed
+
+    @property
+    def remaining(self) -> int:
+        return max(0, self.quantity_target - self.dispatched)
+
+    @property
+    def cost_per_run(self) -> float | None:
+        """Observed mean cost of this plate's completed runs, or None.
+
+        Deliberately measured rather than estimated from the file: the file's
+        estimate ignores what the run actually consumed, and a plate that has
+        never completed has no honest number to show.
+        """
+        if self.completed <= 0 or self.actual_cost is None:
+            return None
+        return self.actual_cost / self.completed
+
+    @property
+    def estimated_remaining_cost(self) -> float | None:
+        per_run = self.cost_per_run
+        if per_run is None:
+            return None
+        return per_run * self.remaining
+
+
+@dataclass
+class BatchProgress:
+    """Whole-order roll-up, plus the per-plate breakdown it was derived from."""
+
+    plates: list[PlateProgress] = field(default_factory=list)
+    has_targets: bool = False
+
+    def _sum(self, attr: str) -> int:
+        return sum(getattr(p, attr) for p in self.plates)
+
+    @property
+    def pending(self) -> int:
+        return self._sum("pending")
+
+    @property
+    def printing(self) -> int:
+        return self._sum("printing")
+
+    @property
+    def completed(self) -> int:
+        return self._sum("completed")
+
+    @property
+    def failed(self) -> int:
+        return self._sum("failed")
+
+    @property
+    def cancelled(self) -> int:
+        return self._sum("cancelled")
+
+    @property
+    def skipped(self) -> int:
+        return self._sum("skipped")
+
+    @property
+    def target(self) -> int:
+        return self._sum("quantity_target")
+
+    @property
+    def remaining(self) -> int:
+        return self._sum("remaining")
+
+    @property
+    def actual_cost(self) -> float | None:
+        costs = [p.actual_cost for p in self.plates if p.actual_cost is not None]
+        return sum(costs) if costs else None
+
+    @property
+    def estimated_remaining_cost(self) -> float | None:
+        estimates = [p.estimated_remaining_cost for p in self.plates if p.estimated_remaining_cost is not None]
+        return sum(estimates) if estimates else None
+
+    @property
+    def filament_used_grams(self) -> float | None:
+        grams = [p.filament_used_grams for p in self.plates if p.filament_used_grams is not None]
+        return sum(grams) if grams else None
+
+    @property
+    def print_time_seconds(self) -> int:
+        return self._sum("print_time_seconds")
+
+    @property
+    def is_fulfilled(self) -> bool:
+        """True when every target is met and nothing is still in flight.
+
+        A zero total target is never "fulfilled". Without that guard a legacy
+        batch whose items were all cancelled one by one would report itself
+        completed — its derived target counts only pending/printing/completed
+        items, so cancelling the lot leaves a target of zero that trivially
+        satisfies ``remaining == 0``.
+        """
+        return self.target > 0 and self.remaining == 0 and self.pending == 0 and self.printing == 0
+
+
+async def load_progress(db: AsyncSession, batch: PrintBatch) -> BatchProgress:
+    """Build the per-plate progress roll-up for *batch*.
+
+    Two queries plus one for costs, regardless of how many plates the order
+    has — this runs once per batch in the list endpoint.
+    """
+    plate_rows = (await db.execute(select(PrintBatchPlate).where(PrintBatchPlate.batch_id == batch.id))).scalars().all()
+
+    # (plate_id, status) -> count, plus the time/weight actually recorded.
+    item_rows = (
+        await db.execute(
+            select(
+                PrintQueueItem.plate_id,
+                PrintQueueItem.status,
+                func.count(PrintQueueItem.id),
+                func.sum(PrintQueueItem.print_time_seconds),
+            )
+            .where(PrintQueueItem.batch_id == batch.id)
+            .group_by(PrintQueueItem.plate_id, PrintQueueItem.status)
+        )
+    ).all()
+
+    # Per-run actuals, attributed through the queue item that produced them.
+    # PrintLogEntry is the authoritative per-run record (#1378) and is already
+    # scoped to the printed plate (#2614), so a multi-plate order gets each
+    # plate's own cost rather than the whole file's.
+    cost_rows = (
+        await db.execute(
+            select(
+                PrintQueueItem.plate_id,
+                func.sum(func.coalesce(PrintLogEntry.cost, 0.0) + func.coalesce(PrintLogEntry.energy_cost, 0.0)),
+                func.sum(PrintLogEntry.filament_used_grams),
+            )
+            .select_from(PrintLogEntry)
+            .join(PrintQueueItem, PrintLogEntry.queue_item_id == PrintQueueItem.id)
+            .where(PrintQueueItem.batch_id == batch.id)
+            .group_by(PrintQueueItem.plate_id)
+        )
+    ).all()
+    costs = {row[0]: (row[1], row[2]) for row in cost_rows}
+
+    progress = BatchProgress(has_targets=bool(plate_rows))
+    by_plate: dict[int | None, PlateProgress] = {}
+
+    for row in plate_rows:
+        by_plate[row.plate_id] = PlateProgress(
+            plate_id=row.plate_id,
+            plate_name=row.plate_name,
+            quantity_target=row.quantity_target,
+            sort_order=row.sort_order,
+        )
+
+    for plate_id, status, count, time_sum in item_rows:
+        plate = by_plate.get(plate_id)
+        if plate is None:
+            # A queue item for a plate the order has no target row for: either
+            # a legacy batch, or an item grouped in by hand after the fact.
+            # Its own dispatched count becomes its target so it reads as
+            # complete rather than as owing work nobody asked for.
+            plate = PlateProgress(plate_id=plate_id, plate_name=None, quantity_target=0, sort_order=plate_id or 0)
+            by_plate[plate_id] = plate
+            if status in CONSUMING_STATUSES:
+                plate.quantity_target += count
+        elif not progress.has_targets and status in CONSUMING_STATUSES:
+            plate.quantity_target += count
+        if status in COUNTED_STATUSES:
+            setattr(plate, status, getattr(plate, status) + count)
+        else:
+            logger.debug("Batch %s: ignoring queue item status %r in progress roll-up", batch.id, status)
+        plate.print_time_seconds += int(time_sum or 0)
+
+    for plate_id, (cost_sum, gram_sum) in costs.items():
+        plate = by_plate.get(plate_id)
+        if plate is None:
+            continue
+        plate.actual_cost = float(cost_sum) if cost_sum else None
+        plate.filament_used_grams = float(gram_sum) if gram_sum else None
+
+    progress.plates = sorted(by_plate.values(), key=lambda p: (p.sort_order, p.plate_id or 0))
+    return progress
+
+
+async def refresh_batch_status(db: AsyncSession, batch: PrintBatch) -> bool:
+    """Flip an ``active`` batch to ``completed`` once its targets are met.
+
+    Returns True when the status changed. A ``cancelled`` batch is never
+    resurrected, and a ``completed`` batch drops back to ``active`` if its
+    targets grow — raising a target on a finished order reopens it rather than
+    leaving a "completed" order that still owes prints.
+    """
+    progress = await load_progress(db, batch)
+
+    if batch.status == "cancelled":
+        return False
+
+    if batch.status == "active" and progress.is_fulfilled:
+        batch.status = "completed"
+        batch.completed_at = datetime.now(timezone.utc)
+        logger.info("Batch %s fulfilled — marked completed", batch.id)
+        return True
+
+    # A grouping whose every item was cancelled one at a time is finished, but
+    # nothing was produced, so "completed" would be a lie and `is_fulfilled`
+    # rightly refuses it (its derived target is zero). Left alone it would sit
+    # on "active" forever. Cancelled is what it is, and matches what the
+    # batch-level Cancel action would have set had it been used.
+    #
+    # Deliberately not applied to orders: an order states its intent
+    # independently of its runs, so cancelling every run still leaves it owing
+    # work and offering to re-queue it. A grouping has no such statement — it
+    # was only ever the sum of its items.
+    if batch.status == "active" and not progress.has_targets and progress.completed == 0:
+        settled = progress.pending == 0 and progress.printing == 0
+        if settled and progress.cancelled > 0 and progress.failed == 0 and progress.skipped == 0:
+            batch.status = "cancelled"
+            logger.info("Batch %s had every item cancelled — marked cancelled", batch.id)
+            return True
+
+    if batch.status == "completed" and not progress.is_fulfilled:
+        batch.status = "active"
+        batch.completed_at = None
+        logger.info("Batch %s reopened — targets no longer met", batch.id)
+        return True
+
+    return False
+
+
+async def backfill_batch_statuses(db: AsyncSession) -> int:
+    """Close out ``active`` batches that finished before the status existed.
+
+    ``completed`` only became reachable with #342. Every batch created since
+    the feature shipped in April 2026 is therefore still marked ``active``,
+    however long ago its last run finished — so without this pass the Batches
+    tab opens on months of accumulated history.
+
+    Runs on every startup rather than once behind a marker: it is cheap (only
+    batches with nothing in flight are even considered), it is idempotent, and
+    repeating it also closes out any order whose last run landed while the
+    process was down.
+
+    Returns the number of batches whose status changed.
+    """
+    candidates = (
+        (
+            await db.execute(
+                select(PrintBatch)
+                .where(PrintBatch.status == "active")
+                # Anything still queued or printing is by definition unfinished,
+                # and re-deriving its progress would change nothing.
+                .where(
+                    ~select(PrintQueueItem.id)
+                    .where(PrintQueueItem.batch_id == PrintBatch.id)
+                    .where(PrintQueueItem.status.in_(("pending", "printing")))
+                    .exists()
+                )
+            )
+        )
+        .scalars()
+        .all()
+    )
+
+    changed = 0
+    for batch in candidates:
+        if await refresh_batch_status(db, batch):
+            changed += 1
+
+    if changed:
+        await db.commit()
+        logger.info("Marked %d finished batch(es) as completed at startup (#342)", changed)
+    return changed
+
+
+async def refresh_batch_status_for_item(db: AsyncSession, queue_item_id: int) -> None:
+    """Re-evaluate the batch owning *queue_item_id*, if it has one.
+
+    Called from the print-completion path so a finished order reports itself
+    complete the moment its last run lands, rather than whenever someone next
+    opens the page.
+    """
+    batch_id = (
+        await db.execute(select(PrintQueueItem.batch_id).where(PrintQueueItem.id == queue_item_id))
+    ).scalar_one_or_none()
+    if batch_id is None:
+        return
+    batch = (await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))).scalar_one_or_none()
+    if batch is None:
+        return
+    await refresh_batch_status(db, batch)
+
+
+async def _next_position(db: AsyncSession, printer_id: int | None) -> int:
+    """Next free queue position in the scope a clone will land in.
+
+    Positions are per-queue, not global: one sequence per printer plus one
+    shared sequence for unassigned / model-based items, matching the scope the
+    add-to-queue route uses. Taking a global MAX here would drop every clone
+    at the end of whichever printer's queue happens to be longest and scramble
+    the order the user sees.
+    """
+    # Same advisory lock the add-to-queue route takes (#1625-followup): two
+    # concurrent inserts into an empty scope would otherwise both read
+    # MAX(position) as 0 and land on position 1. SQLite serialises writes
+    # implicitly and needs no equivalent.
+    bind = db.get_bind()
+    if bind.dialect.name == "postgresql":
+        await db.execute(
+            text("SELECT pg_advisory_xact_lock(1625, :k)"), {"k": printer_id if printer_id is not None else 0}
+        )
+
+    scope = PrintQueueItem.printer_id == printer_id if printer_id is not None else PrintQueueItem.printer_id.is_(None)
+    max_pos = (
+        await db.execute(
+            select(func.max(PrintQueueItem.position)).where(scope).where(PrintQueueItem.status == "pending")
+        )
+    ).scalar() or 0
+    return max_pos + 1
+
+
+def _clone_queue_item(source: PrintQueueItem, *, position: int, created_by_id: int | None) -> PrintQueueItem:
+    """Copy *source*'s print configuration into a fresh pending item.
+
+    Lifecycle state (status, timestamps, retry counters, scheduler flags) is
+    deliberately not copied — the clone is a new run, not a resurrection.
+
+    ``scheduled_time`` is dropped too: dispatching more of a plate is a
+    "queue this now" action, and replaying the original's scheduled time would
+    either fire immediately (it is in the past) or silently park the new run
+    until a moment the user chose for a different print.
+
+    ``cleanup_library_after_dispatch`` is forced off. It only ever comes from
+    the Printers-page direct-print flow, where it deletes the transient library
+    row after dispatch — replaying that on a clone would delete the source file
+    out from under the rest of the order.
+    """
+    clone = PrintQueueItem(
+        status="pending",
+        position=position,
+        created_by_id=created_by_id if created_by_id is not None else source.created_by_id,
+        cleanup_library_after_dispatch=False,
+    )
+    for column in CLONED_SETTING_COLUMNS:
+        setattr(clone, column, getattr(source, column))
+    return clone
+
+
+async def dispatch_remaining(
+    db: AsyncSession,
+    batch: PrintBatch,
+    *,
+    plate_id: int | None = None,
+    only_plate: bool = False,
+    limit: int | None = None,
+    created_by_id: int | None = None,
+) -> list[PrintQueueItem]:
+    """Create queue items for the runs *batch* still owes.
+
+    ``only_plate`` restricts the dispatch to the single plate named by
+    ``plate_id`` (which may legitimately be ``None`` for a single-plate file);
+    otherwise every plate with work outstanding is dispatched in plate order.
+    ``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.
+    """
+    progress = await load_progress(db, batch)
+    if not progress.has_targets:
+        return []
+
+    targets = [p for p in progress.plates if p.remaining > 0]
+    if only_plate:
+        targets = [p for p in targets if p.plate_id == plate_id]
+
+    created: list[PrintQueueItem] = []
+
+    for plate in targets:
+        if limit is not None and len(created) >= limit:
+            break
+
+        source = (
+            await db.execute(
+                select(PrintQueueItem)
+                .options(selectinload(PrintQueueItem.variants))
+                .where(PrintQueueItem.batch_id == batch.id)
+                .where(PrintQueueItem.plate_id == plate.plate_id)
+                .order_by(PrintQueueItem.id.desc())
+                .limit(1)
+            )
+        ).scalar_one_or_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."
+            )
+
+        wanted = plate.remaining
+        if limit is not None:
+            wanted = min(wanted, limit - len(created))
+
+        # One scope per source printer; clones for this plate all land in it,
+        # appended after whatever is already queued there.
+        position = await _next_position(db, source.printer_id)
+
+        for _ in range(wanted):
+            clone = _clone_queue_item(source, position=position, created_by_id=created_by_id)
+            position += 1
+            db.add(clone)
+            await db.flush()
+            for variant in source.variants:
+                cloned_variant = PrintQueueVariant(queue_item_id=clone.id)
+                for column in CLONED_VARIANT_COLUMNS:
+                    setattr(cloned_variant, column, getattr(variant, column))
+                db.add(cloned_variant)
+            created.append(clone)
+
+    if created:
+        # Dispatching more work can only ever un-fulfil an order, but run the
+        # check anyway so a reopened batch flips back from completed.
+        await db.flush()
+        await refresh_batch_status(db, batch)
+
+    logger.info("Dispatched %d item(s) for batch %s", len(created), batch.id)
+    return created

+ 2 - 0
backend/app/services/print_log.py

@@ -18,6 +18,7 @@ async def write_log_entry(
     *,
     status: str,
     archive_id: int | None = None,
+    queue_item_id: int | None = None,
     print_name: str | None = None,
     printer_name: str | None = None,
     printer_id: int | None = None,
@@ -56,6 +57,7 @@ async def write_log_entry(
 
     entry = PrintLogEntry(
         archive_id=archive_id,
+        queue_item_id=queue_item_id,
         print_name=print_name,
         printer_name=printer_name,
         printer_id=printer_id,

+ 117 - 0
backend/tests/integration/test_ownership_permissions.py

@@ -398,6 +398,123 @@ class TestArchiveOwnershipPermissions(TestOwnershipPermissionsSetup):
         assert response.status_code == 403
         assert "reprint" in response.json()["detail"].lower()
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_dispatch_allowed_for_own_archive_with_reprint_own(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """The dispatch gate must not block the ordinary self-service case (#342)."""
+        headers = {"Authorization": f"Bearer {auth_setup['operator_token']}"}
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, created_by_id=auth_setup["operator_user"]["id"])
+
+        order = await async_client.post(
+            "/api/v1/queue/batches",
+            headers=headers,
+            json={
+                "name": "Own order",
+                "archive_id": archive.id,
+                "plates": [{"plate_id": 1, "quantity_target": 2}],
+            },
+        )
+        assert order.status_code == 200
+        batch_id = order.json()["id"]
+        assert (
+            await async_client.post(
+                "/api/v1/queue/",
+                headers=headers,
+                json={
+                    "printer_id": printer.id,
+                    "archive_id": archive.id,
+                    "batch_id": batch_id,
+                    "plate_id": 1,
+                },
+            )
+        ).status_code == 200
+
+        response = await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", headers=headers, json={})
+        assert response.status_code == 200
+        assert response.json()["remaining_count"] == 0
+        assert response.json()["pending_count"] == 2
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_dispatch_honours_the_reprint_gate(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Dispatching a batch order must not be a weaker door than POST /queue/ (#342).
+
+        Dispatch clones existing queue items, so without the same source-file
+        gate a caller holding queue:create and queue:update_all — but
+        explicitly denied archives:reprint_* — could start prints of an
+        archive that POST /queue/ would have refused them.
+        """
+        admin_headers = {"Authorization": f"Bearer {auth_setup['admin_token']}"}
+        group_resp = await async_client.post(
+            "/api/v1/groups/",
+            headers=admin_headers,
+            json={
+                "name": "BatchDispatchNoReprint",
+                "description": "Test group: can manage the queue but not reprint archives",
+                "permissions": [
+                    "queue:create",
+                    "queue:read_all",
+                    "queue:update_all",
+                    "archives:read_all",
+                    "printers:read",
+                ],
+            },
+        )
+        assert group_resp.status_code in (200, 201)
+        await async_client.post(
+            "/api/v1/users/",
+            headers=admin_headers,
+            json={
+                "username": "batch_noreprint_user",
+                "password": "BatchNoreprint1!",
+                "group_ids": [group_resp.json()["id"]],
+            },
+        )
+        login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "batch_noreprint_user", "password": "BatchNoreprint1!"},
+        )
+        token = login.json()["access_token"]
+
+        # Admin builds an order with one dispatched run and two still owed.
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, created_by_id=auth_setup["admin_user"]["id"])
+        order = await async_client.post(
+            "/api/v1/queue/batches",
+            headers=admin_headers,
+            json={
+                "name": "Gated order",
+                "archive_id": archive.id,
+                "plates": [{"plate_id": 1, "quantity_target": 3}],
+            },
+        )
+        assert order.status_code == 200
+        batch_id = order.json()["id"]
+        seeded = await async_client.post(
+            "/api/v1/queue/",
+            headers=admin_headers,
+            json={"printer_id": printer.id, "archive_id": archive.id, "batch_id": batch_id, "plate_id": 1},
+        )
+        assert seeded.status_code == 200
+
+        response = await async_client.post(
+            f"/api/v1/queue/batches/{batch_id}/dispatch",
+            headers={"Authorization": f"Bearer {token}"},
+            json={},
+        )
+
+        assert response.status_code == 403
+        assert "reprint" in response.json()["detail"].lower()
+
+        # And nothing was queued behind the refusal.
+        listing = await async_client.get(f"/api/v1/queue/batches/{batch_id}", headers=admin_headers)
+        assert listing.json()["pending_count"] == 1
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_queue_route_ownerless_archive_requires_reprint_all(

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

@@ -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

+ 229 - 0
frontend/src/__tests__/components/BatchOrdersView.test.tsx

@@ -0,0 +1,229 @@
+/**
+ * Tests for the Batch Orders tab (#342).
+ *
+ * The point of this view is the gap the Queue and History tabs cannot show:
+ * what an order asked for versus what has actually been produced, including
+ * the runs that failed and are therefore still owed.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { BatchOrdersView } from '../../components/BatchOrdersView';
+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 batch = (over: Partial<PrintBatch> = {}): PrintBatch => ({
+  id: 1,
+  name: 'Widget run',
+  archive_id: 7,
+  library_file_id: null,
+  quantity: 6,
+  status: 'active',
+  created_at: '2026-08-01T10:00:00Z',
+  completed_at: null,
+  created_by_id: null,
+  created_by_username: null,
+  project_id: null,
+  due_date: null,
+  notes: null,
+  pending_count: 0,
+  printing_count: 0,
+  completed_count: 0,
+  failed_count: 0,
+  cancelled_count: 0,
+  skipped_count: 0,
+  has_targets: true,
+  target_count: 6,
+  remaining_count: 6,
+  actual_cost: null,
+  estimated_remaining_cost: null,
+  filament_used_grams: null,
+  print_time_seconds: 0,
+  plates: [],
+  ...over,
+});
+
+const allow = () => true;
+const deny = () => false;
+const passthroughT = (key: string, options?: Record<string, unknown>) => {
+  void options;
+  return key;
+};
+
+describe('BatchOrdersView (#342)', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    server.use(
+      http.get('/api/v1/settings/', () => HttpResponse.json({ currency: 'EUR' })),
+      http.get('/api/v1/queue/batches', () => HttpResponse.json([])),
+    );
+  });
+
+  it('shows the empty state when nothing matches the filter', async () => {
+    render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
+    await waitFor(() =>
+      expect(screen.getByText('queue.batchOrders.emptyTitle')).toBeInTheDocument(),
+    );
+  });
+
+  it('surfaces a finished order that has no queue rows left', async () => {
+    // The case the Queue and History tabs each miss: every run completed, so
+    // nothing is pending, yet the order is the thing the user wants to see.
+    server.use(
+      http.get('/api/v1/queue/batches', ({ request }) => {
+        const status = new URL(request.url).searchParams.get('status');
+        if (status !== 'completed') return HttpResponse.json([]);
+        return HttpResponse.json([
+          batch({ status: 'completed', completed_count: 6, remaining_count: 0, completed_at: '2026-08-02T10:00:00Z' }),
+        ]);
+      }),
+    );
+    const user = userEvent.setup();
+    render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
+
+    await waitFor(() => expect(screen.getByText('queue.batchOrders.emptyTitle')).toBeInTheDocument());
+    await user.click(screen.getByRole('button', { name: 'queue.batchOrders.filter.completed' }));
+
+    await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
+    expect(screen.getByText('queue.batchOrders.status.completed')).toBeInTheDocument();
+  });
+
+  it('offers to queue what a failed run still owes', async () => {
+    let dispatched: { plate_id?: number | null; only_plate?: boolean } | null = null;
+    server.use(
+      http.get('/api/v1/queue/batches', () =>
+        HttpResponse.json([
+          batch({
+            completed_count: 1,
+            failed_count: 1,
+            target_count: 2,
+            remaining_count: 1,
+            plates: [plate({ quantity_target: 2, dispatched: 1, remaining: 1, completed_count: 1, failed_count: 1 })],
+          }),
+        ]),
+      ),
+      http.post('/api/v1/queue/batches/:id/dispatch', async ({ request }) => {
+        dispatched = (await request.json()) as { plate_id?: number | null };
+        return HttpResponse.json(batch({ remaining_count: 0, pending_count: 1 }));
+      }),
+    );
+
+    const user = userEvent.setup();
+    render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
+
+    await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
+    // Reported at both levels: the order summary and the plate that burned.
+    expect(screen.getAllByText('queue.batchOrders.failed')).toHaveLength(2);
+
+    await user.click(screen.getByRole('button', { name: /queue.batchOrders.dispatchRemaining/ }));
+    await waitFor(() => expect(dispatched).not.toBeNull());
+    // Order-level dispatch covers every plate, so no plate filter is sent.
+    expect(dispatched).toEqual({});
+  });
+
+  it('dispatches a single plate from its own row', async () => {
+    let dispatched: { plate_id?: number | null; only_plate?: boolean } | null = null;
+    server.use(
+      http.get('/api/v1/queue/batches', () =>
+        HttpResponse.json([
+          batch({
+            target_count: 4,
+            remaining_count: 3,
+            plates: [
+              plate({ plate_id: 1, quantity_target: 1, remaining: 0 }),
+              plate({ plate_id: 2, quantity_target: 3, dispatched: 0, completed_count: 0, remaining: 3 }),
+            ],
+          }),
+        ]),
+      ),
+      http.post('/api/v1/queue/batches/:id/dispatch', async ({ request }) => {
+        dispatched = (await request.json()) as { plate_id?: number | null };
+        return HttpResponse.json(batch());
+      }),
+    );
+
+    const user = userEvent.setup();
+    render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
+
+    await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
+    // Only the plate with work outstanding offers the action.
+    const plateButtons = screen.getAllByRole('button', { name: 'queue.batchOrders.dispatchPlate' });
+    expect(plateButtons).toHaveLength(1);
+
+    await user.click(plateButtons[0]);
+    await waitFor(() => expect(dispatched).not.toBeNull());
+    expect(dispatched).toEqual({ plate_id: 2, only_plate: true });
+  });
+
+  it('marks a legacy batch as grouping-only and offers no dispatch', async () => {
+    server.use(
+      http.get('/api/v1/queue/batches', () =>
+        HttpResponse.json([
+          batch({ has_targets: false, target_count: 3, remaining_count: 0, pending_count: 3, plates: [] }),
+        ]),
+      ),
+    );
+    render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
+
+    await waitFor(() => expect(screen.getByText('queue.batchOrders.noTargets')).toBeInTheDocument());
+    expect(
+      screen.queryByRole('button', { name: /queue.batchOrders.dispatchRemaining/ }),
+    ).not.toBeInTheDocument();
+  });
+
+  it('hides the dispatch and cancel actions without permission', async () => {
+    server.use(
+      http.get('/api/v1/queue/batches', () =>
+        HttpResponse.json([
+          batch({ pending_count: 2, remaining_count: 2, plates: [plate({ quantity_target: 3, remaining: 2 })] }),
+        ]),
+      ),
+    );
+    render(<BatchOrdersView hasPermission={deny} t={passthroughT} />);
+
+    await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
+    expect(
+      screen.queryByRole('button', { name: /queue.batchOrders.dispatchRemaining/ }),
+    ).not.toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: /queue.batchOrders.dispatchPlate/ })).not.toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: 'queue.cancelBatch' })).not.toBeInTheDocument();
+  });
+
+  it('shows cost only once a run has produced one', async () => {
+    server.use(
+      http.get('/api/v1/queue/batches', () =>
+        HttpResponse.json([
+          batch({ id: 1, name: 'Priced', actual_cost: 6, estimated_remaining_cost: 3, completed_count: 2 }),
+          batch({ id: 2, name: 'Unpriced', actual_cost: null, estimated_remaining_cost: null }),
+        ]),
+      ),
+    );
+    render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
+
+    await waitFor(() => expect(screen.getByText('Priced')).toBeInTheDocument());
+    // One cost line, belonging to the priced order — never a fabricated 0.00.
+    expect(screen.getAllByText('queue.batchOrders.costSoFar')).toHaveLength(1);
+  });
+});

+ 162 - 1
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -8,7 +8,7 @@
 
 import { describe, it, expect, vi, beforeEach } from 'vitest';
 import type React from 'react';
-import { screen, waitFor, render as rtlRender } from '@testing-library/react';
+import { screen, waitFor, fireEvent, render as rtlRender } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { BrowserRouter } from 'react-router-dom';
@@ -1988,3 +1988,164 @@ describe('PrintModal — per-plate filament override in model mode (#2552)', ()
     ]);
   });
 });
+
+describe('PrintModal — per-plate quantity (#342)', () => {
+  const mockOnClose = vi.fn();
+
+  const PLATES = {
+    is_multi_plate: true,
+    plates: [
+      { index: 1, name: 'Plate 1', has_thumbnail: false, thumbnail_url: null, objects: ['A'], filaments: [{ type: 'PLA', color: '#FF0000' }], print_time_seconds: 1800, filament_used_grams: 50 },
+      { index: 2, name: 'Plate 2', has_thumbnail: false, thumbnail_url: null, objects: ['B'], filaments: [{ type: 'PLA', color: '#FF0000' }], print_time_seconds: 1800, filament_used_grams: 50 },
+      { index: 3, name: 'Plate 3', has_thumbnail: false, thumbnail_url: null, objects: ['C'], filaments: [{ type: 'PLA', color: '#FF0000' }], print_time_seconds: 1800, filament_used_grams: 50 },
+    ],
+  };
+
+  const renderModal = (ui: React.ReactElement) => {
+    const queryClient = new QueryClient({
+      defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+    });
+    queryClient.setQueryData(['archive-plates', 1], PLATES);
+    return rtlRender(
+      <QueryClientProvider client={queryClient}>
+        <BrowserRouter>
+          <AuthProvider>
+            <ThemeProvider>
+              <ToastProvider>{ui}</ToastProvider>
+            </ThemeProvider>
+          </AuthProvider>
+        </BrowserRouter>
+      </QueryClientProvider>,
+    );
+  };
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),
+      http.get('/api/v1/archives/:id/plates', () => HttpResponse.json(PLATES)),
+      http.get('/api/v1/archives/:id/filament-requirements', () =>
+        HttpResponse.json({ filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', tray_info_idx: '', used_grams: 50 }] }),
+      ),
+      http.get('/api/v1/printers/available-filaments', () => HttpResponse.json([])),
+      http.post('/api/v1/queue/batches', () => HttpResponse.json({ id: 42, name: 'Order', status: 'active' })),
+      http.post('/api/v1/queue/', () => HttpResponse.json({ id: 1, status: 'pending' })),
+    );
+  });
+
+  const qtyInput = (plateName: string) =>
+    screen.getByRole('spinbutton', { name: new RegExp(plateName, 'i') });
+
+  it('replaces the single Quantity field with one control per selected plate', async () => {
+    const user = userEvent.setup();
+    renderModal(<PrintModal mode="create" archiveId={1} archiveName="Three.gcode.3mf" onClose={mockOnClose} />);
+
+    await waitFor(() => expect(screen.getByText('Plate 2')).toBeInTheDocument());
+    // Plate 1 is auto-selected and already carries its own quantity control.
+    expect(qtyInput('Plate 1')).toBeInTheDocument();
+    // The global field is gone — two controls for the same number would be ambiguous.
+    expect(screen.queryByLabelText(/^Quantity$/i)).not.toBeInTheDocument();
+    // Unselected plates have no control.
+    expect(screen.queryByRole('spinbutton', { name: /Plate 2/i })).not.toBeInTheDocument();
+
+    await user.click(screen.getByText('Plate 2'));
+    await waitFor(() => expect(qtyInput('Plate 2')).toBeInTheDocument());
+  });
+
+  it('keeps the single Quantity field for a single-plate file', async () => {
+    const queryClient = new QueryClient({
+      defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+    });
+    queryClient.setQueryData(['archive-plates', 1], { is_multi_plate: false, plates: [] });
+    rtlRender(
+      <QueryClientProvider client={queryClient}>
+        <BrowserRouter>
+          <AuthProvider>
+            <ThemeProvider>
+              <ToastProvider>
+                <PrintModal mode="create" archiveId={1} archiveName="One.gcode.3mf" onClose={mockOnClose} />
+              </ToastProvider>
+            </ThemeProvider>
+          </AuthProvider>
+        </BrowserRouter>
+      </QueryClientProvider>,
+    );
+
+    await waitFor(() => expect(screen.getByLabelText(/^Quantity$/i)).toBeInTheDocument());
+  });
+
+  it('queues the reporter\'s example: plate 1 once, plate 2 twice, plate 3 three times', async () => {
+    type Queued = { plate_id: number | null; quantity?: number; batch_id?: number };
+    type OrderBody = { plates?: Array<{ plate_id: number | null; quantity_target: number }> };
+    const queued: Queued[] = [];
+    let order: OrderBody | null = null;
+    server.use(
+      http.post('/api/v1/queue/batches', async ({ request }) => {
+        order = (await request.json()) as OrderBody;
+        return HttpResponse.json({ id: 42, name: 'Order', status: 'active' });
+      }),
+      http.post('/api/v1/queue/', async ({ request }) => {
+        queued.push((await request.json()) as Queued);
+        return HttpResponse.json({ id: queued.length, status: 'pending' });
+      }),
+    );
+
+    const user = userEvent.setup();
+    renderModal(<PrintModal mode="create" archiveId={1} archiveName="Three.gcode.3mf" onClose={mockOnClose} />);
+
+    await waitFor(() => expect(screen.getByText('Plate 2')).toBeInTheDocument());
+    await user.click(screen.getByText('Plate 2'));
+    await user.click(screen.getByText('Plate 3'));
+
+    fireEvent.change(qtyInput('Plate 2'), { target: { value: '2' } });
+    fireEvent.change(qtyInput('Plate 3'), { target: { value: '3' } });
+
+    await waitFor(() => expect(screen.getByText(/6 runs in total/i)).toBeInTheDocument());
+
+    await user.click(screen.getAllByRole('button', { name: /X1 Carbon/i })[0]);
+    await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
+
+    await waitFor(() => expect(queued.length).toBe(3));
+
+    // The order records the intent, so a failed run still reads as owed.
+    expect(order!.plates).toEqual([
+      expect.objectContaining({ plate_id: 1, quantity_target: 1 }),
+      expect.objectContaining({ plate_id: 2, quantity_target: 2 }),
+      expect.objectContaining({ plate_id: 3, quantity_target: 3 }),
+    ]);
+
+    // ...and the runs are dispatched immediately, one call per plate carrying
+    // that plate's own count. Quantity 1 is left off the payload as before.
+    const byPlate = Object.fromEntries(queued.map((q) => [q.plate_id, q]));
+    expect(byPlate[1].quantity).toBeUndefined();
+    expect(byPlate[2].quantity).toBe(2);
+    expect(byPlate[3].quantity).toBe(3);
+    expect(queued.every((q) => q.batch_id === 42)).toBe(true);
+  });
+
+  it('does not multiply per-plate counts across a multi-printer fan-out', async () => {
+    type Queued = { plate_id: number | null; quantity?: number; printer_id: number | null };
+    const queued: Queued[] = [];
+    server.use(
+      http.post('/api/v1/queue/', async ({ request }) => {
+        queued.push((await request.json()) as Queued);
+        return HttpResponse.json({ id: queued.length, status: 'pending' });
+      }),
+    );
+
+    const user = userEvent.setup();
+    renderModal(<PrintModal mode="create" archiveId={1} archiveName="Three.gcode.3mf" onClose={mockOnClose} />);
+
+    await waitFor(() => expect(screen.getByText('Plate 2')).toBeInTheDocument());
+    await user.click(screen.getByText('Plate 2'));
+    fireEvent.change(qtyInput('Plate 2'), { target: { value: '4' } });
+
+    // Two printers: the printer count already answers "how many".
+    await user.click(screen.getAllByRole('button', { name: /X1 Carbon/i })[0]);
+    await user.click(screen.getAllByRole('button', { name: /P1S/i })[0]);
+    await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
+
+    await waitFor(() => expect(queued.length).toBe(4)); // 2 plates × 2 printers
+    expect(queued.every((q) => q.quantity === undefined)).toBe(true);
+  });
+});

+ 73 - 0
frontend/src/api/client.ts

@@ -2286,6 +2286,32 @@ export interface PrintQueueItem {
   cleanup_library_after_dispatch?: boolean;
 }
 
+export interface PrintBatchPlateTarget {
+  plate_id: number | null;
+  plate_name?: string | null;
+  quantity_target: number;
+  sort_order?: number;
+}
+
+export interface PrintBatchPlateProgress {
+  plate_id: number | null;
+  plate_name: string | null;
+  quantity_target: number;
+  dispatched: number;
+  remaining: number;
+  pending_count: number;
+  printing_count: number;
+  completed_count: number;
+  failed_count: number;
+  cancelled_count: number;
+  skipped_count: number;
+  /** Measured from finished runs; null until one has produced a cost. */
+  actual_cost: number | null;
+  estimated_remaining_cost: number | null;
+  filament_used_grams: number | null;
+  print_time_seconds: number;
+}
+
 export interface PrintBatch {
   id: number;
   name: string;
@@ -2294,13 +2320,28 @@ export interface PrintBatch {
   quantity: number;
   status: string;
   created_at: string;
+  completed_at: string | null;
   created_by_id: number | null;
   created_by_username: string | null;
+  project_id: number | null;
+  due_date: string | null;
+  notes: string | null;
   pending_count: number;
   printing_count: number;
   completed_count: number;
   failed_count: number;
   cancelled_count: number;
+  skipped_count: number;
+  /** False for batches created before per-plate targets existed (#342):
+   *  they report progress but owe nothing and cannot be dispatched from. */
+  has_targets: boolean;
+  target_count: number;
+  remaining_count: number;
+  actual_cost: number | null;
+  estimated_remaining_cost: number | null;
+  filament_used_grams: number | null;
+  print_time_seconds: number;
+  plates: PrintBatchPlateProgress[];
 }
 
 export interface PrintQueueItemCreate {
@@ -2367,6 +2408,28 @@ export interface PrintBatchCreate {
    *  (manual "Group as batch"). When omitted/empty, an empty batch is
    *  returned so the client can pass batch_id on subsequent addToQueue calls. */
   item_ids?: number[];
+  /** Per-plate targets. Omitting them creates a plain grouping batch. */
+  plates?: PrintBatchPlateTarget[];
+  project_id?: number | null;
+  due_date?: string | null;
+  notes?: string | null;
+}
+
+export interface PrintBatchUpdate {
+  name?: string;
+  status?: 'active' | 'cancelled';
+  /** Replaces the full target set — a plate omitted here is removed. */
+  plates?: PrintBatchPlateTarget[];
+  project_id?: number | null;
+  due_date?: string | null;
+  notes?: string | null;
+}
+
+export interface PrintBatchDispatchRequest {
+  plate_id?: number | null;
+  only_plate?: boolean;
+  /** Cap on items created across all plates; omit to queue everything owed. */
+  limit?: number;
 }
 
 export interface PrintQueueItemUpdate {
@@ -5204,6 +5267,16 @@ export const api = {
       method: 'POST',
       body: JSON.stringify(data),
     }),
+  updateBatch: (id: number, data: PrintBatchUpdate) =>
+    request<PrintBatch>(`/queue/batches/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  dispatchBatch: (id: number, data: PrintBatchDispatchRequest = {}) =>
+    request<PrintBatch>(`/queue/batches/${id}/dispatch`, {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
   ungroupBatch: (id: number) =>
     request<{ ungrouped_count: number; message: string }>(
       `/queue/batches/${id}/ungroup`,

+ 345 - 0
frontend/src/components/BatchOrdersView.tsx

@@ -0,0 +1,345 @@
+import { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Package, Layers, PlayCircle, XCircle, AlertTriangle, Clock, Coins } from 'lucide-react';
+import { api } from '../api/client';
+import type { PrintBatch, PrintBatchPlateProgress, Permission } from '../api/client';
+import { Card } from './Card';
+import { Button } from './Button';
+import { ConfirmModal } from './ConfirmModal';
+import { useToast } from '../contexts/ToastContext';
+import { formatDuration, parseUTCDate } from '../utils/date';
+import { getCurrencySymbol } from '../utils/currency';
+
+type StatusFilter = 'active' | 'completed' | 'cancelled' | 'all';
+
+interface BatchOrdersViewProps {
+  hasPermission: (p: Permission) => boolean;
+  t: (key: string, options?: Record<string, unknown>) => string;
+}
+
+/**
+ * Batch orders tab (#342).
+ *
+ * An order lives longer than the queue it spawned: once its runs finish they
+ * leave the active queue entirely, so the Queue and History tabs each hold
+ * only half the picture. This is the one place that shows what was asked for
+ * against what has actually been produced — including the runs that failed and
+ * are therefore still owed.
+ */
+export function BatchOrdersView({ hasPermission, t }: BatchOrdersViewProps) {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const [statusFilter, setStatusFilter] = useState<StatusFilter>('active');
+  const [cancelTarget, setCancelTarget] = useState<PrintBatch | null>(null);
+
+  const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
+  const currency = getCurrencySymbol(settings?.currency || 'USD');
+
+  const { data: batches, isLoading } = useQuery({
+    queryKey: ['batches', statusFilter],
+    queryFn: () => api.getBatches(statusFilter === 'all' ? undefined : statusFilter),
+  });
+
+  const invalidate = () => {
+    queryClient.invalidateQueries({ queryKey: ['batches'] });
+    queryClient.invalidateQueries({ queryKey: ['queue'] });
+  };
+
+  const dispatchMutation = useMutation({
+    mutationFn: ({ id, plateId }: { id: number; plateId?: number | null }) =>
+      api.dispatchBatch(id, plateId !== undefined ? { plate_id: plateId, only_plate: true } : {}),
+    onSuccess: (batch) => {
+      invalidate();
+      showToast(t('queue.batchOrders.dispatched', { name: batch.name }), 'success');
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
+  const cancelMutation = useMutation({
+    mutationFn: (id: number) => api.cancelBatch(id),
+    onSuccess: () => {
+      invalidate();
+      setCancelTarget(null);
+      showToast(t('queue.batchCancelled'), 'success');
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
+  const canDispatch = hasPermission('queue:create' as Permission);
+  const canCancel = hasPermission('queue:delete_all' as Permission);
+
+  const filters: StatusFilter[] = ['active', 'completed', 'cancelled', 'all'];
+
+  return (
+    <div>
+      <div className="flex flex-wrap items-center gap-2 mb-4">
+        <Package className="w-5 h-5 text-cyan-700 dark:text-cyan-300" />
+        <h2 className="text-base sm:text-lg font-semibold text-white">{t('queue.batchOrders.title')}</h2>
+        <div className="flex gap-1 ml-auto">
+          {filters.map((value) => (
+            <button
+              key={value}
+              type="button"
+              onClick={() => setStatusFilter(value)}
+              className={`text-xs px-2.5 py-1 rounded-full border transition-colors ${
+                statusFilter === value
+                  ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
+                  : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-gray'
+              }`}
+            >
+              {t(`queue.batchOrders.filter.${value}`)}
+            </button>
+          ))}
+        </div>
+      </div>
+
+      {isLoading ? (
+        <div className="text-center py-12 text-bambu-gray">{t('common.loading')}</div>
+      ) : !batches?.length ? (
+        <Card className="p-12 text-center border-dashed">
+          <Package className="w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50" />
+          <h3 className="text-xl font-medium text-white mb-2">{t('queue.batchOrders.emptyTitle')}</h3>
+          <p className="text-bambu-gray max-w-md mx-auto">{t('queue.batchOrders.emptyDescription')}</p>
+        </Card>
+      ) : (
+        <div className="space-y-4">
+          {batches.map((batch) => (
+            <BatchOrderCard
+              key={batch.id}
+              batch={batch}
+              currency={currency}
+              canDispatch={canDispatch}
+              canCancel={canCancel}
+              isDispatching={dispatchMutation.isPending && dispatchMutation.variables?.id === batch.id}
+              onDispatch={(plateId) => dispatchMutation.mutate({ id: batch.id, plateId })}
+              onCancel={() => setCancelTarget(batch)}
+              t={t}
+            />
+          ))}
+        </div>
+      )}
+
+      {cancelTarget && (
+        <ConfirmModal
+          title={t('queue.cancelBatchConfirmTitle')}
+          message={t('queue.cancelBatchConfirmMessage')}
+          confirmText={t('queue.cancelBatch')}
+          variant="warning"
+          onConfirm={() => cancelMutation.mutate(cancelTarget.id)}
+          onCancel={() => setCancelTarget(null)}
+        />
+      )}
+    </div>
+  );
+}
+
+const STATUS_STYLES: Record<string, string> = {
+  active: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300',
+  completed: 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300',
+  cancelled: 'bg-bambu-dark-tertiary text-bambu-gray',
+};
+
+function BatchOrderCard({
+  batch,
+  currency,
+  canDispatch,
+  canCancel,
+  isDispatching,
+  onDispatch,
+  onCancel,
+  t,
+}: {
+  batch: PrintBatch;
+  currency: string;
+  canDispatch: boolean;
+  canCancel: boolean;
+  isDispatching: boolean;
+  onDispatch: (plateId?: number | null) => void;
+  onCancel: () => void;
+  t: (key: string, options?: Record<string, unknown>) => string;
+}) {
+  // Progress is measured against the target, not against what was queued —
+  // that is the whole difference between an order and a grouping.
+  const denominator = batch.has_targets ? batch.target_count : batch.completed_count + batch.pending_count
+    + batch.printing_count + batch.failed_count;
+  const percent = denominator > 0 ? Math.round((batch.completed_count / denominator) * 100) : 0;
+  const dueDate = batch.due_date ? parseUTCDate(batch.due_date) : null;
+  const isOverdue = dueDate != null && batch.status === 'active' && dueDate.getTime() < Date.now();
+
+  return (
+    <Card className="p-4">
+      <div className="flex flex-wrap items-start gap-3 mb-3">
+        <div className="min-w-0 flex-1">
+          <div className="flex items-center gap-2 flex-wrap">
+            <p className="text-white font-medium truncate">{batch.name}</p>
+            <span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_STYLES[batch.status] ?? STATUS_STYLES.cancelled}`}>
+              {t(`queue.batchOrders.status.${batch.status}`)}
+            </span>
+            {!batch.has_targets && (
+              <span
+                className="text-xs px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray"
+                title={t('queue.batchOrders.noTargetsHint')}
+              >
+                {t('queue.batchOrders.noTargets')}
+              </span>
+            )}
+          </div>
+          <p className="text-xs text-bambu-gray mt-1">
+            {batch.created_by_username
+              ? t('queue.addedBy', { name: batch.created_by_username })
+              : null}
+            {dueDate && (
+              <span className={isOverdue ? 'text-orange-600 dark:text-orange-400' : ''}>
+                {batch.created_by_username ? ' • ' : ''}
+                {t('queue.batchOrders.due', { date: dueDate.toLocaleDateString() })}
+              </span>
+            )}
+          </p>
+          {batch.notes && <p className="text-xs text-bambu-gray mt-1 whitespace-pre-wrap">{batch.notes}</p>}
+        </div>
+
+        <div className="flex items-center gap-2 flex-shrink-0">
+          {batch.has_targets && batch.remaining_count > 0 && batch.status !== 'cancelled' && canDispatch && (
+            <Button variant="primary" size="sm" onClick={() => onDispatch()} disabled={isDispatching}>
+              <PlayCircle className="w-4 h-4 mr-1" />
+              {t('queue.batchOrders.dispatchRemaining', { count: batch.remaining_count })}
+            </Button>
+          )}
+          {batch.status === 'active' && batch.pending_count > 0 && canCancel && (
+            <Button variant="ghost" size="sm" onClick={onCancel}>
+              <XCircle className="w-4 h-4 mr-1" />
+              {t('queue.cancelBatch')}
+            </Button>
+          )}
+        </div>
+      </div>
+
+      <div className="flex items-center gap-3 mb-2">
+        <div className="flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
+          <div
+            className={`h-full rounded-full transition-all ${
+              batch.status === 'completed' ? 'bg-bambu-green' : 'bg-blue-500'
+            }`}
+            style={{ width: `${Math.min(100, percent)}%` }}
+          />
+        </div>
+        <span className="text-xs text-bambu-gray whitespace-nowrap tabular-nums">
+          {t('queue.batchProgress', { completed: batch.completed_count, total: denominator })}
+        </span>
+      </div>
+
+      <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-bambu-gray mb-1">
+        {batch.printing_count > 0 && <span>{t('queue.batchOrders.printing', { count: batch.printing_count })}</span>}
+        {batch.pending_count > 0 && <span>{t('queue.batch.pendingCount', { count: batch.pending_count })}</span>}
+        {batch.failed_count > 0 && (
+          <span className="text-orange-600 dark:text-orange-400 flex items-center gap-1">
+            <AlertTriangle className="w-3 h-3" />
+            {t('queue.batchOrders.failed', { count: batch.failed_count })}
+          </span>
+        )}
+        {batch.has_targets && batch.remaining_count > 0 && (
+          <span>{t('queue.batchOrders.remaining', { count: batch.remaining_count })}</span>
+        )}
+        {batch.print_time_seconds > 0 && (
+          <span className="flex items-center gap-1">
+            <Clock className="w-3 h-3" />
+            {formatDuration(batch.print_time_seconds)}
+          </span>
+        )}
+        {batch.actual_cost != null && (
+          <span className="flex items-center gap-1">
+            <Coins className="w-3 h-3" />
+            <span>
+              {t('queue.batchOrders.costSoFar', {
+                amount: `${currency} ${batch.actual_cost.toFixed(2)}`,
+              })}
+            </span>
+            {batch.estimated_remaining_cost != null && batch.estimated_remaining_cost > 0 && (
+              <span>
+                {t('queue.batchOrders.costRemaining', {
+                  amount: `${currency} ${batch.estimated_remaining_cost.toFixed(2)}`,
+                })}
+              </span>
+            )}
+          </span>
+        )}
+      </div>
+
+      {batch.has_targets && batch.plates.length > 0 && (
+        <div className="mt-3 border-t border-bambu-dark-tertiary pt-3 space-y-1.5">
+          {batch.plates.map((plate) => (
+            <PlateRow
+              key={`${plate.plate_id ?? 'file'}`}
+              plate={plate}
+              batchStatus={batch.status}
+              currency={currency}
+              canDispatch={canDispatch}
+              isDispatching={isDispatching}
+              onDispatch={() => onDispatch(plate.plate_id)}
+              t={t}
+            />
+          ))}
+        </div>
+      )}
+    </Card>
+  );
+}
+
+function PlateRow({
+  plate,
+  batchStatus,
+  currency,
+  canDispatch,
+  isDispatching,
+  onDispatch,
+  t,
+}: {
+  plate: PrintBatchPlateProgress;
+  batchStatus: string;
+  currency: string;
+  canDispatch: boolean;
+  isDispatching: boolean;
+  onDispatch: () => void;
+  t: (key: string, options?: Record<string, unknown>) => string;
+}) {
+  const label = plate.plate_name
+    || (plate.plate_id != null ? t('queue.plateNumber', { index: plate.plate_id }) : t('queue.batchOrders.wholeFile'));
+
+  return (
+    <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
+      <Layers className="w-3.5 h-3.5 text-bambu-gray flex-shrink-0" />
+      <span className="text-white min-w-0 truncate">{label}</span>
+      <span className="text-bambu-gray tabular-nums">
+        {t('queue.batchOrders.plateProgress', {
+          completed: plate.completed_count,
+          target: plate.quantity_target,
+        })}
+      </span>
+      {plate.failed_count > 0 && (
+        <span className="text-orange-600 dark:text-orange-400">
+          {t('queue.batchOrders.failed', { count: plate.failed_count })}
+        </span>
+      )}
+      {plate.actual_cost != null && (
+        <span className="text-bambu-gray tabular-nums">{`${currency} ${plate.actual_cost.toFixed(2)}`}</span>
+      )}
+      {plate.remaining > 0 && batchStatus !== 'cancelled' && (
+        <span className="ml-auto flex items-center gap-2">
+          <span className="text-bambu-gray">
+            {t('queue.batchOrders.remaining', { count: plate.remaining })}
+          </span>
+          {canDispatch && (
+            <button
+              type="button"
+              onClick={onDispatch}
+              disabled={isDispatching}
+              className="text-bambu-green hover:underline disabled:opacity-50"
+            >
+              {t('queue.batchOrders.dispatchPlate')}
+            </button>
+          )}
+        </span>
+      )}
+    </div>
+  );
+}

+ 41 - 4
frontend/src/components/PrintModal/PlateSelector.tsx

@@ -19,6 +19,8 @@ export function PlateSelector({
   onSelectAll,
   onDeselectAll,
   multiSelect,
+  quantities,
+  onQuantityChange,
 }: PlateSelectorProps) {
   const { t } = useTranslation();
 
@@ -28,6 +30,10 @@ export function PlateSelector({
   }
 
   const allSelected = selectedPlates.size === plates.length;
+  const showQuantities = !!quantities && !!onQuantityChange;
+  const totalRuns = showQuantities
+    ? plates.reduce((sum, p) => (selectedPlates.has(p.index) ? sum + (quantities[p.index] ?? 1) : sum), 0)
+    : 0;
 
   return (
     <div className="mb-4">
@@ -60,15 +66,22 @@ export function PlateSelector({
         {plates.map((plate) => {
           const isSelected = selectedPlates.has(plate.index);
           return (
-            <button
+            /* The quantity stepper can't live inside the selection button —
+               nesting an input in a button is invalid and every keystroke
+               would toggle the plate. The card is a div; the selectable
+               region stays a button beside the stepper. */
+            <div
               key={plate.index}
-              type="button"
-              onClick={() => onToggle(plate.index)}
-              className={`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${
+              className={`flex items-center gap-2 p-2 rounded-lg border transition-colors ${
                 isSelected
                   ? 'border-bambu-green bg-bambu-green/10'
                   : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
               }`}
+            >
+            <button
+              type="button"
+              onClick={() => onToggle(plate.index)}
+              className="flex items-center gap-2 text-left min-w-0 flex-1"
             >
               {multiSelect && (
                 isSelected
@@ -114,9 +127,33 @@ export function PlateSelector({
                 <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
               )}
             </button>
+            {showQuantities && isSelected && (
+              <div className="flex items-center gap-1 flex-shrink-0">
+                <span className="text-xs text-bambu-gray" aria-hidden="true">×</span>
+                <input
+                  type="number"
+                  min={1}
+                  max={999}
+                  value={quantities[plate.index] ?? 1}
+                  aria-label={t('queue.plateQuantityLabel', {
+                    plate: plate.name || t('queue.plateNumber', { index: plate.index }),
+                  })}
+                  onChange={(e) =>
+                    onQuantityChange(plate.index, Math.max(1, Math.min(999, parseInt(e.target.value) || 1)))
+                  }
+                  className="w-14 px-1.5 py-1 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center focus:outline-none focus:ring-1 focus:ring-bambu-green"
+                />
+              </div>
+            )}
+            </div>
           );
         })}
       </div>
+      {showQuantities && totalRuns > selectedPlates.size && (
+        <p className="text-xs text-bambu-gray mt-2">
+          {t('queue.plateQuantityTotal', { count: totalRuns })}
+        </p>
+      )}
     </div>
   );
 }

+ 59 - 13
frontend/src/components/PrintModal/index.tsx

@@ -124,6 +124,11 @@ export function PrintModal({
   // Quantity — number of copies (creates a batch if > 1)
   const [quantity, setQuantity] = useState(1);
 
+  // Per-plate quantities for multi-plate files (#342). Keyed by plate index;
+  // a plate with no entry means one run. Only used in create mode on a
+  // multi-plate file, where it replaces the single global Quantity field.
+  const [plateQuantities, setPlateQuantities] = useState<Record<number, number>>({});
+
   const [printOptions, setPrintOptions] = useState<PrintOptions>(() => {
     if (mode === 'edit-queue-item' && queueItem) {
       return {
@@ -984,28 +989,45 @@ export function PrintModal({
       return;
     }
 
-    // Multi-plate auto-batch: when the user adds 2+ plates from one source in
-    // a single create submission, pre-create a PrintBatch and pass its
-    // id to each subsequent addToQueue call so the queue UI groups them as a
-    // collapsible batch. Only triggered for single-target submissions —
-    // multi-printer fan-out keeps the old per-item shape.
+    // Batch order (#342): a create submission that produces more than one run
+    // from one source is pre-created as a batch carrying per-plate targets,
+    // and its id is passed to each subsequent addToQueue call. The targets are
+    // what make the order able to say a failed run is still owed — without
+    // them the batch only knows what it happened to queue. Only for
+    // single-target submissions; multi-printer fan-out keeps the old per-item
+    // shape, where "how many" is answered by the printer count.
+    const plateTargets = platesToQueue.map((plate, index) => {
+      const plateIndex = plate ? plate.index : selectedPlate;
+      return {
+        plate_id: plateIndex,
+        plate_name: plate ? (plate.name || null) : null,
+        quantity_target: quantityForPlate(plateIndex),
+        sort_order: index,
+      };
+    });
+    const totalRuns = plateTargets.reduce((sum, target) => sum + target.quantity_target, 0);
     const shouldAutoBatch =
       mode === 'create'
-      && platesToQueue.length > 1
+      && (platesToQueue.length > 1 || totalRuns > 1)
       && (assignmentMode === 'model' || selectedPrinters.length === 1);
     let autoBatchId: number | null = null;
     if (shouldAutoBatch) {
       try {
         const baseName = (archiveName || '').replace(/\.gcode\.3mf$/i, '').replace(/\.3mf$/i, '');
-        const batchName = `${baseName || 'Batch'} · ${platesToQueue.length} plates`;
+        const batchName = platesToQueue.length > 1
+          ? `${baseName || 'Batch'} · ${platesToQueue.length} plates`
+          : `${baseName || 'Batch'} ×${totalRuns}`;
         const batch = await api.createBatch({
           name: batchName,
           archive_id: isLibraryFile ? undefined : archiveId,
           library_file_id: isLibraryFile ? libraryFileId : undefined,
+          plates: plateTargets,
         });
         autoBatchId = batch.id;
       } catch {
         // Non-fatal: fall back to ungrouped items so the queue still works.
+        // The server still creates a plain batch when quantity > 1, so the
+        // queue grouping survives even when the order layer doesn't.
         autoBatchId = null;
       }
     }
@@ -1087,8 +1109,9 @@ export function PrintModal({
           } else {
             // Add-to-queue mode with model-based assignment
             const queueData = getQueueData(null, plateId);
-            if (effectiveQuantity > 1) queueData.quantity = effectiveQuantity;
-            applyAsapInsertion(queueData, null, effectiveQuantity);
+            const plateQuantity = quantityForPlate(plateId);
+            if (plateQuantity > 1) queueData.quantity = plateQuantity;
+            applyAsapInsertion(queueData, null, plateQuantity);
             await addToQueueMutation.mutateAsync(queueData);
           }
           results.success++;
@@ -1142,8 +1165,9 @@ export function PrintModal({
             } else {
               // New print mode, staggered print, or edit mode with additional entries
               const queueData = getQueueData(printerId, plateId);
-              if (effectiveQuantity > 1) queueData.quantity = effectiveQuantity;
-              applyAsapInsertion(queueData, printerId, effectiveQuantity);
+              const plateQuantity = quantityForPlate(plateId);
+              if (plateQuantity > 1) queueData.quantity = plateQuantity;
+              applyAsapInsertion(queueData, printerId, plateQuantity);
               // Apply stagger offset for groups after the first
               if (useStagger) {
                 const groupIndex = Math.floor(i / scheduleOptions.staggerGroupSize);
@@ -1250,6 +1274,21 @@ export function PrintModal({
   // Quantity only applies for single-printer or model-based assignment (not multi-printer)
   const effectiveQuantity = (assignmentMode === 'printer' && selectedPrinters.length > 1) ? 1 : quantity;
 
+  // On a multi-plate file the per-plate steppers own the quantity and the
+  // global field is hidden (#342) — the reporter's case is "plate 1 once,
+  // plate 2 twice", which one shared number cannot express. Single-plate
+  // files, and edit mode, keep the single field exactly as before.
+  const usePerPlateQuantities = mode === 'create' && isMultiPlate && plates.length > 1;
+
+  /** Runs to queue for one plate. `null` = the single-plate / whole-file case. */
+  const quantityForPlate = (plateIndex: number | null): number => {
+    if (!usePerPlateQuantities || plateIndex == null) return effectiveQuantity;
+    // Multi-printer fan-out already means one copy per printer; multiplying by
+    // a per-plate count on top would silently produce plates × printers × n.
+    if (assignmentMode === 'printer' && selectedPrinters.length > 1) return 1;
+    return Math.max(1, plateQuantities[plateIndex] ?? 1);
+  };
+
   // Clear gcode_injection if the admin removes all snippets while the modal
   // is open — the checkbox itself hides via hasGcodeSnippets in
   // ScheduleOptions, but the boolean would otherwise stay true and ship to
@@ -1411,6 +1450,10 @@ export function PrintModal({
               onSelectAll={!isEditing ? () => setSelectedPlates(new Set(plates.map(p => p.index))) : undefined}
               onDeselectAll={!isEditing ? () => setSelectedPlates(new Set()) : undefined}
               multiSelect={!isEditing}
+              quantities={usePerPlateQuantities ? plateQuantities : undefined}
+              onQuantityChange={usePerPlateQuantities
+                ? (plateIndex, value) => setPlateQuantities(prev => ({ ...prev, [plateIndex]: value }))
+                : undefined}
             />
 
             {/* Cross-model alternatives (#671) replace the printer picker entirely:
@@ -1606,8 +1649,11 @@ export function PrintModal({
               />
             )}
 
-            {/* Quantity — create multiple copies (batch). Hidden for multi-printer selection. */}
-            {mode !== 'edit-queue-item' && (assignmentMode === 'model' || selectedPrinters.length <= 1) && (
+            {/* Quantity — create multiple copies (batch). Hidden for multi-printer
+                selection, and for multi-plate files where the per-plate steppers
+                in PlateSelector own the number instead (#342). */}
+            {mode !== 'edit-queue-item' && !usePerPlateQuantities
+              && (assignmentMode === 'model' || selectedPrinters.length <= 1) && (
               <div className="flex items-center gap-3">
                 <label htmlFor="printQuantity" className="text-sm text-bambu-gray whitespace-nowrap">
                   {t('queue.quantity', 'Quantity')}

+ 8 - 0
frontend/src/components/PrintModal/types.ts

@@ -197,6 +197,14 @@ export interface PlateSelectorProps {
   onDeselectAll?: () => void;
   /** Whether multi-select (checkboxes) is enabled */
   multiSelect?: boolean;
+  /**
+   * How many runs of each plate to queue, keyed by plate index (#342). When
+   * provided, each selected plate gets its own quantity control and the
+   * modal's single global Quantity field is hidden — one number per plate is
+   * the whole point, and two controls for the same value would be ambiguous.
+   */
+  quantities?: Record<number, number>;
+  onQuantityChange?: (plateIndex: number, quantity: number) => void;
 }
 
 /**

+ 33 - 0
frontend/src/i18n/locales/de.ts

@@ -1198,6 +1198,38 @@ export default {
     // Batch / quantity
     quantity: 'Menge',
     quantityHint: 'Erstellt {{count}} Warteschlangeneinträge',
+    plateQuantityLabel: 'Durchläufe von {{plate}}',
+    plateQuantityTotal_one: '{{count}} Durchlauf insgesamt',
+    plateQuantityTotal_other: '{{count}} Durchläufe insgesamt',
+    batchOrders: {
+      title: 'Stapelaufträge',
+      emptyTitle: 'Keine Stapelaufträge',
+      emptyDescription: 'Stelle eine Datei mit mehreren Druckplatten oder mehrere Kopien einer Platte in die Warteschlange — der Auftrag erscheint hier mit seinem Fortschritt.',
+      filter: {
+        active: 'Aktiv',
+        completed: 'Abgeschlossen',
+        cancelled: 'Abgebrochen',
+        all: 'Alle',
+      },
+      status: {
+        active: 'Aktiv',
+        completed: 'Abgeschlossen',
+        cancelled: 'Abgebrochen',
+      },
+      noTargets: 'Nur Gruppierung',
+      noTargetsHint: 'Vor der Einführung von Zielmengen pro Platte erstellt; verfolgt daher die eingereihten Durchläufe statt einer Zielmenge.',
+      dispatchRemaining: '{{count}} verbleibende einreihen',
+      dispatchPlate: 'Rest einreihen',
+      dispatched: 'Verbleibende Durchläufe für {{name}} eingereiht',
+      plateProgress: '{{completed}} von {{target}} fertig',
+      remaining: '{{count}} noch offen',
+      failed: '{{count}} fehlgeschlagen',
+      printing: '{{count}} im Druck',
+      wholeFile: 'Gesamte Datei',
+      due: 'Fällig {{date}}',
+      costSoFar: '{{amount}} bisher',
+      costRemaining: '({{amount}} verbleibend)',
+    },
     activeBatches: 'Aktive Stapel',
     batchProgress: '{{completed}} von {{total}} abgeschlossen',
     cancelBatch: 'Verbleibende abbrechen',
@@ -1223,6 +1255,7 @@ export default {
       dragGroup: 'Gruppe ziehen',
     },
     tabs: {
+      batches: 'Stapel',
       queue: 'Warteschlange',
       history: 'Verlauf',
       timeline: 'Zeitachse',

+ 33 - 0
frontend/src/i18n/locales/en.ts

@@ -1207,6 +1207,38 @@ export default {
     // Batch / quantity
     quantity: 'Quantity',
     quantityHint: 'Creates {{count}} queue items',
+    plateQuantityLabel: 'Runs of {{plate}}',
+    plateQuantityTotal_one: '{{count}} run in total',
+    plateQuantityTotal_other: '{{count}} runs in total',
+    batchOrders: {
+      title: 'Batch Orders',
+      emptyTitle: 'No batch orders',
+      emptyDescription: 'Queue a multi-plate file, or more than one copy of a plate, and the order shows up here with its progress.',
+      filter: {
+        active: 'Active',
+        completed: 'Completed',
+        cancelled: 'Cancelled',
+        all: 'All',
+      },
+      status: {
+        active: 'Active',
+        completed: 'Completed',
+        cancelled: 'Cancelled',
+      },
+      noTargets: 'Grouping only',
+      noTargetsHint: 'Created before per-plate targets existed, so it tracks the runs it queued rather than a target.',
+      dispatchRemaining: 'Queue {{count}} remaining',
+      dispatchPlate: 'Queue remaining',
+      dispatched: 'Queued the remaining runs for {{name}}',
+      plateProgress: '{{completed}} of {{target}} done',
+      remaining: '{{count}} still owed',
+      failed: '{{count}} failed',
+      printing: '{{count}} printing',
+      wholeFile: 'Whole file',
+      due: 'Due {{date}}',
+      costSoFar: '{{amount}} so far',
+      costRemaining: '({{amount}} to go)',
+    },
     activeBatches: 'Active Batches',
     batchProgress: '{{completed}} of {{total}} completed',
     cancelBatch: 'Cancel Remaining',
@@ -1233,6 +1265,7 @@ export default {
     },
     // Tabs
     tabs: {
+      batches: 'Batches',
       queue: 'Queue',
       history: 'History',
       timeline: 'Timeline',

+ 33 - 0
frontend/src/i18n/locales/es.ts

@@ -1198,6 +1198,38 @@ export default {
     // Batch / quantity
     quantity: 'Cantidad',
     quantityHint: 'Crea {{count}} elementos en la cola',
+    plateQuantityLabel: 'Impresiones de {{plate}}',
+    plateQuantityTotal_one: '{{count}} impresión en total',
+    plateQuantityTotal_other: '{{count}} impresiones en total',
+    batchOrders: {
+      title: 'Pedidos por lotes',
+      emptyTitle: 'Sin pedidos por lotes',
+      emptyDescription: 'Pon en cola un archivo de varias bandejas, o más de una copia de una bandeja, y el pedido aparecerá aquí con su progreso.',
+      filter: {
+        active: 'Activos',
+        completed: 'Completados',
+        cancelled: 'Cancelados',
+        all: 'Todos',
+      },
+      status: {
+        active: 'Activo',
+        completed: 'Completado',
+        cancelled: 'Cancelado',
+      },
+      noTargets: 'Solo agrupación',
+      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',
+      dispatchPlate: 'Encolar restantes',
+      dispatched: 'Impresiones restantes encoladas para {{name}}',
+      plateProgress: '{{completed}} de {{target}} hechas',
+      remaining: '{{count}} pendientes',
+      failed: '{{count}} fallidas',
+      printing: '{{count}} imprimiendo',
+      wholeFile: 'Archivo completo',
+      due: 'Vence {{date}}',
+      costSoFar: '{{amount}} hasta ahora',
+      costRemaining: '({{amount}} por gastar)',
+    },
     activeBatches: 'Lotes activos',
     batchProgress: '{{completed}} de {{total}} completados',
     cancelBatch: 'Cancelar los restantes',
@@ -1223,6 +1255,7 @@ export default {
       dragGroup: 'Arrastrar grupo',
     },
     tabs: {
+      batches: 'Lotes',
       queue: 'Cola',
       history: 'Historial',
       timeline: 'Cronología',

+ 33 - 0
frontend/src/i18n/locales/fr.ts

@@ -1198,6 +1198,38 @@ export default {
     // Batch / quantity
     quantity: 'Quantité',
     quantityHint: 'Crée {{count}} éléments de file d\'attente',
+    plateQuantityLabel: 'Impressions de {{plate}}',
+    plateQuantityTotal_one: '{{count}} impression au total',
+    plateQuantityTotal_other: '{{count}} impressions au total',
+    batchOrders: {
+      title: 'Commandes par lot',
+      emptyTitle: 'Aucune commande par lot',
+      emptyDescription: 'Mettez en file un fichier multi-plateaux, ou plusieurs copies d\'un plateau, et la commande apparaît ici avec son avancement.',
+      filter: {
+        active: 'Actives',
+        completed: 'Terminées',
+        cancelled: 'Annulées',
+        all: 'Toutes',
+      },
+      status: {
+        active: 'Active',
+        completed: 'Terminée',
+        cancelled: 'Annulée',
+      },
+      noTargets: 'Regroupement seul',
+      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',
+      dispatchPlate: 'Mettre le reste en file',
+      dispatched: 'Impressions restantes mises en file pour {{name}}',
+      plateProgress: '{{completed}} sur {{target}} faites',
+      remaining: '{{count}} encore dues',
+      failed: '{{count}} échouées',
+      printing: '{{count}} en cours',
+      wholeFile: 'Fichier entier',
+      due: 'Échéance {{date}}',
+      costSoFar: '{{amount}} à ce jour',
+      costRemaining: '({{amount}} à venir)',
+    },
     activeBatches: 'Lots actifs',
     batchProgress: '{{completed}} sur {{total}} terminés',
     cancelBatch: 'Annuler les restants',
@@ -1223,6 +1255,7 @@ export default {
       dragGroup: 'Faire glisser le groupe',
     },
     tabs: {
+      batches: 'Lots',
       queue: 'File',
       history: 'Historique',
       timeline: 'Chronologie',

+ 33 - 0
frontend/src/i18n/locales/it.ts

@@ -1198,6 +1198,38 @@ export default {
     // Batch / quantity
     quantity: 'Quantità',
     quantityHint: 'Crea {{count}} elementi in coda',
+    plateQuantityLabel: 'Stampe di {{plate}}',
+    plateQuantityTotal_one: '{{count}} stampa in totale',
+    plateQuantityTotal_other: '{{count}} stampe in totale',
+    batchOrders: {
+      title: 'Ordini in lotto',
+      emptyTitle: 'Nessun ordine in lotto',
+      emptyDescription: 'Metti in coda un file multi-piatto, o più copie di un piatto, e l\'ordine compare qui con il suo avanzamento.',
+      filter: {
+        active: 'Attivi',
+        completed: 'Completati',
+        cancelled: 'Annullati',
+        all: 'Tutti',
+      },
+      status: {
+        active: 'Attivo',
+        completed: 'Completato',
+        cancelled: 'Annullato',
+      },
+      noTargets: 'Solo raggruppamento',
+      noTargetsHint: 'Creato prima delle quantità per piatto: tiene traccia delle stampe in coda anziché di un obiettivo.',
+      dispatchRemaining: 'Accoda {{count}} rimanenti',
+      dispatchPlate: 'Accoda le rimanenti',
+      dispatched: 'Stampe rimanenti accodate per {{name}}',
+      plateProgress: '{{completed}} di {{target}} fatte',
+      remaining: '{{count}} ancora dovute',
+      failed: '{{count}} fallite',
+      printing: '{{count}} in stampa',
+      wholeFile: 'Intero file',
+      due: 'Scadenza {{date}}',
+      costSoFar: '{{amount}} finora',
+      costRemaining: '({{amount}} da spendere)',
+    },
     activeBatches: 'Lotti attivi',
     batchProgress: '{{completed}} di {{total}} completati',
     cancelBatch: 'Annulla rimanenti',
@@ -1223,6 +1255,7 @@ export default {
       dragGroup: 'Trascina gruppo',
     },
     tabs: {
+      batches: 'Lotti',
       queue: 'Coda',
       history: 'Cronologia',
       timeline: 'Linea temporale',

+ 33 - 0
frontend/src/i18n/locales/ja.ts

@@ -1197,6 +1197,38 @@ export default {
     // Batch / quantity
     quantity: '数量',
     quantityHint: '{{count}}件のキューアイテムを作成',
+    plateQuantityLabel: '{{plate}} の実行回数',
+    plateQuantityTotal_one: '合計 {{count}} 回',
+    plateQuantityTotal_other: '合計 {{count}} 回',
+    batchOrders: {
+      title: 'バッチ注文',
+      emptyTitle: 'バッチ注文はありません',
+      emptyDescription: '複数プレートのファイル、または同じプレートを複数回キューに入れると、注文がここに進捗とともに表示されます。',
+      filter: {
+        active: '進行中',
+        completed: '完了',
+        cancelled: 'キャンセル済み',
+        all: 'すべて',
+      },
+      status: {
+        active: '進行中',
+        completed: '完了',
+        cancelled: 'キャンセル済み',
+      },
+      noTargets: 'グループのみ',
+      noTargetsHint: 'プレートごとの目標数が導入される前に作成されたため、目標ではなくキューに入れた実行を追跡します。',
+      dispatchRemaining: '残り{{count}}件をキューに追加',
+      dispatchPlate: '残りをキューに追加',
+      dispatched: '{{name}} の残りの実行をキューに追加しました',
+      plateProgress: '{{target}}件中{{completed}}件完了',
+      remaining: '残り{{count}}件',
+      failed: '{{count}}件失敗',
+      printing: '{{count}}件印刷中',
+      wholeFile: 'ファイル全体',
+      due: '期限 {{date}}',
+      costSoFar: 'これまで {{amount}}',
+      costRemaining: '(残り {{amount}})',
+    },
     activeBatches: 'アクティブなバッチ',
     batchProgress: '{{total}}件中{{completed}}件完了',
     cancelBatch: '残りをキャンセル',
@@ -1222,6 +1254,7 @@ export default {
       dragGroup: 'グループをドラッグ',
     },
     tabs: {
+      batches: 'バッチ',
       queue: 'キュー',
       history: '履歴',
       timeline: 'タイムライン',

+ 33 - 0
frontend/src/i18n/locales/ko.ts

@@ -1136,6 +1136,38 @@ export default {
     plateNumber: '플레이트 {{index}}',
     quantity: '수량',
     quantityHint: '{{count}}개 대기열 항목 생성',
+    plateQuantityLabel: '{{plate}} 실행 횟수',
+    plateQuantityTotal_one: '총 {{count}}회',
+    plateQuantityTotal_other: '총 {{count}}회',
+    batchOrders: {
+      title: '배치 주문',
+      emptyTitle: '배치 주문 없음',
+      emptyDescription: '여러 플레이트 파일이나 한 플레이트의 여러 복사본을 대기열에 넣으면 주문이 진행 상황과 함께 여기에 표시됩니다.',
+      filter: {
+        active: '진행 중',
+        completed: '완료',
+        cancelled: '취소됨',
+        all: '전체',
+      },
+      status: {
+        active: '진행 중',
+        completed: '완료',
+        cancelled: '취소됨',
+      },
+      noTargets: '그룹만',
+      noTargetsHint: '플레이트별 목표 수량이 도입되기 전에 생성되어 목표가 아닌 대기열에 넣은 실행을 추적합니다.',
+      dispatchRemaining: '남은 {{count}}개 대기열 추가',
+      dispatchPlate: '남은 항목 추가',
+      dispatched: '{{name}}의 남은 실행을 대기열에 추가했습니다',
+      plateProgress: '{{target}}개 중 {{completed}}개 완료',
+      remaining: '{{count}}개 남음',
+      failed: '{{count}}개 실패',
+      printing: '{{count}}개 인쇄 중',
+      wholeFile: '파일 전체',
+      due: '기한 {{date}}',
+      costSoFar: '현재까지 {{amount}}',
+      costRemaining: '({{amount}} 남음)',
+    },
     activeBatches: '활성 배치',
     batchProgress: '{{total}}개 중 {{completed}}개 완료',
     cancelBatch: '나머지 취소',
@@ -1161,6 +1193,7 @@ export default {
       dragGroup: '그룹 드래그',
     },
     tabs: {
+      batches: '배치',
       queue: '큐',
       history: '기록',
       timeline: '타임라인',

+ 33 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -1198,6 +1198,38 @@ export default {
     // Batch / quantity
     quantity: 'Quantidade',
     quantityHint: 'Cria {{count}} itens na fila',
+    plateQuantityLabel: 'Impressões de {{plate}}',
+    plateQuantityTotal_one: '{{count}} impressão no total',
+    plateQuantityTotal_other: '{{count}} impressões no total',
+    batchOrders: {
+      title: 'Pedidos em lote',
+      emptyTitle: 'Nenhum pedido em lote',
+      emptyDescription: 'Coloque na fila um arquivo com várias mesas, ou mais de uma cópia de uma mesa, e o pedido aparece aqui com o progresso.',
+      filter: {
+        active: 'Ativos',
+        completed: 'Concluídos',
+        cancelled: 'Cancelados',
+        all: 'Todos',
+      },
+      status: {
+        active: 'Ativo',
+        completed: 'Concluído',
+        cancelled: 'Cancelado',
+      },
+      noTargets: 'Apenas agrupamento',
+      noTargetsHint: 'Criado antes das quantidades por mesa, então acompanha as impressões enfileiradas em vez de uma meta.',
+      dispatchRemaining: 'Enfileirar {{count}} restantes',
+      dispatchPlate: 'Enfileirar restantes',
+      dispatched: 'Impressões restantes enfileiradas para {{name}}',
+      plateProgress: '{{completed}} de {{target}} prontas',
+      remaining: '{{count}} ainda devidas',
+      failed: '{{count}} falharam',
+      printing: '{{count}} imprimindo',
+      wholeFile: 'Arquivo inteiro',
+      due: 'Vence {{date}}',
+      costSoFar: '{{amount}} até agora',
+      costRemaining: '({{amount}} a gastar)',
+    },
     activeBatches: 'Lotes ativos',
     batchProgress: '{{completed}} de {{total}} concluídos',
     cancelBatch: 'Cancelar restantes',
@@ -1223,6 +1255,7 @@ export default {
       dragGroup: 'Arrastar grupo',
     },
     tabs: {
+      batches: 'Lotes',
       queue: 'Fila',
       history: 'Histórico',
       timeline: 'Linha do tempo',

+ 33 - 0
frontend/src/i18n/locales/ru.ts

@@ -1146,6 +1146,38 @@ export default {
     plateNumber: "Пластина {{index}}",
     quantity: "Количество",
     quantityHint: "Будет создано заданий: {{count}}",
+    plateQuantityLabel: "Повторы: {{plate}}",
+    plateQuantityTotal_one: "Всего запусков: {{count}}",
+    plateQuantityTotal_other: "Всего запусков: {{count}}",
+    batchOrders: {
+      title: "Пакетные заказы",
+      emptyTitle: "Пакетных заказов нет",
+      emptyDescription: "Поставьте в очередь файл с несколькими столами или несколько копий одного стола — заказ появится здесь вместе с прогрессом.",
+      filter: {
+        active: "Активные",
+        completed: "Завершённые",
+        cancelled: "Отменённые",
+        all: "Все",
+      },
+      status: {
+        active: "Активен",
+        completed: "Завершён",
+        cancelled: "Отменён",
+      },
+      noTargets: "Только группировка",
+      noTargetsHint: "Создан до появления целевых количеств по столам, поэтому отслеживает поставленные в очередь запуски, а не цель.",
+      dispatchRemaining: "Поставить в очередь: {{count}}",
+      dispatchPlate: "Поставить остаток",
+      dispatched: "Оставшиеся запуски для «{{name}}» поставлены в очередь",
+      plateProgress: "Готово {{completed}} из {{target}}",
+      remaining: "Осталось: {{count}}",
+      failed: "Неудачных: {{count}}",
+      printing: "Печатается: {{count}}",
+      wholeFile: "Весь файл",
+      due: "Срок: {{date}}",
+      costSoFar: "{{amount}} потрачено",
+      costRemaining: "(ещё {{amount}})",
+    },
     activeBatches: "Активные партии",
     batchProgress: "Выполнено {{completed}} из {{total}}",
     cancelBatch: "Отменить оставшиеся",
@@ -1171,6 +1203,7 @@ export default {
       dragGroup: "Перетащить группу",
     },
     tabs: {
+      batches: "Партии",
       queue: "Очередь",
       history: "История",
       timeline: "Временная шкала",

+ 33 - 0
frontend/src/i18n/locales/tr.ts

@@ -1198,6 +1198,38 @@ export default {
     // Toplu / miktar
     quantity: 'Miktar',
     quantityHint: '{{count}} kuyruk öğesi oluşturur',
+    plateQuantityLabel: '{{plate}} baskı sayısı',
+    plateQuantityTotal_one: 'toplam {{count}} baskı',
+    plateQuantityTotal_other: 'toplam {{count}} baskı',
+    batchOrders: {
+      title: 'Toplu siparişler',
+      emptyTitle: 'Toplu sipariş yok',
+      emptyDescription: 'Çok tablalı bir dosyayı ya da bir tablanın birden fazla kopyasını kuyruğa alın; sipariş ilerlemesiyle birlikte burada görünür.',
+      filter: {
+        active: 'Etkin',
+        completed: 'Tamamlanan',
+        cancelled: 'İptal edilen',
+        all: 'Tümü',
+      },
+      status: {
+        active: 'Etkin',
+        completed: 'Tamamlandı',
+        cancelled: 'İptal edildi',
+      },
+      noTargets: 'Yalnızca gruplama',
+      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',
+      dispatchPlate: 'Kalanları kuyruğa al',
+      dispatched: '{{name}} için kalan baskılar kuyruğa alındı',
+      plateProgress: '{{target}} baskıdan {{completed}} tamam',
+      remaining: '{{count}} baskı bekliyor',
+      failed: '{{count}} başarısız',
+      printing: '{{count}} yazdırılıyor',
+      wholeFile: 'Dosyanın tamamı',
+      due: 'Son tarih {{date}}',
+      costSoFar: 'şu ana kadar {{amount}}',
+      costRemaining: '({{amount}} kaldı)',
+    },
     activeBatches: 'Aktif Yığınlar',
     batchProgress: '{{total}} öğeden {{completed}} tanesi tamamlandı',
     cancelBatch: 'Kalanları İptal Et',
@@ -1223,6 +1255,7 @@ export default {
       dragGroup: 'Grubu sürükle',
     },
     tabs: {
+      batches: 'Gruplar',
       queue: 'Kuyruk',
       history: 'Geçmiş',
       timeline: 'Zaman çizelgesi',

+ 33 - 0
frontend/src/i18n/locales/uk.ts

@@ -1207,6 +1207,38 @@ export default {
     // Batch / quantity
     quantity: "Кількість",
     quantityHint: "Буде створено елементів черги: {{count}}",
+    plateQuantityLabel: "Повтори: {{plate}}",
+    plateQuantityTotal_one: "Усього запусків: {{count}}",
+    plateQuantityTotal_other: "Усього запусків: {{count}}",
+    batchOrders: {
+      title: "Пакетні замовлення",
+      emptyTitle: "Пакетних замовлень немає",
+      emptyDescription: "Поставте в чергу файл із кількома столами або кілька копій одного стола — замовлення з’явиться тут разом із прогресом.",
+      filter: {
+        active: "Активні",
+        completed: "Завершені",
+        cancelled: "Скасовані",
+        all: "Усі",
+      },
+      status: {
+        active: "Активне",
+        completed: "Завершене",
+        cancelled: "Скасоване",
+      },
+      noTargets: "Лише групування",
+      noTargetsHint: "Створено до появи цільових кількостей на стіл, тож відстежує поставлені в чергу запуски, а не ціль.",
+      dispatchRemaining: "Поставити в чергу: {{count}}",
+      dispatchPlate: "Поставити решту",
+      dispatched: "Решту запусків для «{{name}}» поставлено в чергу",
+      plateProgress: "Готово {{completed}} з {{target}}",
+      remaining: "Залишилось: {{count}}",
+      failed: "Невдалих: {{count}}",
+      printing: "Друкується: {{count}}",
+      wholeFile: "Увесь файл",
+      due: "Термін: {{date}}",
+      costSoFar: "{{amount}} витрачено",
+      costRemaining: "(ще {{amount}})",
+    },
     activeBatches: "Активні партії",
     batchProgress: "{{completed}} з {{total}} завершено",
     cancelBatch: "Скасувати решту",
@@ -1233,6 +1265,7 @@ export default {
     },
     // Tabs
     tabs: {
+      batches: "Партії",
       queue: "Черга",
       history: "Історія",
       timeline: "Хронологія",

+ 33 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -1198,6 +1198,38 @@ export default {
     // Batch / quantity
     quantity: '数量',
     quantityHint: '创建 {{count}} 个队列项目',
+    plateQuantityLabel: '{{plate}} 的打印次数',
+    plateQuantityTotal_one: '共 {{count}} 次',
+    plateQuantityTotal_other: '共 {{count}} 次',
+    batchOrders: {
+      title: '批量订单',
+      emptyTitle: '暂无批量订单',
+      emptyDescription: '将多盘文件或同一盘的多份副本加入队列,订单就会连同进度显示在这里。',
+      filter: {
+        active: '进行中',
+        completed: '已完成',
+        cancelled: '已取消',
+        all: '全部',
+      },
+      status: {
+        active: '进行中',
+        completed: '已完成',
+        cancelled: '已取消',
+      },
+      noTargets: '仅分组',
+      noTargetsHint: '创建于按盘目标数量之前,因此跟踪的是已入队的打印,而不是目标数量。',
+      dispatchRemaining: '将剩余 {{count}} 个加入队列',
+      dispatchPlate: '将剩余加入队列',
+      dispatched: '已将 {{name}} 的剩余打印加入队列',
+      plateProgress: '已完成 {{completed}}/{{target}}',
+      remaining: '还差 {{count}} 个',
+      failed: '{{count}} 个失败',
+      printing: '{{count}} 个打印中',
+      wholeFile: '整个文件',
+      due: '截止 {{date}}',
+      costSoFar: '已花费 {{amount}}',
+      costRemaining: '(还需 {{amount}})',
+    },
     activeBatches: '活跃批次',
     batchProgress: '已完成 {{completed}}/{{total}}',
     cancelBatch: '取消剩余',
@@ -1223,6 +1255,7 @@ export default {
       dragGroup: '拖动批次',
     },
     tabs: {
+      batches: '批次',
       queue: '队列',
       history: '历史',
       timeline: '时间线',

+ 33 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -1198,6 +1198,38 @@ export default {
     // Batch / quantity
     quantity: '數量',
     quantityHint: '建立 {{count}} 個佇列項目',
+    plateQuantityLabel: '{{plate}} 的列印次數',
+    plateQuantityTotal_one: '共 {{count}} 次',
+    plateQuantityTotal_other: '共 {{count}} 次',
+    batchOrders: {
+      title: '批次訂單',
+      emptyTitle: '尚無批次訂單',
+      emptyDescription: '將多盤檔案或同一盤的多份副本加入佇列,訂單就會連同進度顯示在這裡。',
+      filter: {
+        active: '進行中',
+        completed: '已完成',
+        cancelled: '已取消',
+        all: '全部',
+      },
+      status: {
+        active: '進行中',
+        completed: '已完成',
+        cancelled: '已取消',
+      },
+      noTargets: '僅分組',
+      noTargetsHint: '建立於每盤目標數量之前,因此追蹤的是已加入佇列的列印,而不是目標數量。',
+      dispatchRemaining: '將剩餘 {{count}} 個加入佇列',
+      dispatchPlate: '將剩餘加入佇列',
+      dispatched: '已將 {{name}} 的剩餘列印加入佇列',
+      plateProgress: '已完成 {{completed}}/{{target}}',
+      remaining: '還差 {{count}} 個',
+      failed: '{{count}} 個失敗',
+      printing: '{{count}} 個列印中',
+      wholeFile: '整個檔案',
+      due: '截止 {{date}}',
+      costSoFar: '已花費 {{amount}}',
+      costRemaining: '(還需 {{amount}})',
+    },
     activeBatches: '活躍批次',
     batchProgress: '已完成 {{completed}}/{{total}}',
     cancelBatch: '取消剩餘',
@@ -1223,6 +1255,7 @@ export default {
       dragGroup: '拖曳批次',
     },
     tabs: {
+      batches: '批次',
       queue: '佇列',
       history: '歷史',
       timeline: '時間軸',

+ 20 - 5
frontend/src/pages/QueuePage.tsx

@@ -77,6 +77,7 @@ import { useAuth } from '../contexts/AuthContext';
 import { QueueStatsBar } from '../components/QueueStatsBar';
 import { CompactHistoryRow } from '../components/CompactHistoryRow';
 import { QueueTimelineView } from '../components/QueueTimelineView';
+import { BatchOrdersView } from '../components/BatchOrdersView';
 
 function formatWeight(g: number, useKg = false): string {
   if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`;
@@ -1444,16 +1445,16 @@ export function QueuePage() {
   // History tab renders unconditionally so this no longer drives the UI.
   // Tabbed page structure: Active queue stays as the main view; History
   // and Timeline split off. Persists per-user via localStorage.
-  const [activeTab, setActiveTab] = useState<'queue' | 'history' | 'timeline' | 'pipelines'>(() => {
+  const [activeTab, setActiveTab] = useState<'queue' | 'batches' | 'history' | 'timeline' | 'pipelines'>(() => {
     // URL deep-link wins so the legacy /pipelines/runs redirect lands on the
     // right tab. localStorage holds the per-user last-selected fallback.
     const search = new URLSearchParams(window.location.search);
     const url = search.get('tab');
-    if (url === 'pipelines' || url === 'history' || url === 'timeline' || url === 'queue') {
+    if (url === 'pipelines' || url === 'history' || url === 'timeline' || url === 'queue' || url === 'batches') {
       return url;
     }
     const saved = localStorage.getItem('queue.activeTab');
-    if (saved === 'history' || saved === 'timeline' || saved === 'pipelines') return saved;
+    if (saved === 'history' || saved === 'timeline' || saved === 'pipelines' || saved === 'batches') return saved;
     return 'queue';
   });
   // Active-tab layout toggle. "position" = today's flat list; "printer"
@@ -1528,6 +1529,17 @@ export function QueuePage() {
 
   const timeFormat: TimeFormat = settings?.time_format || 'system';
 
+  // Badge count for the Batches tab (#342). Deliberately its own query rather
+  // than derived from the queue: an order whose runs have all finished has no
+  // queue rows left, and those are precisely the orders the tab exists to
+  // surface. Shares the ['batches'] key with the tab itself, so dispatching or
+  // cancelling refreshes both.
+  const { data: activeBatches } = useQuery({
+    queryKey: ['batches', 'active'],
+    queryFn: () => api.getBatches('active'),
+  });
+  const activeBatchCount = activeBatches?.length ?? 0;
+
   const { data: queue, isLoading } = useQuery({
     queryKey: ['queue', filterPrinter, filterStatus],
     queryFn: () => api.getQueue(filterPrinter || undefined, filterStatus || undefined),
@@ -2313,6 +2325,7 @@ export function QueuePage() {
       <div className="flex gap-1 border-b border-bambu-dark-tertiary mb-6 overflow-x-auto">
         {([
           { id: 'queue' as const, label: t('queue.tabs.queue'), icon: Clock, count: pendingItems.length + activeItems.length },
+          { id: 'batches' as const, label: t('queue.tabs.batches'), icon: Package, count: activeBatchCount },
           { id: 'history' as const, label: t('queue.tabs.history'), icon: ListOrdered, count: historyItems.length },
           { id: 'timeline' as const, label: t('queue.tabs.timeline'), icon: GanttChart, count: null as number | null },
           // Slicer Pipelines dashboard (#1425 PR C). Lives here instead of
@@ -2343,7 +2356,7 @@ export function QueuePage() {
       </div>
 
       {/* Summary Stats — about the print queue, not pipelines. */}
-      {activeTab !== 'pipelines' && <QueueStatsBar
+      {activeTab !== 'pipelines' && activeTab !== 'batches' && <QueueStatsBar
         activeCount={activeItems.length}
         pendingCount={pendingItems.length}
         totalTime={totalQueueTime}
@@ -2392,7 +2405,7 @@ export function QueuePage() {
       {/* Filters — about the print queue items (printer / status / location).
           The Pipelines tab has its own pipeline + status filters inside the
           dashboard, so this row is hidden when that tab is active. */}
-      {activeTab !== 'pipelines' && (
+      {activeTab !== 'pipelines' && activeTab !== 'batches' && (
       <div className="flex flex-wrap items-center gap-2 sm:gap-4 mb-6">
         <select
           className="px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none"
@@ -2505,6 +2518,8 @@ export function QueuePage() {
           dashboard renders even when the regular queue is empty. */}
       {activeTab === 'pipelines' ? (
         <PipelineRunsView />
+      ) : activeTab === 'batches' ? (
+        <BatchOrdersView hasPermission={hasPermission} t={t} />
       ) : isLoading ? (
         <div className="text-center py-12 text-bambu-gray">{t('common.loading')}</div>
       ) : queue?.length === 0 ? (

File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/index-Ds22o6-q.js


+ 1 - 1
static/index.html

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

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini