print_batch.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642
  1. """Batch order planning: per-plate targets, progress, and staged dispatch (#342).
  2. A batch stores *intent* in :class:`PrintBatchPlate` rows — "this order wants 3
  3. of plate 2" — while its queue items record what was actually dispatched.
  4. Everything here derives one from the other.
  5. The distinction matters for exactly one reason, and it is the reason the
  6. feature exists: a failed or cancelled run does not count towards the target, so
  7. ``remaining`` goes back up and the order still says it owes a print. A design
  8. that only counted the items it created could not tell "the user cancelled this
  9. deliberately" apart from "this one burned and needs reprinting".
  10. Batches created before targets existed have no plate rows. They still report
  11. progress — the plate breakdown is derived from their queue items and every
  12. target simply equals the number of items dispatched, so ``remaining`` is zero
  13. and the dispatch endpoint has nothing to do. ``has_targets`` tells callers
  14. which kind of batch they are looking at.
  15. """
  16. import logging
  17. from dataclasses import dataclass, field
  18. from datetime import datetime, timezone
  19. from sqlalchemy import func, select
  20. from sqlalchemy.ext.asyncio import AsyncSession
  21. from sqlalchemy.orm import selectinload
  22. from backend.app.models.print_batch import PrintBatch, PrintBatchPlate
  23. from backend.app.models.print_log import PrintLogEntry
  24. from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
  25. from backend.app.services.queue_item_printer import drop_printer_resolved
  26. from backend.app.services.queue_position import next_queue_position
  27. logger = logging.getLogger(__name__)
  28. # Statuses that consume a unit of the target. "printing" counts because the
  29. # run is in flight — re-dispatching it would double-print. "failed",
  30. # "cancelled" and "skipped" deliberately do not.
  31. CONSUMING_STATUSES = ("pending", "printing", "completed")
  32. # Queue statuses the roll-up has a counter for. Anything else is ignored rather
  33. # than crashing the page — the queue's status vocabulary is allowed to grow
  34. # without this module having to be updated in lockstep.
  35. COUNTED_STATUSES = ("pending", "printing", "completed", "failed", "cancelled", "skipped")
  36. # Columns copied onto a clone when dispatching more of a plate. This is the
  37. # print *configuration* the user already chose and the API already validated —
  38. # copying the row is what keeps a second dispatch identical to the first
  39. # without re-serialising twenty fields through a template blob that would drift
  40. # from the model the first time someone adds a column.
  41. CLONED_SETTING_COLUMNS = (
  42. "printer_id",
  43. "target_model",
  44. "target_location",
  45. "required_filament_types",
  46. "archive_id",
  47. "library_file_id",
  48. "project_id",
  49. "batch_id",
  50. "ams_mapping",
  51. # A virtual printer job's slicer pick for its archive (#2700): each copy's
  52. # print makes or joins an archive too.
  53. "slicer_ams_mapping",
  54. "filament_overrides",
  55. "plate_id",
  56. "print_time_seconds",
  57. "gcode_injection",
  58. "nozzle_mapping",
  59. "nozzle_rack_choice",
  60. "require_previous_success",
  61. "auto_off_after",
  62. "manual_start",
  63. "bed_levelling",
  64. "flow_cali",
  65. "vibration_cali",
  66. "layer_inspect",
  67. "timelapse",
  68. "use_ams",
  69. "nozzle_offset_cali",
  70. "confirm_outcome",
  71. "preheat_override",
  72. "preheat_chamber_target_override",
  73. "skip_filament_check",
  74. # The scheduler bills and budget-checks from it; dispatch_batch checks the
  75. # dispatcher may use it.
  76. "cost_center_id",
  77. )
  78. CLONED_VARIANT_COLUMNS = (
  79. "position",
  80. "library_file_id",
  81. "target_model",
  82. "plate_id",
  83. "ams_mapping",
  84. "nozzle_mapping",
  85. "nozzle_rack_choice",
  86. "filament_overrides",
  87. "required_filament_types",
  88. "print_time_seconds",
  89. )
  90. class BatchDispatchError(Exception):
  91. """Raised when more runs are owed but nothing can be cloned to produce them."""
  92. @dataclass
  93. class PlateProgress:
  94. """Per-plate roll-up for one batch."""
  95. plate_id: int | None
  96. plate_name: str | None
  97. quantity_target: int
  98. sort_order: int = 0
  99. pending: int = 0
  100. printing: int = 0
  101. completed: int = 0
  102. failed: int = 0
  103. cancelled: int = 0
  104. skipped: int = 0
  105. # Actual material + energy cost of this plate's finished runs. None when no
  106. # run has produced a cost yet — reported as "unknown", never as zero.
  107. actual_cost: float | None = None
  108. filament_used_grams: float | None = None
  109. print_time_seconds: int = 0
  110. # Whether any queue item for this plate still exists, in any status.
  111. # Dispatch clones one to inherit the print configuration, so a plate with
  112. # none cannot be queued however many runs it still owes.
  113. has_source: bool = False
  114. @property
  115. def dispatched(self) -> int:
  116. return self.pending + self.printing + self.completed
  117. @property
  118. def remaining(self) -> int:
  119. return max(0, self.quantity_target - self.dispatched)
  120. @property
  121. def can_dispatch(self) -> bool:
  122. """True when this plate owes runs *and* something can produce them."""
  123. return self.remaining > 0 and self.has_source
  124. @property
  125. def cost_per_run(self) -> float | None:
  126. """Observed mean cost of this plate's completed runs, or None.
  127. Deliberately measured rather than estimated from the file: the file's
  128. estimate ignores what the run actually consumed, and a plate that has
  129. never completed has no honest number to show.
  130. """
  131. if self.completed <= 0 or self.actual_cost is None:
  132. return None
  133. return self.actual_cost / self.completed
  134. @property
  135. def estimated_remaining_cost(self) -> float | None:
  136. per_run = self.cost_per_run
  137. if per_run is None:
  138. return None
  139. return per_run * self.remaining
  140. @dataclass
  141. class BatchProgress:
  142. """Whole-order roll-up, plus the per-plate breakdown it was derived from."""
  143. plates: list[PlateProgress] = field(default_factory=list)
  144. has_targets: bool = False
  145. def _sum(self, attr: str) -> int:
  146. return sum(getattr(p, attr) for p in self.plates)
  147. @property
  148. def pending(self) -> int:
  149. return self._sum("pending")
  150. @property
  151. def printing(self) -> int:
  152. return self._sum("printing")
  153. @property
  154. def completed(self) -> int:
  155. return self._sum("completed")
  156. @property
  157. def failed(self) -> int:
  158. return self._sum("failed")
  159. @property
  160. def cancelled(self) -> int:
  161. return self._sum("cancelled")
  162. @property
  163. def skipped(self) -> int:
  164. return self._sum("skipped")
  165. @property
  166. def target(self) -> int:
  167. return self._sum("quantity_target")
  168. @property
  169. def remaining(self) -> int:
  170. return self._sum("remaining")
  171. @property
  172. def dispatchable_remaining(self) -> int:
  173. """Of the runs still owed, how many can actually be queued.
  174. Lower than ``remaining`` when a plate's last queue item was deleted:
  175. the order still owes the run, but nothing survives to clone its
  176. printer target, AMS mapping and print options from.
  177. """
  178. return sum(p.remaining for p in self.plates if p.has_source)
  179. @property
  180. def actual_cost(self) -> float | None:
  181. costs = [p.actual_cost for p in self.plates if p.actual_cost is not None]
  182. return sum(costs) if costs else None
  183. @property
  184. def estimated_remaining_cost(self) -> float | None:
  185. estimates = [p.estimated_remaining_cost for p in self.plates if p.estimated_remaining_cost is not None]
  186. return sum(estimates) if estimates else None
  187. @property
  188. def filament_used_grams(self) -> float | None:
  189. grams = [p.filament_used_grams for p in self.plates if p.filament_used_grams is not None]
  190. return sum(grams) if grams else None
  191. @property
  192. def print_time_seconds(self) -> int:
  193. return self._sum("print_time_seconds")
  194. @property
  195. def is_fulfilled(self) -> bool:
  196. """True when every target is met and nothing is still in flight.
  197. A zero total target is never "fulfilled". Without that guard a legacy
  198. batch whose items were all cancelled one by one would report itself
  199. completed — its derived target counts only pending/printing/completed
  200. items, so cancelling the lot leaves a target of zero that trivially
  201. satisfies ``remaining == 0``.
  202. """
  203. return self.target > 0 and self.remaining == 0 and self.pending == 0 and self.printing == 0
  204. async def load_progress(db: AsyncSession, batch: PrintBatch) -> BatchProgress:
  205. """Build the per-plate progress roll-up for *batch*.
  206. Two queries plus one for costs, regardless of how many plates the order
  207. has — this runs once per batch in the list endpoint.
  208. """
  209. plate_rows = (await db.execute(select(PrintBatchPlate).where(PrintBatchPlate.batch_id == batch.id))).scalars().all()
  210. # (plate_id, status) -> count, plus the time/weight actually recorded.
  211. item_rows = (
  212. await db.execute(
  213. select(
  214. PrintQueueItem.plate_id,
  215. PrintQueueItem.status,
  216. func.count(PrintQueueItem.id),
  217. func.sum(PrintQueueItem.print_time_seconds),
  218. )
  219. .where(PrintQueueItem.batch_id == batch.id)
  220. .group_by(PrintQueueItem.plate_id, PrintQueueItem.status)
  221. )
  222. ).all()
  223. # Per-run actuals, attributed through the queue item that produced them.
  224. # PrintLogEntry is the authoritative per-run record (#1378) and is already
  225. # scoped to the printed plate (#2614), so a multi-plate order gets each
  226. # plate's own cost rather than the whole file's.
  227. cost_rows = (
  228. await db.execute(
  229. select(
  230. PrintQueueItem.plate_id,
  231. func.sum(
  232. func.coalesce(PrintLogEntry.cost, 0.0)
  233. + func.coalesce(PrintLogEntry.energy_cost, 0.0)
  234. + func.coalesce(PrintLogEntry.wear_cost, 0.0)
  235. ),
  236. func.sum(PrintLogEntry.filament_used_grams),
  237. )
  238. .select_from(PrintLogEntry)
  239. .join(PrintQueueItem, PrintLogEntry.queue_item_id == PrintQueueItem.id)
  240. .where(PrintQueueItem.batch_id == batch.id)
  241. .group_by(PrintQueueItem.plate_id)
  242. )
  243. ).all()
  244. costs = {row[0]: (row[1], row[2]) for row in cost_rows}
  245. progress = BatchProgress(has_targets=bool(plate_rows))
  246. by_plate: dict[int | None, PlateProgress] = {}
  247. for row in plate_rows:
  248. by_plate[row.plate_id] = PlateProgress(
  249. plate_id=row.plate_id,
  250. plate_name=row.plate_name,
  251. quantity_target=row.quantity_target,
  252. sort_order=row.sort_order,
  253. )
  254. for plate_id, status, count, time_sum in item_rows:
  255. plate = by_plate.get(plate_id)
  256. if plate is None:
  257. # A queue item for a plate the order has no target row for: either
  258. # a legacy batch, or an item grouped in by hand after the fact.
  259. # Its own dispatched count becomes its target so it reads as
  260. # complete rather than as owing work nobody asked for.
  261. plate = PlateProgress(plate_id=plate_id, plate_name=None, quantity_target=0, sort_order=plate_id or 0)
  262. by_plate[plate_id] = plate
  263. if status in CONSUMING_STATUSES:
  264. plate.quantity_target += count
  265. elif not progress.has_targets and status in CONSUMING_STATUSES:
  266. plate.quantity_target += count
  267. # Any surviving row is a clone source, whatever its status — a
  268. # cancelled or failed run still carries the configuration a re-queue
  269. # needs. Set before the status filter below so a status this module
  270. # has no counter for still marks the plate dispatchable.
  271. plate.has_source = True
  272. if status in COUNTED_STATUSES:
  273. setattr(plate, status, getattr(plate, status) + count)
  274. else:
  275. logger.debug("Batch %s: ignoring queue item status %r in progress roll-up", batch.id, status)
  276. plate.print_time_seconds += int(time_sum or 0)
  277. for plate_id, (cost_sum, gram_sum) in costs.items():
  278. plate = by_plate.get(plate_id)
  279. if plate is None:
  280. continue
  281. plate.actual_cost = float(cost_sum) if cost_sum else None
  282. plate.filament_used_grams = float(gram_sum) if gram_sum else None
  283. progress.plates = sorted(by_plate.values(), key=lambda p: (p.sort_order, p.plate_id or 0))
  284. return progress
  285. async def refresh_batch_status(db: AsyncSession, batch: PrintBatch) -> bool:
  286. """Flip an ``active`` batch to ``completed`` once its targets are met.
  287. Returns True when the status changed. A ``cancelled`` batch is never
  288. resurrected, and a ``completed`` batch drops back to ``active`` if its
  289. targets grow — raising a target on a finished order reopens it rather than
  290. leaving a "completed" order that still owes prints.
  291. """
  292. progress = await load_progress(db, batch)
  293. if batch.status == "cancelled":
  294. return False
  295. if batch.status == "active" and progress.is_fulfilled:
  296. batch.status = "completed"
  297. batch.completed_at = datetime.now(timezone.utc)
  298. logger.info("Batch %s fulfilled — marked completed", batch.id)
  299. return True
  300. # A grouping whose every item was cancelled one at a time is finished, but
  301. # nothing was produced, so "completed" would be a lie and `is_fulfilled`
  302. # rightly refuses it (its derived target is zero). Left alone it would sit
  303. # on "active" forever. Cancelled is what it is, and matches what the
  304. # batch-level Cancel action would have set had it been used.
  305. #
  306. # Deliberately not applied to orders: an order states its intent
  307. # independently of its runs, so cancelling every run still leaves it owing
  308. # work and offering to re-queue it. A grouping has no such statement — it
  309. # was only ever the sum of its items.
  310. if batch.status == "active" and not progress.has_targets and progress.completed == 0:
  311. settled = progress.pending == 0 and progress.printing == 0
  312. if settled and progress.cancelled > 0 and progress.failed == 0 and progress.skipped == 0:
  313. batch.status = "cancelled"
  314. logger.info("Batch %s had every item cancelled — marked cancelled", batch.id)
  315. return True
  316. # Likewise finished, but with real failures among its jobs and nothing
  317. # produced: neither "completed" nor "cancelled" says that. Not while a
  318. # job is skipped: resuming the printer puts it back in the queue.
  319. if settled and progress.failed > 0 and progress.skipped == 0:
  320. batch.status = "failed"
  321. logger.info("Batch %s ended with failures and nothing printed — marked failed", batch.id)
  322. return True
  323. # A failed grouping that has open work again (a job added to it) is active.
  324. if batch.status == "failed" and (progress.pending > 0 or progress.printing > 0):
  325. batch.status = "active"
  326. logger.info("Batch %s has open work again — reopened", batch.id)
  327. return True
  328. if batch.status == "completed" and not progress.is_fulfilled:
  329. batch.status = "active"
  330. batch.completed_at = None
  331. logger.info("Batch %s reopened — targets no longer met", batch.id)
  332. return True
  333. return False
  334. async def backfill_batch_statuses(db: AsyncSession) -> int:
  335. """Close out ``active`` batches that finished before the status existed.
  336. ``completed`` only became reachable with #342. Every batch created since
  337. the feature shipped in April 2026 is therefore still marked ``active``,
  338. however long ago its last run finished — so without this pass the Batches
  339. tab opens on months of accumulated history.
  340. Runs on every startup rather than once behind a marker: it is cheap (only
  341. batches with nothing in flight are even considered), it is idempotent, and
  342. repeating it also closes out any order whose last run landed while the
  343. process was down.
  344. Returns the number of batches whose status changed.
  345. """
  346. candidates = (
  347. (
  348. await db.execute(
  349. select(PrintBatch)
  350. .where(PrintBatch.status == "active")
  351. # Anything still queued or printing is by definition unfinished,
  352. # and re-deriving its progress would change nothing.
  353. .where(
  354. ~select(PrintQueueItem.id)
  355. .where(PrintQueueItem.batch_id == PrintBatch.id)
  356. .where(PrintQueueItem.status.in_(("pending", "printing")))
  357. .exists()
  358. )
  359. )
  360. )
  361. .scalars()
  362. .all()
  363. )
  364. changed = 0
  365. for batch in candidates:
  366. if await refresh_batch_status(db, batch):
  367. changed += 1
  368. if changed:
  369. await db.commit()
  370. logger.info("Marked %d finished batch(es) as completed at startup (#342)", changed)
  371. return changed
  372. async def refresh_batches_for_items(db: AsyncSession, queue_item_ids: list[int]) -> None:
  373. """Re-evaluate every batch owning one of *queue_item_ids*, once each.
  374. For the paths that end items without a print completing (a cancel, a stop,
  375. the stranded-item sweep, a library delete): a grouping whose last item was
  376. cancelled, or an order whose last run was closed, would otherwise stay
  377. active until the next restart. Changes are left for the caller to commit.
  378. """
  379. if not queue_item_ids:
  380. return
  381. await db.flush()
  382. batch_ids = (
  383. (
  384. await db.execute(
  385. select(PrintQueueItem.batch_id)
  386. .where(PrintQueueItem.id.in_(queue_item_ids))
  387. .where(PrintQueueItem.batch_id.is_not(None))
  388. .distinct()
  389. )
  390. )
  391. .scalars()
  392. .all()
  393. )
  394. for batch_id in batch_ids:
  395. batch = await db.get(PrintBatch, batch_id)
  396. if batch is not None:
  397. await refresh_batch_status(db, batch)
  398. async def refresh_batches_after(queue_item_ids: list[int]) -> None:
  399. """:func:`refresh_batches_for_items` in its own session, after the caller's commit.
  400. Best-effort, as on the print-completion path: an order's status must not
  401. be the reason a cancel, a stop or a scheduler step fails or rolls back.
  402. """
  403. if not queue_item_ids:
  404. return
  405. from backend.app.core import database
  406. try:
  407. async with database.async_session() as db:
  408. await refresh_batches_for_items(db, queue_item_ids)
  409. await db.commit()
  410. except Exception as e:
  411. logger.warning("Failed to refresh the batch status of queue items %s: %s", queue_item_ids, e)
  412. async def refresh_batch_status_for_item(db: AsyncSession, queue_item_id: int) -> None:
  413. """Re-evaluate the batch owning *queue_item_id*, if it has one.
  414. Called from the print-completion path so a finished order reports itself
  415. complete the moment its last run lands, rather than whenever someone next
  416. opens the page.
  417. """
  418. batch_id = (
  419. await db.execute(select(PrintQueueItem.batch_id).where(PrintQueueItem.id == queue_item_id))
  420. ).scalar_one_or_none()
  421. if batch_id is None:
  422. return
  423. batch = (await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))).scalar_one_or_none()
  424. if batch is None:
  425. return
  426. await refresh_batch_status(db, batch)
  427. def _clone_queue_item(source: PrintQueueItem, *, position: int, created_by_id: int | None) -> PrintQueueItem:
  428. """Copy *source*'s print configuration into a fresh pending item.
  429. Lifecycle state (status, timestamps, retry counters, scheduler flags) is
  430. deliberately not copied — the clone is a new run, not a resurrection.
  431. ``scheduled_time`` is dropped too: dispatching more of a plate is a
  432. "queue this now" action, and replaying the original's scheduled time would
  433. either fire immediately (it is in the past) or silently park the new run
  434. until a moment the user chose for a different print.
  435. ``cleanup_library_after_dispatch`` is forced off. It comes from the
  436. Printers-page direct-print flow and from a queue virtual printer's "Remove
  437. from File Manager once it prints", and deletes the library row at dispatch
  438. — replaying that on a clone would delete the source file out from under the
  439. rest of the order.
  440. """
  441. clone = PrintQueueItem(
  442. status="pending",
  443. position=position,
  444. created_by_id=created_by_id if created_by_id is not None else source.created_by_id,
  445. cleanup_library_after_dispatch=False,
  446. )
  447. for column in CLONED_SETTING_COLUMNS:
  448. setattr(clone, column, getattr(source, column))
  449. # A printer the scheduler picked for a model-based job (or one with
  450. # printer alternatives) was that run's pick, not the user's: each copy
  451. # waits for any printer of the model again.
  452. if source.printer_id is not None and (source.target_model or source.variants):
  453. clone.printer_id = None
  454. drop_printer_resolved(clone)
  455. return clone
  456. def _plate_label(plate: PlateProgress) -> str:
  457. """How a plate is named in an error the user reads.
  458. Prefers the plate name the order stored, because that is what the Batches
  459. tab shows; falls back to the plate number for orders created before names
  460. were recorded.
  461. """
  462. if plate.plate_name:
  463. return plate.plate_name
  464. return f"Plate {plate.plate_id if plate.plate_id is not None else 1}"
  465. async def dispatch_remaining(
  466. db: AsyncSession,
  467. batch: PrintBatch,
  468. *,
  469. plate_id: int | None = None,
  470. only_plate: bool = False,
  471. limit: int | None = None,
  472. created_by_id: int | None = None,
  473. ) -> list[PrintQueueItem]:
  474. """Create queue items for the runs *batch* still owes.
  475. ``only_plate`` restricts the dispatch to the single plate named by
  476. ``plate_id`` (which may legitimately be ``None`` for a single-plate file);
  477. otherwise every plate with work outstanding is dispatched in plate order.
  478. ``limit`` caps the total number of items created across all plates.
  479. Raises :class:`BatchDispatchError` when nothing at all can be produced
  480. because no plate that owes runs has an item to clone — the order can
  481. describe work whose every run has since been deleted, and there is no
  482. configuration to copy in that case. A plate in that state is skipped
  483. rather than aborting the whole order: one unrecoverable plate must not
  484. block the plates that are still perfectly dispatchable.
  485. """
  486. progress = await load_progress(db, batch)
  487. if not progress.has_targets:
  488. return []
  489. targets = [p for p in progress.plates if p.remaining > 0]
  490. if only_plate:
  491. targets = [p for p in targets if p.plate_id == plate_id]
  492. created: list[PrintQueueItem] = []
  493. stranded: list[PlateProgress] = []
  494. for plate in targets:
  495. if limit is not None and len(created) >= limit:
  496. break
  497. source = (
  498. await db.execute(
  499. select(PrintQueueItem)
  500. .options(selectinload(PrintQueueItem.variants))
  501. .where(PrintQueueItem.batch_id == batch.id)
  502. .where(PrintQueueItem.plate_id == plate.plate_id)
  503. .order_by(PrintQueueItem.id.desc())
  504. .limit(1)
  505. )
  506. ).scalar_one_or_none()
  507. if source is None:
  508. stranded.append(plate)
  509. continue
  510. wanted = plate.remaining
  511. if limit is not None:
  512. wanted = min(wanted, limit - len(created))
  513. # Clones for this plate are appended to the end of the queue (#3200),
  514. # after whatever is already pending, in plate order.
  515. position = await next_queue_position(db)
  516. for _ in range(wanted):
  517. clone = _clone_queue_item(source, position=position, created_by_id=created_by_id)
  518. position += 1
  519. db.add(clone)
  520. await db.flush()
  521. for variant in source.variants:
  522. cloned_variant = PrintQueueVariant(queue_item_id=clone.id)
  523. for column in CLONED_VARIANT_COLUMNS:
  524. setattr(cloned_variant, column, getattr(variant, column))
  525. db.add(cloned_variant)
  526. created.append(clone)
  527. if not created and stranded:
  528. names = ", ".join(_plate_label(p) for p in stranded)
  529. raise BatchDispatchError(
  530. f"{names} {'have' if len(stranded) > 1 else 'has'} no queued or finished run to copy settings from. "
  531. "Queue the plate once from the file, then dispatch the rest from here."
  532. )
  533. if created:
  534. # Dispatching more work can only ever un-fulfil an order, but run the
  535. # check anyway so a reopened batch flips back from completed.
  536. await db.flush()
  537. await refresh_batch_status(db, batch)
  538. if stranded:
  539. logger.info("Batch %s: skipped %d plate(s) with no item to clone", batch.id, len(stranded))
  540. logger.info("Dispatched %d item(s) for batch %s", len(created), batch.id)
  541. return created