diff --git a/backend/app/services/print_service.py b/backend/app/services/print_service.py index b2c5de2b..05c05431 100644 --- a/backend/app/services/print_service.py +++ b/backend/app/services/print_service.py @@ -162,12 +162,12 @@ async def _prepare_one( return image, label_data.model_dump() prepared = await asyncio.gather(*[_prepare_one(r) for r in requests]) - images = [img for img, _ in prepared] - label_data_dumps = [dump for _, dump in prepared] # 3. Pre-allocate job UUIDs + persist in JobStore + # Pro Request 1 Job persistieren (DB-Tracking bleibt 1:1 zur Hangar-Bestellung). + # Image-Replication für copies > 1 kommt in Schritt 4 (Batch-Enqueue). job_ids: list[UUID] = [] - for request, ld_dump in zip(requests, label_data_dumps, strict=True): + for request, (_img, ld_dump) in zip(requests, prepared, strict=True): job_id = uuid4() db_job = Job( id=job_id, @@ -186,12 +186,29 @@ async def _prepare_one( await self._store.save_queued(db_job) job_ids.append(job_id) + # 3b. Image-Liste für die Hardware aufbauen — Bug 2026-06-14: + # `PrintOptions.copies > 1` ist im DB-Schema vorgesehen, wurde aber + # bisher nicht in die Hardware-Druck-Liste übersetzt. Effekt: ein Request + # mit copies=3 schickte nur 1 Image an print_multi() → 1 Etikett statt 3. + # Fix: pro Request das Image so oft replizieren wie options.copies sagt. + # job_ids werden parallel repliziert damit die Längen-Validierung in + # enqueue_batch passt; duplizierte UUIDs landen einmal im self._jobs-Dict + # (Dict-Set überschreibt). Der Batch-Worker markiert je Job-ID einmal + # done — Hangar-Bestellung bleibt 1:N mit den replizierten Etiketten. + images: list[Image.Image] = [] + expanded_job_ids: list[UUID] = [] + for req, (img, _ld), jid in zip(requests, prepared, job_ids, strict=True): + n = max(1, int(req.options.copies)) + for _ in range(n): + images.append(img) + expanded_job_ids.append(jid) + # 4. Enqueue as BatchJob first_options = requests[0].options await self._queue.enqueue_batch( printer_id=self._printer_id, images=images, - job_ids=job_ids, + job_ids=expanded_job_ids, tape_mm=tape_mm, options={ "auto_cut": first_options.auto_cut, diff --git a/backend/tests/unit/services/test_print_service.py b/backend/tests/unit/services/test_print_service.py index c260a7f2..0647299b 100644 --- a/backend/tests/unit/services/test_print_service.py +++ b/backend/tests/unit/services/test_print_service.py @@ -158,6 +158,74 @@ async def test_options_forwarded_to_queue(self, make_service) -> None: assert "copies" not in kwargs +# --------------------------------------------------------------------------- +# Copies-Replikation in submit_batch_job (Bug 2026-06-14) +# --------------------------------------------------------------------------- + + +class TestSubmitBatchJobCopies: + """Verifiziert dass PrintOptions.copies > 1 in enqueue_batch zu N Images + repliziert wird. Bug 2026-06-14: zuvor wurde copies in der DB gespeichert + aber nie an print_multi() weitergereicht → User-Report nur 1 Etikett + statt N. Refs Hangar Issue #109.""" + + @pytest.mark.asyncio + async def test_copies_3_yields_three_images(self, make_service) -> None: + svc, queue, _store, _backend = make_service(loaded_tape_mm=12) + queue.enqueue_batch = AsyncMock() + request = PrintRequest( + content_type=ContentType.QR_TWO_LINES, + data=RawLabelData( + primary_id="SMA-022-003", + title="Samla 22L Box 3", + qr_payload="https://example.com/loc/SMA-022-003", + ), + options=PrintOptions(copies=3), + ) + await svc.submit_batch_job([request], half_cut=True) + queue.enqueue_batch.assert_awaited_once() + kwargs = queue.enqueue_batch.await_args.kwargs + assert len(kwargs["images"]) == 3, "copies=3 must yield 3 images" + assert len(kwargs["job_ids"]) == 3, "job_ids must match images length" + # Alle drei job_ids zeigen auf den gleichen Hangar-Request-Job + assert len(set(kwargs["job_ids"])) == 1, "same job_id replicated 3x" + + @pytest.mark.asyncio + async def test_copies_1_yields_single_image(self, make_service) -> None: + svc, queue, _store, _backend = make_service(loaded_tape_mm=12) + queue.enqueue_batch = AsyncMock() + request = PrintRequest( + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="https://example.com/x"), + options=PrintOptions(copies=1), + ) + await svc.submit_batch_job([request], half_cut=False) + kwargs = queue.enqueue_batch.await_args.kwargs + assert len(kwargs["images"]) == 1 + assert len(kwargs["job_ids"]) == 1 + + @pytest.mark.asyncio + async def test_mixed_copies_per_request(self, make_service) -> None: + """Mehrere Requests mit unterschiedlichem copies-Wert: total images = Summe.""" + svc, queue, _store, _backend = make_service(loaded_tape_mm=12) + queue.enqueue_batch = AsyncMock() + req1 = PrintRequest( + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="https://example.com/a"), + options=PrintOptions(copies=2), + ) + req2 = PrintRequest( + content_type=ContentType.QR_ONLY, + data=RawLabelData(qr_payload="https://example.com/b"), + options=PrintOptions(copies=1), + ) + await svc.submit_batch_job([req1, req2], half_cut=False) + kwargs = queue.enqueue_batch.await_args.kwargs + assert len(kwargs["images"]) == 3, "copies=2 + copies=1 = 3 images" + assert len(kwargs["job_ids"]) == 3 + assert len(set(kwargs["job_ids"])) == 2, "two distinct request-jobs" + + # --------------------------------------------------------------------------- # NoTapeLoadedError when preflight returns loaded_tape_mm=None # ---------------------------------------------------------------------------