bambuddy/backend/app/models/print_batch.py
maziggy 71a06f3638 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.
2026-08-04 11:11:36 +02:00

101 lines
4.5 KiB
Python

from datetime import datetime
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.
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"
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")
# 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)
# 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")
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
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