Parcourir la source

Add batch print quantity to print/schedule dialog (#342)

maziggy il y a 1 mois
Parent
commit
3270179090

+ 1 - 0
CHANGELOG.md

@@ -14,6 +14,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Prefer Lowest Remaining Filament** ([#805](https://github.com/maziggy/bambuddy/issues/805)) — New optional setting in Settings → Filament that prefers AMS spools with the lowest remaining filament during auto-matching. When multiple spools match the same type and color, the one with the least filament remaining is selected first. Helps consume partial spools before starting new ones. Applies to queue scheduling, print modal, and multi-printer mapping. Unknown remain values (e.g. external spools without sensors) are treated as full. Disabled by default. Requested by @Mofoss.
 - **REST/Webhook Smart Plug Type** ([#472](https://github.com/maziggy/bambuddy/issues/472)) — New "REST" smart plug type for controlling power via generic HTTP APIs. Works with any home automation platform that has an HTTP endpoint (openHAB, ioBroker, FHEM, Node-RED, etc.). Configure separate ON/OFF URLs with custom HTTP methods (GET/POST/PUT/PATCH), request bodies, and headers. Optional status polling via a GET endpoint with JSON path extraction for state, power, and energy monitoring. Fully controllable — supports auto on/off with prints, daily scheduling, sidebar quick-toggle, and power alerts. Requested by @Percy2Live.
 - **Configurable Default Print Options** ([#858](https://github.com/maziggy/bambuddy/issues/858)) — Print options (bed levelling, flow calibration, vibration calibration, first layer inspection, timelapse) now have configurable defaults in Settings → Workflow. Set your preferred defaults once and every new print dialog starts with those values. Still overridable per print. Requested by @NoahTingey.
+- **Batch Print Quantity** ([#342](https://github.com/maziggy/bambuddy/issues/342)) — Print multiple copies of a file in one step. The print and schedule dialogs now have a quantity field — set it to any number and the system creates that many queue items automatically. When quantity is greater than one, items are grouped into a batch for tracking. In the direct print dialog, the first copy prints immediately while the remaining copies are queued. The queue page shows a batch badge on grouped items. Batch progress and cancellation are available via the API. Requested by @cimdDev.
 
 ### Improved
 - **Queue Page Visual Refresh** — Compact stats bar replaces the five summary cards (saves vertical space), color-coded left borders on all queue items for instant status scanning, collapsible history section (collapsed by default), and condensed single-line rows for history items showing more prints at a glance.

+ 1 - 0
README.md

@@ -108,6 +108,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - **Background print dispatch** — FTP uploads and print-start commands run in the background with real-time WebSocket progress toasts (per-job upload bars, status badges, cancel button)
 - Print queue with drag-and-drop and timeline schedule view
 - Multi-printer selection (send to multiple printers at once)
+- Batch print quantity (print multiple copies — set quantity in the print/schedule dialog, first copy prints immediately, rest are queued)
 - Staggered batch start (start printers in groups with configurable interval to avoid power spikes — works in both Print and Queue dialogs)
 - Configurable default print options (bed levelling, flow/vibration calibration, first layer inspection, timelapse) in Settings → Workflow
 - Model-based queue assignment (send to "any X1C" for load balancing) with location filtering

+ 183 - 32
backend/app/api/routes/print_queue.py

@@ -18,10 +18,12 @@ 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_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.user import User
 from backend.app.schemas.print_queue import (
+    PrintBatchResponse,
     PrintQueueBulkUpdate,
     PrintQueueBulkUpdateResponse,
     PrintQueueItemCreate,
@@ -209,6 +211,9 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         # User tracking (Issue #206)
         "created_by_id": item.created_by_id,
         "created_by_username": item.created_by.username if item.created_by else None,
+        # Batch grouping
+        "batch_id": item.batch_id,
+        "batch_name": item.batch.name if item.batch else None,
     }
     response = PrintQueueItemResponse(**item_dict)
     if item.archive:
@@ -281,6 +286,7 @@ async def list_queue(
             selectinload(PrintQueueItem.printer),
             selectinload(PrintQueueItem.library_file),
             selectinload(PrintQueueItem.created_by),
+            selectinload(PrintQueueItem.batch),
         )
         .order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)
     )
@@ -407,6 +413,36 @@ async def add_to_queue(
             all_types = existing_types | set(override_types)
             required_filament_types = json.dumps(sorted(all_types))
 
+    # Validate quantity
+    quantity = max(1, data.quantity)
+
+    # Create batch if quantity > 1
+    batch = None
+    batch_id = None
+    if quantity > 1:
+        # Derive batch name from source file
+        batch_name_base = "Batch"
+        if archive:
+            batch_name_base = archive.print_name or archive.filename or "Batch"
+        elif library_file:
+            if library_file.file_metadata:
+                batch_name_base = library_file.file_metadata.get("print_name") or library_file.filename
+            else:
+                batch_name_base = library_file.filename
+        batch_name_base = batch_name_base.replace(".gcode.3mf", "").replace(".3mf", "")
+
+        batch = PrintBatch(
+            name=f"{batch_name_base} ×{quantity}",
+            archive_id=data.archive_id,
+            library_file_id=data.library_file_id,
+            quantity=quantity,
+            status="active",
+            created_by_id=current_user.id if current_user else None,
+        )
+        db.add(batch)
+        await db.flush()  # Get batch.id before creating items
+        batch_id = batch.id
+
     # Get next position for this printer (or for unassigned/model-based items)
     if data.printer_id is not None:
         result = await db.execute(
@@ -423,40 +459,48 @@ async def add_to_queue(
         )
     max_pos = result.scalar() or 0
 
-    item = PrintQueueItem(
-        printer_id=data.printer_id,
-        target_model=target_model_norm,
-        target_location=data.target_location,
-        required_filament_types=required_filament_types,
-        filament_overrides=filament_overrides_json,
-        archive_id=data.archive_id,
-        library_file_id=data.library_file_id,
-        scheduled_time=data.scheduled_time,
-        require_previous_success=data.require_previous_success,
-        auto_off_after=data.auto_off_after,
-        manual_start=data.manual_start,
-        ams_mapping=json.dumps(data.ams_mapping) if data.ams_mapping else None,
-        plate_id=data.plate_id,
-        bed_levelling=data.bed_levelling,
-        flow_cali=data.flow_cali,
-        vibration_cali=data.vibration_cali,
-        layer_inspect=data.layer_inspect,
-        timelapse=data.timelapse,
-        use_ams=data.use_ams,
-        position=max_pos + 1,
-        status="pending",
-        created_by_id=current_user.id if current_user else None,
-    )
-    db.add(item)
+    ams_mapping_json = json.dumps(data.ams_mapping) if data.ams_mapping else None
+    items = []
+    for i in range(quantity):
+        item = PrintQueueItem(
+            printer_id=data.printer_id,
+            target_model=target_model_norm,
+            target_location=data.target_location,
+            required_filament_types=required_filament_types,
+            filament_overrides=filament_overrides_json,
+            archive_id=data.archive_id,
+            library_file_id=data.library_file_id,
+            scheduled_time=data.scheduled_time,
+            require_previous_success=data.require_previous_success,
+            auto_off_after=data.auto_off_after,
+            manual_start=data.manual_start,
+            ams_mapping=ams_mapping_json,
+            plate_id=data.plate_id,
+            bed_levelling=data.bed_levelling,
+            flow_cali=data.flow_cali,
+            vibration_cali=data.vibration_cali,
+            layer_inspect=data.layer_inspect,
+            timelapse=data.timelapse,
+            use_ams=data.use_ams,
+            position=max_pos + 1 + i,
+            status="pending",
+            created_by_id=current_user.id if current_user else None,
+            batch_id=batch_id,
+        )
+        db.add(item)
+        items.append(item)
+
     await db.commit()
-    await db.refresh(item)
 
-    # Load relationships for response
-    await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
+    # Refresh the first item for the response
+    item = items[0]
+    await db.refresh(item)
+    await db.refresh(item, ["archive", "printer", "library_file", "created_by", "batch"])
 
     source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}"
     target_desc = data.printer_id or (f"model {target_model_norm}" if target_model_norm else "unassigned")
-    logger.info("Added %s to queue for %s", source_name, target_desc)
+    qty_desc = f" (×{quantity})" if quantity > 1 else ""
+    logger.info("Added %s to queue for %s%s", source_name, target_desc, qty_desc)
 
     # MQTT relay - publish queue job added
     try:
@@ -481,6 +525,8 @@ async def add_to_queue(
             else f"Job #{item.id}"
         )
         job_name = job_name.replace(".gcode.3mf", "").replace(".3mf", "")
+        if quantity > 1:
+            job_name = f"{job_name} ×{quantity}"
         target = (
             item.printer.name if item.printer else (f"Any {item.target_model}" if target_model_norm else "Unassigned")
         )
@@ -561,6 +607,106 @@ async def bulk_update_queue_items(
     )
 
 
+# --- Batch endpoints ---
+
+
+@router.get("/batches", response_model=list[PrintBatchResponse])
+async def list_batches(
+    status: str | None = Query(None, description="Filter by status (active, completed, cancelled)"),
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
+):
+    """List all print batches with progress stats."""
+    query = select(PrintBatch).order_by(PrintBatch.created_at.desc())
+    if status:
+        query = query.where(PrintBatch.status == status)
+    result = await db.execute(query)
+    batches = result.scalars().all()
+
+    responses = []
+    for batch in batches:
+        responses.append(await _build_batch_response(db, batch))
+    return responses
+
+
+@router.get("/batches/{batch_id}", response_model=PrintBatchResponse)
+async def get_batch(
+    batch_id: int,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
+):
+    """Get a print batch with progress stats."""
+    result = await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))
+    batch = result.scalar_one_or_none()
+    if not batch:
+        raise HTTPException(404, "Batch not found")
+    return await _build_batch_response(db, batch)
+
+
+@router.delete("/batches/{batch_id}")
+async def cancel_batch(
+    batch_id: int,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
+):
+    """Cancel all pending items in a batch and mark batch as cancelled."""
+    result = await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))
+    batch = result.scalar_one_or_none()
+    if not batch:
+        raise HTTPException(404, "Batch not found")
+
+    # Cancel all pending queue items in this batch
+    result = await db.execute(
+        select(PrintQueueItem).where(and_(PrintQueueItem.batch_id == batch_id, PrintQueueItem.status == "pending"))
+    )
+    pending_items = result.scalars().all()
+    cancelled_count = 0
+    for item in pending_items:
+        item.status = "cancelled"
+        cancelled_count += 1
+
+    batch.status = "cancelled"
+    await db.commit()
+
+    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()}
+
+    # 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
+
+    return PrintBatchResponse(
+        id=batch.id,
+        name=batch.name,
+        archive_id=batch.archive_id,
+        library_file_id=batch.library_file_id,
+        quantity=batch.quantity,
+        status=batch.status,
+        created_at=batch.created_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),
+    )
+
+
 @router.get("/{item_id}", response_model=PrintQueueItemResponse)
 async def get_queue_item(
     item_id: int,
@@ -575,6 +721,7 @@ async def get_queue_item(
             selectinload(PrintQueueItem.printer),
             selectinload(PrintQueueItem.library_file),
             selectinload(PrintQueueItem.created_by),
+            selectinload(PrintQueueItem.batch),
         )
         .where(PrintQueueItem.id == item_id)
     )
@@ -656,7 +803,7 @@ async def update_queue_item(
         setattr(item, field, value)
 
     await db.commit()
-    await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
+    await db.refresh(item, ["archive", "printer", "library_file", "created_by", "batch"])
 
     logger.info("Updated queue item %s", item_id)
     return _enrich_response(item)
@@ -844,7 +991,11 @@ async def start_queue_item(
     """
     result = await db.execute(
         select(PrintQueueItem)
-        .options(selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer))
+        .options(
+            selectinload(PrintQueueItem.archive),
+            selectinload(PrintQueueItem.printer),
+            selectinload(PrintQueueItem.batch),
+        )
         .where(PrintQueueItem.id == item_id)
     )
     item = result.scalar_one_or_none()
@@ -857,7 +1008,7 @@ async def start_queue_item(
     # Clear manual_start flag so scheduler picks it up
     item.manual_start = False
     await db.commit()
-    await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
+    await db.refresh(item, ["archive", "printer", "library_file", "created_by", "batch"])
 
     logger.info("Manually started queue item %s (cleared manual_start flag)", item_id)
     return _enrich_response(item)

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

@@ -95,6 +95,7 @@ async def init_db():
         notification_template,
         orca_base_cache,
         pending_upload,
+        print_batch,
         print_log,
         print_queue,
         printer,
@@ -1609,6 +1610,14 @@ async def run_migrations(conn):
     except OperationalError:
         pass  # Already applied
 
+    # Migration: Add batch_id column to print_queue for batch grouping
+    try:
+        await conn.execute(
+            text("ALTER TABLE print_queue ADD COLUMN batch_id INTEGER REFERENCES print_batches(id) ON DELETE SET NULL")
+        )
+    except OperationalError:
+        pass
+
     # Seed default settings keys that must exist on fresh install
     default_settings = [
         ("advanced_auth_enabled", "false"),

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

@@ -14,6 +14,7 @@ from backend.app.models.notification import NotificationLog
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.orca_base_cache import OrcaBaseProfile
 from backend.app.models.pending_upload import PendingUpload
+from backend.app.models.print_batch import PrintBatch
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
@@ -44,6 +45,7 @@ __all__ = [
     "AMSSensorHistory",
     "AmsLabel",
     "PendingUpload",
+    "PrintBatch",
     "LibraryFolder",
     "LibraryFile",
     "User",

+ 45 - 0
backend/app/models/print_batch.py

@@ -0,0 +1,45 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Integer, String, 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."""
+
+    __tablename__ = "print_batches"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(255))
+
+    # Source file (one of these)
+    archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True)
+    library_file_id: Mapped[int | None] = mapped_column(
+        ForeignKey("library_files.id", ondelete="SET NULL"), nullable=True
+    )
+
+    # Total requested quantity (for display — actual items may differ if cancelled)
+    quantity: Mapped[int] = mapped_column(Integer, default=1)
+
+    # Status: active, completed, cancelled
+    status: Mapped[str] = mapped_column(String(20), default="active")
+
+    # Timestamps
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    # User tracking
+    created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+
+    # Relationships
+    archive: Mapped["PrintArchive | None"] = relationship()
+    library_file: Mapped["LibraryFile | None"] = relationship()
+    created_by: Mapped["User | None"] = relationship()
+    queue_items: Mapped[list["PrintQueueItem"]] = relationship(back_populates="batch")
+
+
+from backend.app.models.archive import PrintArchive  # noqa: E402
+from backend.app.models.library import LibraryFile  # noqa: E402
+from backend.app.models.print_queue import PrintQueueItem  # noqa: E402
+from backend.app.models.user import User  # noqa: E402

+ 3 - 0
backend/app/models/print_queue.py

@@ -33,6 +33,7 @@ class PrintQueueItem(Base):
         ForeignKey("library_files.id", ondelete="CASCADE"), nullable=True
     )
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
+    batch_id: Mapped[int | None] = mapped_column(ForeignKey("print_batches.id", ondelete="SET NULL"), nullable=True)
 
     # Scheduling
     position: Mapped[int] = mapped_column(Integer, default=0)  # Queue order
@@ -84,11 +85,13 @@ class PrintQueueItem(Base):
     archive: Mapped["PrintArchive | None"] = relationship()
     library_file: Mapped["LibraryFile | None"] = relationship()
     project: Mapped["Project | None"] = relationship(back_populates="queue_items")
+    batch: Mapped["PrintBatch | None"] = relationship(back_populates="queue_items")
     created_by: Mapped["User | None"] = relationship()
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
 from backend.app.models.library import LibraryFile  # noqa: E402
+from backend.app.models.print_batch import PrintBatch  # noqa: E402
 from backend.app.models.printer import Printer  # noqa: E402
 from backend.app.models.project import Project  # noqa: E402
 from backend.app.models.user import User  # noqa: E402

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

@@ -40,6 +40,8 @@ class PrintQueueItemCreate(BaseModel):
     layer_inspect: bool = False
     timelapse: bool = False
     use_ams: bool = True
+    # Batch: create multiple copies (creates a batch if > 1)
+    quantity: int = 1
 
 
 class PrintQueueItemUpdate(BaseModel):
@@ -111,6 +113,10 @@ class PrintQueueItemResponse(BaseModel):
     created_by_id: int | None = None
     created_by_username: str | None = None
 
+    # Batch grouping
+    batch_id: int | None = None
+    batch_name: str | None = None
+
     class Config:
         from_attributes = True
 
@@ -149,3 +155,26 @@ class PrintQueueBulkUpdateResponse(BaseModel):
     updated_count: int
     skipped_count: int  # Items that were not pending
     message: str
+
+
+class PrintBatchResponse(BaseModel):
+    """Response for a print batch with progress stats."""
+
+    id: int
+    name: str
+    archive_id: int | None = None
+    library_file_id: int | None = None
+    quantity: int
+    status: str
+    created_at: UTCDatetime
+    created_by_id: int | None = None
+    created_by_username: 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
+
+    class Config:
+        from_attributes = True

+ 212 - 0
backend/tests/integration/test_print_queue_api.py

@@ -1423,3 +1423,215 @@ class TestAbortedStatusNormalisation:
                     pass
 
         assert item.status == "completed"
+
+    # ========================================================================
+    # Batch quantity tests
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_quantity_default(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify quantity=1 (default) creates a single item with no batch."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["batch_id"] is None
+        assert result["batch_name"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_quantity_one_explicit(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify quantity=1 explicitly creates a single item with no batch."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "quantity": 1,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["batch_id"] is None
+        assert result["batch_name"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_quantity_creates_batch(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify quantity > 1 creates a batch and multiple queue items."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "quantity": 3,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        # First item is returned, linked to a batch
+        assert result["batch_id"] is not None
+        assert result["batch_name"] is not None
+        assert "×3" in result["batch_name"]
+
+        # Verify all 3 items were created
+        list_response = await async_client.get("/api/v1/queue/")
+        items = list_response.json()
+        batch_items = [i for i in items if i["batch_id"] == result["batch_id"]]
+        assert len(batch_items) == 3
+        # All items should have the same settings
+        for item in batch_items:
+            assert item["printer_id"] == printer.id
+            assert item["archive_id"] == archive.id
+            assert item["status"] == "pending"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_quantity_sequential_positions(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify batch items get sequential positions."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "quantity": 3,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        batch_id = response.json()["batch_id"]
+
+        list_response = await async_client.get("/api/v1/queue/")
+        items = list_response.json()
+        batch_items = sorted(
+            [i for i in items if i["batch_id"] == batch_id],
+            key=lambda i: i["position"],
+        )
+        positions = [i["position"] for i in batch_items]
+        assert positions == [positions[0], positions[0] + 1, positions[0] + 2]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_quantity_with_print_options(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify print options are applied to all batch items."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "quantity": 2,
+            "bed_levelling": False,
+            "timelapse": True,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        batch_id = response.json()["batch_id"]
+
+        list_response = await async_client.get("/api/v1/queue/")
+        batch_items = [i for i in list_response.json() if i["batch_id"] == batch_id]
+        assert len(batch_items) == 2
+        for item in batch_items:
+            assert item["bed_levelling"] is False
+            assert item["timelapse"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_batch(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):
+        """Verify batch can be retrieved with progress stats."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        # Create a batch of 3
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "quantity": 3,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        batch_id = response.json()["batch_id"]
+
+        # Get batch
+        response = await async_client.get(f"/api/v1/queue/batches/{batch_id}")
+        assert response.status_code == 200
+        result = response.json()
+        assert result["id"] == batch_id
+        assert result["quantity"] == 3
+        assert result["status"] == "active"
+        assert result["pending_count"] == 3
+        assert result["printing_count"] == 0
+        assert result["completed_count"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_batches(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):
+        """Verify batches can be listed."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        # Create two batches
+        for qty in [2, 3]:
+            await async_client.post(
+                "/api/v1/queue/",
+                json={"printer_id": printer.id, "archive_id": archive.id, "quantity": qty},
+            )
+
+        response = await async_client.get("/api/v1/queue/batches")
+        assert response.status_code == 200
+        batches = response.json()
+        assert len(batches) >= 2
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_batch(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):
+        """Verify cancelling a batch cancels all pending items."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "quantity": 3,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        batch_id = response.json()["batch_id"]
+
+        # Cancel the batch
+        response = await async_client.delete(f"/api/v1/queue/batches/{batch_id}")
+        assert response.status_code == 200
+
+        # Verify all items are cancelled
+        list_response = await async_client.get("/api/v1/queue/")
+        batch_items = [i for i in list_response.json() if i["batch_id"] == batch_id]
+        for item in batch_items:
+            assert item["status"] == "cancelled"
+
+        # Verify batch status
+        batch_response = await async_client.get(f"/api/v1/queue/batches/{batch_id}")
+        assert batch_response.json()["status"] == "cancelled"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_batch_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent batch."""
+        response = await async_client.get("/api/v1/queue/batches/9999")
+        assert response.status_code == 404

+ 83 - 0
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -48,6 +48,8 @@ const createMockQueueItem = (overrides: Partial<PrintQueueItem> = {}): PrintQueu
   archive_thumbnail: null,
   printer_name: 'Test Printer',
   print_time_seconds: 3600,
+  batch_id: null,
+  batch_name: null,
   ...overrides,
 });
 
@@ -1057,4 +1059,85 @@ describe('PrintModal', () => {
       expect((queueRequests[2] as { plate_id: number }).plate_id).toBe(3);
     });
   });
+
+  describe('batch quantity', () => {
+    it('shows quantity input in reprint mode', () => {
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      expect(screen.getByLabelText('Quantity')).toBeInTheDocument();
+    });
+
+    it('shows quantity input in add-to-queue mode', () => {
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      expect(screen.getByLabelText('Quantity')).toBeInTheDocument();
+    });
+
+    it('does not show quantity input in edit-queue-item mode', () => {
+      render(
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={1}
+          archiveName="Benchy"
+          queueItem={createMockQueueItem()}
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      expect(screen.queryByLabelText('Quantity')).not.toBeInTheDocument();
+    });
+
+    it('defaults quantity to 1', () => {
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      const input = screen.getByLabelText('Quantity') as HTMLInputElement;
+      expect(input.value).toBe('1');
+    });
+
+    it('quantity input has default value of 1 and accepts changes', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          initialSelectedPrinterIds={[1]}
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      const input = screen.getByLabelText('Quantity') as HTMLInputElement;
+      expect(input.value).toBe('1');
+
+      await user.tripleClick(input);
+      await user.keyboard('5');
+      expect(input.value).toBe('5');
+    });
+  });
 });

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

@@ -1365,6 +1365,26 @@ export interface PrintQueueItem {
   // User tracking (Issue #206)
   created_by_id?: number | null;
   created_by_username?: string | null;
+  // Batch grouping
+  batch_id?: number | null;
+  batch_name?: string | null;
+}
+
+export interface PrintBatch {
+  id: number;
+  name: string;
+  archive_id: number | null;
+  library_file_id: number | null;
+  quantity: number;
+  status: string;
+  created_at: string;
+  created_by_id: number | null;
+  created_by_username: string | null;
+  pending_count: number;
+  printing_count: number;
+  completed_count: number;
+  failed_count: number;
+  cancelled_count: number;
 }
 
 export interface PrintQueueItemCreate {
@@ -1387,6 +1407,8 @@ export interface PrintQueueItemCreate {
   layer_inspect?: boolean;
   timelapse?: boolean;
   use_ams?: boolean;
+  // Batch: create multiple copies (creates a batch if > 1)
+  quantity?: number;
 }
 
 export interface PrintQueueItemUpdate {
@@ -3448,6 +3470,14 @@ export const api = {
       method: 'PATCH',
       body: JSON.stringify(data),
     }),
+  // Batches
+  getBatches: (status?: string) => {
+    const params = status ? `?status=${status}` : '';
+    return request<PrintBatch[]>(`/queue/batches${params}`);
+  },
+  getBatch: (id: number) => request<PrintBatch>(`/queue/batches/${id}`),
+  cancelBatch: (id: number) =>
+    request<{ message: string }>(`/queue/batches/${id}`, { method: 'DELETE' }),
 
   // K-Profiles
   getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>

+ 49 - 4
frontend/src/components/PrintModal/index.tsx

@@ -87,6 +87,9 @@ export function PrintModal({
   // Derived single-plate value for filament queries and single-select contexts
   const selectedPlate = selectedPlates.size === 1 ? [...selectedPlates][0] : null;
 
+  // Quantity — number of copies (creates a batch if > 1)
+  const [quantity, setQuantity] = useState(1);
+
   const [printOptions, setPrintOptions] = useState<PrintOptions>(() => {
     if (mode === 'edit-queue-item' && queueItem) {
       return {
@@ -686,7 +689,9 @@ export function PrintModal({
             await updateQueueMutation.mutateAsync(updateData);
           } else {
             // Add-to-queue mode with model-based assignment
-            await addToQueueMutation.mutateAsync(getQueueData(null, plateId));
+            const queueData = getQueueData(null, plateId);
+            if (effectiveQuantity > 1) queueData.quantity = effectiveQuantity;
+            await addToQueueMutation.mutateAsync(queueData);
           }
           results.success++;
         } catch (error) {
@@ -735,6 +740,12 @@ export function PrintModal({
                   ...printOptions,
                 });
               }
+              // Queue remaining copies if quantity > 1
+              if (effectiveQuantity > 1) {
+                const queueData = getQueueData(printerId, plateId);
+                queueData.quantity = effectiveQuantity - 1;
+                await addToQueueMutation.mutateAsync(queueData);
+              }
             } else if (mode === 'edit-queue-item' && progressCounter === 1) {
               // Edit mode - update the original queue item for the first entry
               const printerMapping = getMappingForPrinter(printerId);
@@ -756,6 +767,7 @@ export function PrintModal({
             } else {
               // Add-to-queue mode, stagger-reprint mode, or edit mode with additional entries
               const queueData = getQueueData(printerId, plateId);
+              if (effectiveQuantity > 1) queueData.quantity = effectiveQuantity;
               // Apply stagger offset for groups after the first
               if (useStagger) {
                 const groupIndex = Math.floor(i / scheduleOptions.staggerGroupSize);
@@ -825,18 +837,25 @@ export function PrintModal({
     return true;
   }, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlates.size, isPending]);
 
+  // Quantity only applies for single-printer or model-based assignment (not multi-printer)
+  const effectiveQuantity = (assignmentMode === 'printer' && selectedPrinters.length > 1) ? 1 : quantity;
+
   // Modal title and action button text based on mode
   const getModalConfig = () => {
     const printerCount = selectedPrinters.length;
 
     if (mode === 'reprint') {
       const staggerReprint = willUseStagger && printerCount > 1;
+      let submitText = staggerReprint
+        ? t('printModal.staggerToPrinters', { count: printerCount, defaultValue: 'Stagger to {{count}} printers' })
+        : printerCount > 1 ? t('queue.printToPrinters', { count: printerCount }) : t('queue.print');
+      if (effectiveQuantity > 1) {
+        submitText = `${submitText} ×${effectiveQuantity}`;
+      }
       return {
         title: isLibraryFile ? t('queue.print') : t('queue.reprint'),
         icon: Printer,
-        submitText: staggerReprint
-          ? t('printModal.staggerToPrinters', { count: printerCount, defaultValue: 'Stagger to {{count}} printers' })
-          : printerCount > 1 ? t('queue.printToPrinters', { count: printerCount }) : t('queue.print'),
+        submitText,
         submitIcon: staggerReprint ? Calendar : Printer,
         loadingText: submitProgress.total > 1
           ? t('queue.sendingProgress', { current: submitProgress.current, total: submitProgress.total })
@@ -850,6 +869,9 @@ export function PrintModal({
       } else if (printerCount > 1) {
         submitText = t('queue.queueToPrinters', { count: printerCount });
       }
+      if (effectiveQuantity > 1) {
+        submitText = `${submitText} ×${effectiveQuantity}`;
+      }
       return {
         title: t('queue.schedulePrint'),
         icon: Calendar,
@@ -1037,6 +1059,29 @@ export function PrintModal({
               <PrintOptionsPanel options={printOptions} onChange={setPrintOptions} defaultExpanded={!!initialSelectedPrinterIds?.length} />
             )}
 
+            {/* Quantity — create multiple copies (batch). Hidden for multi-printer selection. */}
+            {mode !== 'edit-queue-item' && (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')}
+                </label>
+                <input
+                  id="printQuantity"
+                  type="number"
+                  min={1}
+                  max={999}
+                  value={quantity}
+                  onChange={(e) => setQuantity(Math.max(1, Math.min(999, parseInt(e.target.value) || 1)))}
+                  className="w-20 px-2 py-1 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white focus:outline-none focus:ring-1 focus:ring-bambu-green"
+                />
+                {quantity > 1 && (
+                  <span className="text-xs text-bambu-gray">
+                    {t('queue.quantityHint', 'Creates {{count}} queue items', { count: quantity })}
+                  </span>
+                )}
+              </div>
+            )}
+
             {/* Stagger option for reprint mode with multiple printers */}
             {mode === 'reprint' && assignmentMode === 'printer' && selectedPrinters.length > 1 && (
               <div className="space-y-2 pb-2">

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

@@ -856,6 +856,16 @@ export default {
     clearPlateSuccess: 'Druckplatte freigegeben — bereit für nächsten Druck',
     plateReady: 'Druckplatte freigegeben — bereit für nächsten Druck',
     plateNumber: 'Platte {{index}}',
+    // Batch / quantity
+    quantity: 'Menge',
+    quantityHint: 'Erstellt {{count}} Warteschlangeneinträge',
+    activeBatches: 'Aktive Stapel',
+    batchProgress: '{{completed}} von {{total}} abgeschlossen',
+    cancelBatch: 'Verbleibende abbrechen',
+    batchCancelled: 'Verbleibende Stapeleinträge abgebrochen',
+    cancelBatchConfirmTitle: 'Stapel abbrechen',
+    cancelBatchConfirmMessage: 'Alle verbleibenden ausstehenden Einträge in diesem Stapel abbrechen?',
+    batch: 'Stapel',
     // Sections
     sections: {
       currentlyPrinting: 'Aktuell druckend',

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

@@ -856,6 +856,16 @@ export default {
     clearPlateSuccess: 'Plate cleared — ready for next print',
     plateReady: 'Plate cleared — ready for next print',
     plateNumber: 'Plate {{index}}',
+    // Batch / quantity
+    quantity: 'Quantity',
+    quantityHint: 'Creates {{count}} queue items',
+    activeBatches: 'Active Batches',
+    batchProgress: '{{completed}} of {{total}} completed',
+    cancelBatch: 'Cancel Remaining',
+    batchCancelled: 'Remaining batch items cancelled',
+    cancelBatchConfirmTitle: 'Cancel Batch',
+    cancelBatchConfirmMessage: 'Cancel all remaining pending items in this batch?',
+    batch: 'Batch',
     // Sections
     sections: {
       currentlyPrinting: 'Currently Printing',

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

@@ -856,6 +856,16 @@ export default {
     clearPlateSuccess: 'Plateau vidé — prêt pour l\'impression suivante',
     plateReady: 'Plateau vidé — prêt pour l\'impression suivante',
     plateNumber: 'Plateau {{index}}',
+    // Batch / quantity
+    quantity: 'Quantité',
+    quantityHint: 'Crée {{count}} éléments de file d\'attente',
+    activeBatches: 'Lots actifs',
+    batchProgress: '{{completed}} sur {{total}} terminés',
+    cancelBatch: 'Annuler les restants',
+    batchCancelled: 'Éléments restants du lot annulés',
+    cancelBatchConfirmTitle: 'Annuler le lot',
+    cancelBatchConfirmMessage: 'Annuler tous les éléments en attente restants dans ce lot ?',
+    batch: 'Lot',
     // Sections
     sections: {
       currentlyPrinting: 'En cours',

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

@@ -856,6 +856,16 @@ export default {
     clearPlateSuccess: 'Piatto liberato — pronto per la prossima stampa',
     plateReady: 'Piatto liberato — pronto per la prossima stampa',
     plateNumber: 'Piatto {{index}}',
+    // Batch / quantity
+    quantity: 'Quantità',
+    quantityHint: 'Crea {{count}} elementi in coda',
+    activeBatches: 'Lotti attivi',
+    batchProgress: '{{completed}} di {{total}} completati',
+    cancelBatch: 'Annulla rimanenti',
+    batchCancelled: 'Elementi rimanenti del lotto annullati',
+    cancelBatchConfirmTitle: 'Annulla lotto',
+    cancelBatchConfirmMessage: 'Annullare tutti gli elementi in sospeso rimanenti in questo lotto?',
+    batch: 'Lotto',
     // Sections
     sections: {
       currentlyPrinting: 'In stampa',

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

@@ -855,6 +855,16 @@ export default {
     clearPlateSuccess: 'プレートをクリアしました — 次の印刷の準備完了',
     plateReady: 'プレートをクリアしました — 次の印刷の準備完了',
     plateNumber: 'プレート {{index}}',
+    // Batch / quantity
+    quantity: '数量',
+    quantityHint: '{{count}}件のキューアイテムを作成',
+    activeBatches: 'アクティブなバッチ',
+    batchProgress: '{{total}}件中{{completed}}件完了',
+    cancelBatch: '残りをキャンセル',
+    batchCancelled: '残りのバッチアイテムをキャンセルしました',
+    cancelBatchConfirmTitle: 'バッチをキャンセル',
+    cancelBatchConfirmMessage: 'このバッチの残りの保留中アイテムをすべてキャンセルしますか?',
+    batch: 'バッチ',
     // Sections
     sections: {
       currentlyPrinting: '印刷中',

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

@@ -856,6 +856,16 @@ export default {
     clearPlateSuccess: 'Placa limpa — pronta para a próxima impressão',
     plateReady: 'Placa limpa — pronta para a próxima impressão',
     plateNumber: 'Placa {{index}}',
+    // Batch / quantity
+    quantity: 'Quantidade',
+    quantityHint: 'Cria {{count}} itens na fila',
+    activeBatches: 'Lotes ativos',
+    batchProgress: '{{completed}} de {{total}} concluídos',
+    cancelBatch: 'Cancelar restantes',
+    batchCancelled: 'Itens restantes do lote cancelados',
+    cancelBatchConfirmTitle: 'Cancelar lote',
+    cancelBatchConfirmMessage: 'Cancelar todos os itens pendentes restantes neste lote?',
+    batch: 'Lote',
     // Sections
     sections: {
       currentlyPrinting: 'Imprimindo Atualmente',

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

@@ -856,6 +856,16 @@ export default {
     clearPlateSuccess: '打印板已清理 — 准备进行下一个打印',
     plateReady: '打印板已清理 — 准备进行下一个打印',
     plateNumber: '板 {{index}}',
+    // Batch / quantity
+    quantity: '数量',
+    quantityHint: '创建 {{count}} 个队列项目',
+    activeBatches: '活跃批次',
+    batchProgress: '已完成 {{completed}}/{{total}}',
+    cancelBatch: '取消剩余',
+    batchCancelled: '已取消剩余批次项目',
+    cancelBatchConfirmTitle: '取消批次',
+    cancelBatchConfirmMessage: '取消此批次中所有剩余的待处理项目?',
+    batch: '批次',
     // Sections
     sections: {
       currentlyPrinting: '正在打印',

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

@@ -480,6 +480,11 @@ function SortableQueueItem({
                 <ExternalLink className="w-3.5 h-3.5" />
               </Link>
             ) : null}
+            {item.batch_name && (
+              <span className="flex-shrink-0 px-1.5 py-0.5 text-[10px] sm:text-xs bg-purple-500/20 text-purple-300 rounded border border-purple-500/30">
+                {item.batch_name}
+              </span>
+            )}
           </div>
 
           <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs sm:text-sm text-bambu-gray">

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-DKoL3gto.js


+ 1 - 1
static/index.html

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

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff