Procházet zdrojové kódy

fix(archives): an offline stop is logged and its real end replaces it; one log entry per run; untracked completions and print start find the print's own archive and 3MF

maziggy před 3 dny
rodič
revize
bb0bf03f68

+ 5 - 0
CHANGELOG.md

@@ -164,6 +164,11 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A print stopped while its printer was offline counts in the print log, and its real end replaces the stop** — When **Stop** couldn't reach the printer, the queue item and archive were closed as cancelled, but no print log entry was written, so the run was missing from the print log and failure statistics, and the Archives page kept showing it as printing until reloaded. The stop now writes the cancelled run and updates the Archives page. Should the printer report the print's real end on reconnect, that outcome replaces the cancelled entry, and the archive no longer shows **Completed** with the reason "Cancelled by user".
+  - **One entry per run:** a printer reporting the same end twice (after a reconnect) logged the run twice, doubling it in the statistics. The repeat is now left out.
+  - **A reprint's reason:** an archive whose earlier run failed kept that failure reason when a reprint completed. Each run's end now sets the archive's reason, or clears it.
+- **A print Bambuddy didn't start is matched to its own archive** — At its end, such a print was matched to an archive whose name merely contained its name, so finishing "Clip" could complete (and bill) a "Cable Clip" still marked as printing; `_` in a name also matched any character. It now needs the same name, allowing for the printer writing spaces as underscores, cutting long names short, or reporting the file name for a print started on its screen.
+- **Print start picks the print's own 3MF from the printer's folders** — When the 3MF couldn't be downloaded directly, Bambuddy searched the printer's folders and took the first file containing the print's name, so "Cube" could be archived with "Cube_large.3mf". A file named for the print is now taken, in whichever folder, and of several files that only contain the name, none.
 - **The A1, A1 Mini, P1 and A2L built-in calibration is not archived as a print** — These printers report their calibration run as a bare `auto_cali_for_user_param.gcode`, not under the system path other models use, so Bambuddy archived each run as a print (without a 3MF) and announced it. It is now recognised as the printer's own job. The printer kill switch still stops it on these models, since nothing tells it apart from a file given that name: switch the kill switch off to calibrate them.
 - **A notification channel limited to one printer gets that printer's events only** — A job added for **Any <model>** or not yet assigned, and any job waiting (for filament, a plate, a printer), were sent without a printer, so no printer filter applied: every channel limited to a printer received all of them, other printers' included. A job that waits on a printer now names it, and reaches that printer's channels; one with no printer yet goes to channels set to all printers, and a channel limited to one printer hears of it once it is assigned to or started there. A power alert from a plug linked to no printer likewise goes to channels set to all printers. Events about the whole setup (the queue finishing, stock, app messages) still reach every channel.
 - **Queue drying pre-dries in blocking mode** — With **Wait for drying to complete** on, a new drying cycle was refused on any printer with a pending job, scheduled ones included, so queue drying never dried anything ahead of a scheduled print. Only a job that could start now keeps a new cycle off; a printer whose job is scheduled for later is dried ahead of it, and the job still waits if the cycle has not finished by then.

+ 27 - 0
backend/app/api/routes/print_queue.py

@@ -28,6 +28,7 @@ from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.printer_scope import PrinterScope, ensure_model_target_allowed
+from backend.app.core.websocket import ws_manager
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.print_batch import PrintBatch, PrintBatchPlate
@@ -2562,6 +2563,7 @@ async def stop_queue_item(
     # archive 436). Close it out here, mirroring what the MQTT path would have
     # done. Only touch a still-"printing" archive so we never overwrite a real
     # completion that raced in.
+    closed_archive_id: int | None = None
     if not stop_sent and item.archive_id:
         archive = await db.get(PrintArchive, item.archive_id)
         if archive and archive.status == "printing":
@@ -2570,9 +2572,34 @@ async def stop_queue_item(
             # The vocabulary key every user cancel stores (#2974); that the
             # printer was offline is on the queue row's error_message.
             archive.failure_reason = "userCancelled"
+            # The run's log entry, so the stop counts in the print log and
+            # failure statistics. Without word from the printer, how far it got
+            # is unknown: no grams, cost or wear are entered. Should the printer
+            # report the real end after all, that entry is replaced.
+            from backend.app.services.print_log import write_log_entry
+
+            await write_log_entry(
+                db,
+                status="cancelled",
+                archive_id=archive.id,
+                queue_item_id=item.id,
+                print_name=archive.print_name,
+                printer_name=await db.scalar(select(Printer.name).where(Printer.id == printer_id)),
+                printer_id=printer_id,
+                started_at=archive.started_at,
+                completed_at=archive.completed_at,
+                filament_type=archive.filament_type,
+                filament_color=archive.filament_color,
+                failure_reason=archive.failure_reason,
+                thumbnail_path=archive.thumbnail_path,
+                created_by_id=archive.created_by_id,
+            )
+            closed_archive_id = archive.id
 
     await db.commit()
     await refresh_batches_after([item.id])
+    if closed_archive_id is not None:
+        await ws_manager.send_archive_updated({"id": closed_archive_id, "status": "cancelled"})
 
     logger.info("Stopped printing queue item %s (stop command sent: %s)", item_id, stop_sent)
 

+ 100 - 62
backend/app/main.py

@@ -5465,65 +5465,68 @@ async def on_print_start(printer_id: int, data: dict):
             search_term = (subtask_name or filename).lower().replace(".gcode", "").replace(".3mf", "")
             logger.info("Direct FTP download failed, searching directories for '%s'", search_term)
             search_dirs = ["/cache", "/model", "/data", "/data/Metadata", "/"]
+            # Every folder is listed before one file is picked, so a file named
+            # for the print in a later folder wins over one that merely
+            # contains the name in an earlier folder.
+            listed_paths: list[str] = []
             for search_dir in search_dirs:
-                if downloaded_filename:
-                    break
                 try:
                     dir_files = await list_files_async(
                         printer.ip_address, printer.access_code, search_dir, printer_model=printer.model
                     )
-                    threemf_files = [f.get("name") for f in dir_files if f.get("name", "").endswith(".3mf")]
-                    if threemf_files:
-                        logger.info(
-                            f"Found {len(threemf_files)} 3MF files in {search_dir}: {threemf_files[:5]}{'...' if len(threemf_files) > 5 else ''}"
-                        )
-                    for f in dir_files:
-                        if f.get("is_directory"):
-                            continue
-                        fname = f.get("name", "")
-                        # Normalize both for comparison (spaces and underscores are equivalent)
-                        fname_normalized = fname.lower().replace(" ", "_")
-                        search_normalized = search_term.replace(" ", "_")
-                        if fname.endswith(".3mf") and search_normalized in fname_normalized:
-                            logger.info("Found matching file in %s: %s", search_dir, fname)
-                            temp_path = app_settings.archive_dir / "temp" / fname
-                            temp_path.parent.mkdir(parents=True, exist_ok=True)
-                            remote_full_path = posixpath.join(search_dir, fname)
-                            if ftp_retry_enabled:
-                                downloaded = await with_ftp_retry(
-                                    download_file_async,
-                                    printer.ip_address,
-                                    printer.access_code,
-                                    remote_full_path,
-                                    temp_path,
-                                    timeout=ftp_timeout,
-                                    socket_timeout=ftp_timeout,
-                                    printer_model=printer.model,
-                                    max_retries=ftp_retry_count,
-                                    retry_delay=ftp_retry_delay,
-                                    operation_name=f"Download 3MF from {remote_full_path}",
-                                    cooloff_ip=printer.ip_address,
-                                )
-                            else:
-                                downloaded = await download_file_async(
-                                    printer.ip_address,
-                                    printer.access_code,
-                                    remote_full_path,
-                                    temp_path,
-                                    timeout=ftp_timeout,
-                                    socket_timeout=ftp_timeout,
-                                    printer_model=printer.model,
-                                )
-                            if downloaded:
-                                downloaded_filename = fname
-                                logger.info("Found and downloaded from %s: %s", search_dir, fname)
-                                cache_3mf_download(printer_id, fname, temp_path)
-                                break
-                            # The listing named the file, so it is on the card;
-                            # only the transfer failed (#3063).
-                            ftp_transfer_failed = True
                 except Exception as e:
                     logger.debug("Failed to list %s: %s", search_dir, e)
+                    continue
+                threemf_files = [f.get("name") for f in dir_files if f.get("name", "").endswith(".3mf")]
+                if threemf_files:
+                    logger.info(
+                        f"Found {len(threemf_files)} 3MF files in {search_dir}: {threemf_files[:5]}{'...' if len(threemf_files) > 5 else ''}"
+                    )
+                listed_paths += [
+                    posixpath.join(search_dir, f.get("name", "")) for f in dir_files if not f.get("is_directory")
+                ]
+            for remote_full_path in _3mf_files_for_print(listed_paths, search_term):
+                fname = posixpath.basename(remote_full_path)
+                logger.info("Found matching file: %s", remote_full_path)
+                temp_path = app_settings.archive_dir / "temp" / fname
+                temp_path.parent.mkdir(parents=True, exist_ok=True)
+                try:
+                    if ftp_retry_enabled:
+                        downloaded = await with_ftp_retry(
+                            download_file_async,
+                            printer.ip_address,
+                            printer.access_code,
+                            remote_full_path,
+                            temp_path,
+                            timeout=ftp_timeout,
+                            socket_timeout=ftp_timeout,
+                            printer_model=printer.model,
+                            max_retries=ftp_retry_count,
+                            retry_delay=ftp_retry_delay,
+                            operation_name=f"Download 3MF from {remote_full_path}",
+                            cooloff_ip=printer.ip_address,
+                        )
+                    else:
+                        downloaded = await download_file_async(
+                            printer.ip_address,
+                            printer.access_code,
+                            remote_full_path,
+                            temp_path,
+                            timeout=ftp_timeout,
+                            socket_timeout=ftp_timeout,
+                            printer_model=printer.model,
+                        )
+                except Exception as e:
+                    logger.debug("Failed to download %s: %s", remote_full_path, e)
+                    continue
+                if downloaded:
+                    downloaded_filename = fname
+                    logger.info("Found and downloaded: %s", remote_full_path)
+                    cache_3mf_download(printer_id, fname, temp_path)
+                    break
+                # The listing named the file, so it is on the card;
+                # only the transfer failed (#3063).
+                ftp_transfer_failed = True
 
         # Validate the downloaded 3MF actually matches the plate that's running
         # (#1204): subtask_name lags across consecutive plates of the same model,
@@ -7636,6 +7639,28 @@ def _normalise_subtask_name(name: str) -> str:
     return name.strip().replace(" ", "_").strip("_").casefold()
 
 
+def _3mf_files_for_print(paths: list[str], search_term: str) -> list[str]:
+    """The 3MF files of the printer's folder listings that are this print's.
+
+    A file of the print's own name wins, in whichever folder. Only without one
+    is a file that merely contains the name taken, and then only when it is
+    the one such file: "Cube_large.3mf" is not "Cube"'s file, and of several
+    candidates none can be told apart. Paths keep the listings' order.
+    """
+    threemf = [path for path in paths if path.lower().endswith(".3mf")]
+    exact = [path for path in threemf if _subtask_names_match(_subtask_name_from_filename(path), search_term)]
+    if exact:
+        return exact
+    term = _normalise_subtask_name(search_term)
+    partial = [path for path in threemf if term in _normalise_subtask_name(PurePosixPath(path).name)]
+    if len(partial) > 1:
+        logging.getLogger(__name__).info(
+            "Several 3MF files contain %r and none is named for it: %s", search_term, partial
+        )
+        return []
+    return partial
+
+
 def _subtask_names_match(expected: str, observed: str) -> bool:
     """Whether two subtask names describe the same print.
 
@@ -8003,22 +8028,35 @@ async def on_print_complete(printer_id: int, data: dict):
         async with async_session() as db:
             from backend.app.models.archive import PrintArchive
 
-            # Try matching by subtask_name (stored as print_name) first
+            # Try matching by subtask_name (stored as print_name) first. The
+            # same name rule as the queue's completion check: a name inside
+            # another ("Clip" in "Cable Clip") is a different print, and the
+            # printer's underscores for spaces and truncation still match.
             if subtask_name:
-                result = await db.execute(
+                printing = await db.scalars(
                     select(PrintArchive)
                     .where(PrintArchive.printer_id == printer_id)
                     .where(PrintArchive.status == "printing")
-                    .where(
-                        or_(
-                            PrintArchive.print_name.ilike(f"%{subtask_name}%"),
-                            PrintArchive.filename.ilike(f"%{subtask_name}%"),
-                        )
-                    )
                     .order_by(PrintArchive.created_at.desc())
-                    .limit(1)
                 )
-                archive = result.scalar_one_or_none()
+                # A print started from the printer's screen reports its file
+                # name, extensions included, as the subtask (#3009).
+                reported = {subtask_name, _subtask_name_from_filename(subtask_name)}
+                archive = next(
+                    (
+                        candidate
+                        for candidate in printing
+                        if any(
+                            (candidate.print_name and _subtask_names_match(candidate.print_name, name))
+                            or (
+                                candidate.filename
+                                and _subtask_names_match(_subtask_name_from_filename(candidate.filename), name)
+                            )
+                            for name in reported
+                        )
+                    ),
+                    None,
+                )
                 if archive:
                     archive_id = archive.id
                     logger.info("Found archive %s by subtask_name match: %s", archive_id, subtask_name)

+ 4 - 2
backend/app/services/archive.py

@@ -1628,8 +1628,10 @@ class ArchiveService:
         archive.status = status
         if completed_at:
             archive.completed_at = completed_at
-        if failure_reason:
-            archive.failure_reason = failure_reason
+        # This run's reason, or none: a reprint that completes, or a print
+        # that finished after all once it was stopped offline, keeps no reason
+        # of an earlier outcome.
+        archive.failure_reason = failure_reason
 
         await self.db.commit()
         return True

+ 99 - 26
backend/app/services/print_log.py

@@ -4,9 +4,9 @@ Log entries are written to a separate table and never touch archives or queue it
 """
 
 import logging
-from datetime import datetime
+from datetime import datetime, timezone
 
-from sqlalchemy import func, select
+from sqlalchemy import func, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.models.print_log import PrintLogEntry
@@ -66,37 +66,110 @@ async def write_log_entry(
     if reconciled:
         duration: int | None = 0
     elif started_at and completed_at:
-        duration = int((completed_at - started_at).total_seconds())
+        duration = int((_utc(completed_at) - _utc(started_at)).total_seconds())
     else:
         duration = None
 
-    entry = PrintLogEntry(
-        archive_id=archive_id,
-        queue_item_id=queue_item_id,
-        print_name=print_name,
-        printer_name=printer_name,
-        printer_id=printer_id,
-        status=status,
-        started_at=started_at,
-        completed_at=completed_at,
-        duration_seconds=duration,
-        filament_type=filament_type,
-        filament_color=filament_color,
-        filament_used_grams=filament_used_grams,
-        cost=cost,
-        energy_kwh=energy_kwh,
-        energy_cost=energy_cost,
-        wear_cost=wear_cost_for_run(duration, wear_cost_per_hour),
-        failure_reason=failure_reason,
-        thumbnail_path=thumbnail_path,
-        created_by_id=created_by_id,
-        created_by_username=created_by_username,
-    )
-    db.add(entry)
+    fields = {
+        "archive_id": archive_id,
+        "queue_item_id": queue_item_id,
+        "print_name": print_name,
+        "printer_name": printer_name,
+        "printer_id": printer_id,
+        "status": status,
+        "started_at": started_at,
+        "completed_at": completed_at,
+        "duration_seconds": duration,
+        "filament_type": filament_type,
+        "filament_color": filament_color,
+        "filament_used_grams": filament_used_grams,
+        "cost": cost,
+        "energy_kwh": energy_kwh,
+        "energy_cost": energy_cost,
+        "wear_cost": wear_cost_for_run(duration, wear_cost_per_hour),
+        "failure_reason": failure_reason,
+        "thumbnail_path": thumbnail_path,
+        "created_by_id": created_by_id,
+        "created_by_username": created_by_username,
+    }
+    entry = await _entry_of_this_run(db, archive_id, printer_id, started_at)
+    if entry is None:
+        entry = PrintLogEntry(**fields)
+        db.add(entry)
+    elif entry.status != status:
+        # The run ended differently from how it was first logged: a print
+        # stopped while its printer was offline that finished after all. The
+        # real outcome replaces the stop rather than counting the run twice.
+        for name, value in fields.items():
+            if value is None and name in _KEPT_WHEN_NOT_REPORTED:
+                continue
+            setattr(entry, name, value)
+    # The same outcome reported again (a repeated completion after a reconnect)
+    # changes nothing: the repeat no longer has the run's tracked usage, and the
+    # entry may have been edited or given its energy since.
     await db.flush()
     return entry
 
 
+async def other_runs_of(db: AsyncSession, archive) -> int:
+    """How many log entries *archive* has from runs other than its current one.
+
+    The archive card keeps its first run's figures; a run whose own entry is
+    already written (its stop, logged while the printer was offline) is still
+    the first run.
+    """
+    # Without a start time or printer on the archive, ``!= None`` reads as
+    # ``IS NOT NULL``: every entry is another run's.
+    query = select(func.count(PrintLogEntry.id)).where(
+        PrintLogEntry.archive_id == archive.id,
+        or_(
+            PrintLogEntry.started_at.is_(None),
+            PrintLogEntry.started_at != archive.started_at,
+            PrintLogEntry.printer_id.is_(None),
+            PrintLogEntry.printer_id != archive.printer_id,
+        ),
+    )
+    return await db.scalar(query) or 0
+
+
+# Who and what the run was, which a repeated report may no longer know: a
+# completion that finds the queue item already closed reports none. Its
+# outcome (status, reason, grams, cost) is replaced as reported.
+_KEPT_WHEN_NOT_REPORTED = frozenset(
+    {"queue_item_id", "print_name", "printer_name", "thumbnail_path", "created_by_id", "created_by_username"}
+)
+
+
+def _utc(moment: datetime) -> datetime:
+    """Naive UTC, as the columns store it; an aware time is converted first."""
+    return moment.astimezone(timezone.utc).replace(tzinfo=None) if moment.tzinfo else moment
+
+
+async def _entry_of_this_run(
+    db: AsyncSession,
+    archive_id: int | None,
+    printer_id: int | None,
+    started_at: datetime | None,
+) -> PrintLogEntry | None:
+    """The entry already written for this run, if any.
+
+    A run is its archive, printer and start time together: plates of one
+    archive printing on two printers share the archive row (and can share its
+    start time), and a reprint starts at a new time. The queue item is not
+    part of it: a repeated completion finds the item already closed and
+    reports none. Without an archive, printer or start time a run can't be
+    told apart, so nothing is matched.
+    """
+    if archive_id is None or printer_id is None or started_at is None:
+        return None
+    query = select(PrintLogEntry).where(
+        PrintLogEntry.archive_id == archive_id,
+        PrintLogEntry.printer_id == printer_id,
+        PrintLogEntry.started_at == started_at,
+    )
+    return (await db.scalars(query.order_by(PrintLogEntry.id.desc()).limit(1))).first()
+
+
 async def record_archive_wear(db: AsyncSession, archive, entry: PrintLogEntry) -> None:
     """Copy a run's wear cost onto its archive when it is the archive's first run.
 

+ 3 - 7
backend/app/services/spoolman_tracking.py

@@ -1894,11 +1894,8 @@ async def _apply_spool_cost_to_archive(db, archive_id: int, print_cost: _PrintCo
             )
         return
 
-    from sqlalchemy import func
-
     from backend.app.api.routes.settings import get_setting
     from backend.app.models.archive import PrintArchive
-    from backend.app.models.print_log import PrintLogEntry
 
     archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
     if archive is None:
@@ -1921,10 +1918,9 @@ async def _apply_spool_cost_to_archive(db, archive_id: int, print_cost: _PrintCo
     if total <= 0:
         return
 
-    existing_runs = (
-        await db.execute(select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive_id))
-    ).scalar()
-    if existing_runs:
+    from backend.app.services.print_log import other_runs_of
+
+    if await other_runs_of(db, archive):
         return
 
     new_cost = round(total, 2)

+ 4 - 7
backend/app/services/usage_tracker.py

@@ -989,10 +989,9 @@ async def on_print_complete(
     # so the overwrite must reconstruct the whole-print cost.
 
     if archive_id and results:
-        from sqlalchemy import func, select
+        from sqlalchemy import select
 
         from backend.app.models.archive import PrintArchive
-        from backend.app.models.print_log import PrintLogEntry
 
         archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
         archive = archive_result.scalar_one_or_none()
@@ -1008,11 +1007,9 @@ async def on_print_complete(
                 # live in PrintLogEntry; the archive card keeps the first run's
                 # cost so a failed reprint doesn't visually clobber a successful
                 # 100 g/$X print with a 10 g/$X/10 partial (#1378).
-                _existing_runs_result = await db.execute(
-                    select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive_id)
-                )
-                _existing_runs = _existing_runs_result.scalar()
-                if not _existing_runs:
+                from backend.app.services.print_log import other_runs_of
+
+                if not await other_runs_of(db, archive):
                     archive.cost = round(total_cost, 2)
                     await db.commit()
 

+ 153 - 3
backend/tests/integration/test_print_complete_end_to_end.py

@@ -9,6 +9,7 @@ printer's SD card) is mocked; the printer manager knows no printer.
 """
 
 import asyncio
+from datetime import datetime
 from unittest.mock import AsyncMock, patch
 
 import pytest
@@ -41,11 +42,17 @@ def _clean():
     printer_manager._awaiting_plate_clear.clear()
 
 
+@pytest.fixture(autouse=True)
+async def _no_finish_photo(db_session):
+    """The finish photo would open the printer's camera."""
+    db_session.add(Settings(key="capture_finish_photo", value="false"))
+    await db_session.commit()
+
+
 @pytest.fixture
 async def queued_print(db_session, printer_factory):
     printer = await printer_factory(name="P")
     db_session.add(Settings(key="billing_enabled", value="true"))
-    db_session.add(Settings(key="capture_finish_photo", value="false"))
     user = User(username="payer", password_hash="x", is_active=True)
     center = CostCenter(name="Lab", is_active=True, is_private=False)
     db_session.add_all([user, center])
@@ -57,6 +64,7 @@ async def queued_print(db_session, printer_factory):
         file_path="archives/test/part.3mf",
         file_size=1,
         status="printing",
+        started_at=datetime(2026, 10, 19, 8, 0),
         cost=5.0,
         created_by_id=user.id,
         billing_run_id=RUN,
@@ -79,7 +87,7 @@ async def queued_print(db_session, printer_factory):
     return {"printer": printer.id, "user": user.id, "center": center.id, "archive": archive.id, "item": item.id}
 
 
-async def _complete(test_engine, printer_id: int, status: str) -> None:
+async def _complete(test_engine, printer_id: int, status: str, name: str = "part") -> None:
     maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
     tasks_before = set(asyncio.all_tasks())
     with (
@@ -102,7 +110,7 @@ async def _complete(test_engine, printer_id: int, status: str) -> None:
         plug.on_print_complete = AsyncMock()
         await main_module.on_print_complete(
             printer_id,
-            {"status": status, "filename": "part.gcode", "subtask_name": "part", "timelapse_was_active": False},
+            {"status": status, "filename": f"{name}.gcode", "subtask_name": name, "timelapse_was_active": False},
         )
         spawned = asyncio.all_tasks() - tasks_before
         if spawned:
@@ -157,3 +165,145 @@ async def test_a_second_completion_charges_nothing_more(test_engine, db_session,
 
     _archive, _item, _logs, charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
     assert len(charges) == 1
+
+
+async def test_a_duplicate_completion_logs_the_run_once(test_engine, db_session, queued_print):
+    await _complete(test_engine, queued_print["printer"], "completed")
+    main_module._active_prints[(queued_print["printer"], "part.3mf")] = queued_print["archive"]
+    await _complete(test_engine, queued_print["printer"], "completed")
+
+    _archive, _item, logs, _charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
+    assert [(log.status, log.queue_item_id) for log in logs] == [("completed", queued_print["item"])]
+
+
+async def test_a_completion_takes_its_own_reason(test_engine, db_session, queued_print):
+    """A reprint that completes carries no reason of an earlier run."""
+    archive = await db_session.get(PrintArchive, queued_print["archive"])
+    archive.failure_reason = "spaghetti"
+    await db_session.commit()
+
+    await _complete(test_engine, queued_print["printer"], "completed")
+
+    archive, _item, logs, _charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
+    assert (archive.status, archive.failure_reason) == ("completed", None)
+    assert [(log.status, log.failure_reason) for log in logs] == [("completed", None)]
+
+
+class TestAStopWhileThePrinterIsOffline:
+    """The stop could not reach the printer, so no completion may ever come (#2603)."""
+
+    async def _stop(self, async_client, queued_print):
+        with (
+            patch("backend.app.services.print_control.stop_print_by_user", return_value=False),
+            patch("backend.app.api.routes.print_queue.ws_manager") as ws,
+        ):
+            ws.send_archive_updated = AsyncMock()
+            response = await async_client.post(f"/api/v1/queue/{queued_print['item']}/stop")
+        assert response.status_code == 200, response.text
+        return ws
+
+    async def test_logs_the_run_as_cancelled_and_tells_the_archives_page(self, async_client, db_session, queued_print):
+        ws = await self._stop(async_client, queued_print)
+
+        archive, item, logs, _charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
+        assert (archive.status, archive.failure_reason) == ("cancelled", "userCancelled")
+        assert item.status == "cancelled"
+        assert [(log.status, log.failure_reason, log.queue_item_id) for log in logs] == [
+            ("cancelled", "userCancelled", queued_print["item"])
+        ]
+        assert ws.send_archive_updated.await_args.args[0] == {"id": queued_print["archive"], "status": "cancelled"}
+
+    async def test_a_completion_that_arrives_after_all_replaces_it(
+        self, async_client, test_engine, db_session, queued_print
+    ):
+        """The printer finished the print offline and reports it on reconnect."""
+        await self._stop(async_client, queued_print)
+
+        await _complete(test_engine, queued_print["printer"], "completed")
+
+        archive, item, logs, _charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
+        assert (archive.status, archive.failure_reason) == ("completed", None)
+        assert item.status == "cancelled"
+        assert [(log.status, log.failure_reason, log.queue_item_id) for log in logs] == [
+            ("completed", None, queued_print["item"])
+        ]
+
+
+class TestACompletionNoArchiveWasBoundTo:
+    """A print Bambuddy did not start is matched to its archive by name (L-W3)."""
+
+    async def _printing(self, db_session, printer_id: int, name: str) -> int:
+        archive = PrintArchive(
+            printer_id=printer_id,
+            filename=f"{name}.3mf",
+            print_name=name,
+            file_path=f"archives/test/{name}.3mf",
+            file_size=1,
+            status="printing",
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        return archive.id
+
+    @pytest.mark.parametrize(
+        ("printed", "other"),
+        [
+            ("Clip", "Cable Clip"),  # a name inside another
+            ("Clip_A", "ClipXA"),  # "_" is a LIKE wildcard
+        ],
+    )
+    async def test_another_archive_whose_name_contains_it_is_left_alone(
+        self, test_engine, db_session, printer_factory, printed, other
+    ):
+        printer = await printer_factory(name="P")
+        other_id = await self._printing(db_session, printer.id, other)
+
+        await _complete(test_engine, printer.id, "completed", name=printed)
+
+        db_session.expire_all()
+        assert (await db_session.get(PrintArchive, other_id)).status == "printing"
+
+    async def test_its_own_archive_is_completed(self, test_engine, db_session, printer_factory):
+        printer = await printer_factory(name="P")
+        other_id = await self._printing(db_session, printer.id, "Cable Clip")
+        own_id = await self._printing(db_session, printer.id, "Clip")
+
+        await _complete(test_engine, printer.id, "completed", name="Clip")
+
+        db_session.expire_all()
+        assert (await db_session.get(PrintArchive, own_id)).status == "completed"
+        assert (await db_session.get(PrintArchive, other_id)).status == "printing"
+
+    async def test_the_printers_underscores_for_spaces_still_match(self, test_engine, db_session, printer_factory):
+        printer = await printer_factory(name="P")
+        own_id = await self._printing(db_session, printer.id, "Cable Clip")
+
+        await _complete(test_engine, printer.id, "completed", name="Cable_Clip")
+
+        db_session.expire_all()
+        assert (await db_session.get(PrintArchive, own_id)).status == "completed"
+
+    async def test_a_renamed_archive_is_found_by_its_file_name(self, test_engine, db_session, printer_factory):
+        printer = await printer_factory(name="P")
+        own_id = await self._printing(db_session, printer.id, "Clip")
+        own = await db_session.get(PrintArchive, own_id)
+        own.print_name = "Clip for the desk"
+        await db_session.commit()
+
+        await _complete(test_engine, printer.id, "completed", name="Clip")
+
+        db_session.expire_all()
+        assert (await db_session.get(PrintArchive, own_id)).status == "completed"
+
+    async def test_a_print_started_on_the_printer_reports_its_file_name(self, test_engine, db_session, printer_factory):
+        """The subtask then carries the extensions (#3009)."""
+        printer = await printer_factory(name="P")
+        own_id = await self._printing(db_session, printer.id, "Clip")
+        own = await db_session.get(PrintArchive, own_id)
+        own.filename = "Clip.gcode.3mf"
+        await db_session.commit()
+
+        await _complete(test_engine, printer.id, "completed", name="Clip.gcode.3mf")
+
+        db_session.expire_all()
+        assert (await db_session.get(PrintArchive, own_id)).status == "completed"

+ 135 - 0
backend/tests/integration/test_print_log_one_entry_per_run.py

@@ -0,0 +1,135 @@
+"""One print log entry per run, however often the run is reported.
+
+A run is its archive, printer and start time. A reprint starts at a new time
+and a plate printing on a second printer is a run of its own, so both get
+their own entries; the same run reported again replaces its entry.
+"""
+
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.print_log import PrintLogEntry
+from backend.app.services.print_log import write_log_entry
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+START = datetime(2026, 10, 19, 8, 0)
+
+
+@pytest.fixture
+async def rows(db_session, printer_factory) -> dict:
+    """Two printers and an archive, so the foreign keys hold (PostgreSQL)."""
+    from backend.app.models.archive import PrintArchive
+
+    first, second = await printer_factory(name="A"), await printer_factory(name="B")
+    archive = PrintArchive(
+        printer_id=first.id, filename="a.3mf", file_path="a.3mf", file_size=1, status="printing", started_at=START
+    )
+    db_session.add(archive)
+    await db_session.commit()
+    return {"printer": first.id, "other_printer": second.id, "archive": archive}
+
+
+async def _write(db, rows, *, printer_id=None, started_at=START, status="completed", queue_item_id=None, cost=None):
+    await write_log_entry(
+        db,
+        status=status,
+        archive_id=rows["archive"].id,
+        printer_id=printer_id or rows["printer"],
+        started_at=started_at,
+        completed_at=started_at + timedelta(hours=1),
+        queue_item_id=queue_item_id,
+        cost=cost,
+    )
+    await db.commit()
+
+
+async def _entries(db) -> list[tuple]:
+    rows = (await db.scalars(select(PrintLogEntry).order_by(PrintLogEntry.id))).all()
+    return [(r.printer_id, r.started_at, r.status, r.queue_item_id) for r in rows]
+
+
+async def test_the_same_run_reported_twice_is_one_entry(db_session, rows):
+    await _write(db_session, rows, status="cancelled")
+    await _write(db_session, rows, status="completed")
+
+    assert await _entries(db_session) == [(rows["printer"], START, "completed", None)]
+
+
+async def test_a_reprint_is_an_entry_of_its_own(db_session, rows):
+    await _write(db_session, rows)
+    await _write(db_session, rows, started_at=START + timedelta(days=1))
+
+    assert len(await _entries(db_session)) == 2
+
+
+async def test_a_plate_on_another_printer_is_an_entry_of_its_own(db_session, rows):
+    await _write(db_session, rows)
+    await _write(db_session, rows, printer_id=rows["other_printer"])
+
+    assert len(await _entries(db_session)) == 2
+
+
+async def test_an_aware_start_time_is_the_same_run(db_session, rows):
+    """The stop route holds aware times, the columns store naive UTC."""
+    await _write(db_session, rows, status="cancelled")
+    await _write(db_session, rows, started_at=START.replace(tzinfo=timezone.utc))
+
+    assert [status for _p, _s, status, _q in await _entries(db_session)] == ["completed"]
+
+
+async def test_the_same_outcome_reported_again_changes_nothing(db_session, rows):
+    """A repeat lacks the run's tracked usage; the entry may have been edited
+    or given its energy since."""
+    await _write(db_session, rows, cost=4.20)
+    entry = await db_session.scalar(select(PrintLogEntry))
+    entry.energy_kwh = 0.5
+    entry.failure_reason = "edited"
+    await db_session.commit()
+
+    await _write(db_session, rows, cost=5.00)
+
+    db_session.expire_all()
+    entry = await db_session.scalar(select(PrintLogEntry))
+    assert (entry.cost, entry.energy_kwh, entry.failure_reason) == (4.20, 0.5, "edited")
+
+
+class TestOtherRunsOf:
+    """Whether an archive's card still shows its first run (#1378)."""
+
+    async def _entry(self, db, rows, started_at, printer_id=None):
+        db.add(
+            PrintLogEntry(
+                archive_id=rows["archive"].id,
+                printer_id=printer_id or rows["printer"],
+                status="cancelled",
+                started_at=started_at,
+            )
+        )
+        await db.commit()
+
+    async def test_the_current_run_s_own_entry_is_not_another_run(self, db_session, rows):
+        from backend.app.services.print_log import other_runs_of
+
+        await self._entry(db_session, rows, START)
+
+        assert await other_runs_of(db_session, rows["archive"]) == 0
+
+    async def test_an_earlier_run_or_another_printer_is(self, db_session, rows):
+        from backend.app.services.print_log import other_runs_of
+
+        await self._entry(db_session, rows, START - timedelta(days=1))
+        await self._entry(db_session, rows, START, printer_id=rows["other_printer"])
+
+        assert await other_runs_of(db_session, rows["archive"]) == 2
+
+    async def test_without_a_start_time_every_entry_is(self, db_session, rows):
+        from backend.app.services.print_log import other_runs_of
+
+        await self._entry(db_session, rows, START)
+        rows["archive"].started_at = None
+        await db_session.commit()
+
+        assert await other_runs_of(db_session, rows["archive"]) == 1

+ 2 - 2
backend/tests/unit/services/test_spoolman_print_cost_2591.py

@@ -144,8 +144,6 @@ class TestReportUsagePricesTheArchive:
         def _next_row(*_args, **_kwargs):
             result = MagicMock()
             result.scalar_one_or_none.return_value = next(rows, archive)
-            # The first-run guard counts PrintLogEntry rows.
-            result.scalar.return_value = existing_runs
             return result
 
         db = AsyncMock()
@@ -182,6 +180,8 @@ class TestReportUsagePricesTheArchive:
                     AsyncMock(return_value=None),
                 ),
                 patch("backend.app.services.printer_manager.printer_manager", pm),
+                # The first-run guard: entries of runs other than this one.
+                patch("backend.app.services.print_log.other_runs_of", AsyncMock(return_value=existing_runs)),
             ):
                 await report_usage(printer_id=1, archive_id=7)
 

+ 71 - 0
backend/tests/unit/test_3mf_files_for_print.py

@@ -0,0 +1,71 @@
+"""Which file of a printer directory listing is the starting print's 3MF (SN-C4).
+
+When no direct download path works, print start lists the printer's
+directories and takes the file named for the print. A file that only contains
+the name is another print's ("Cube_large" is not "Cube").
+"""
+
+import pytest
+
+from backend.app.main import _3mf_files_for_print
+
+
+@pytest.mark.parametrize(
+    ("listed", "term", "picked"),
+    [
+        (["Cube_large.gcode.3mf", "Cube.gcode.3mf"], "cube", ["Cube.gcode.3mf"]),
+        (["Cube_large.gcode.3mf", "Cube.3mf"], "cube", ["Cube.3mf"]),
+        # The printer's underscores for spaces
+        (["Cable Clip.gcode.3mf"], "cable_clip", ["Cable Clip.gcode.3mf"]),
+        # No file of its own name: one that contains it is taken only alone
+        (["Cube_v2.gcode.3mf"], "cube", ["Cube_v2.gcode.3mf"]),
+        (["Cube_large.gcode.3mf", "Cube_small.gcode.3mf"], "cube", []),
+        (["Cube.gcode", "notes.txt"], "cube", []),
+        # Across folders: the print's own file in a later folder wins
+        (["/cache/Cube_large.3mf", "/Cube.3mf"], "cube", ["/Cube.3mf"]),
+        (["/cache/Cube.3mf", "/model/Cube.gcode.3mf"], "cube", ["/cache/Cube.3mf", "/model/Cube.gcode.3mf"]),
+        # A folder named like the print does not make its files the print's
+        (["/cube/other.3mf"], "cube", []),
+    ],
+)
+def test_the_print_s_own_file(listed, term, picked):
+    assert _3mf_files_for_print(listed, term) == picked
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_print_start_downloads_only_the_print_s_own_file(test_engine, printer_factory):
+    """Direct paths fail, so print start searches the printer's directories."""
+    from unittest.mock import AsyncMock, patch
+
+    from backend.tests.integration.test_external_print_confirmation_1898 import SUBTASK, _drive_print_start
+
+    printer = await printer_factory()
+    listing = [{"name": f"{SUBTASK}_large.gcode.3mf"}, {"name": f"{SUBTASK}.gcode.3mf"}]
+    listed = patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(return_value=listing))
+
+    mocks = await _drive_print_start(test_engine, printer, download_ok=False, extra_patches=[listed])
+
+    requested = [c.args[2] for c in mocks["download_file_async"].await_args_list]
+    assert "/cache/" + listing[1]["name"] in requested, "the directory search did not try the print's file"
+    assert not [path for path in requested if "_large" in path], requested
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_the_print_s_own_file_in_a_later_folder_wins(test_engine, printer_factory):
+    from unittest.mock import AsyncMock, patch
+
+    from backend.tests.integration.test_external_print_confirmation_1898 import SUBTASK, _drive_print_start
+
+    printer = await printer_factory()
+
+    async def listing(_ip, _code, folder, **_kwargs):
+        return {"/cache": [{"name": f"{SUBTASK}_large.gcode.3mf"}], "/": [{"name": f"{SUBTASK}.3mf"}]}.get(folder, [])
+
+    listed = patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(side_effect=listing))
+
+    mocks = await _drive_print_start(test_engine, printer, download_ok=False, extra_patches=[listed])
+
+    requested = [c.args[2] for c in mocks["download_file_async"].await_args_list]
+    assert not [path for path in requested if "_large" in path], requested

+ 6 - 0
backend/tests/unit/test_cost_tracking.py

@@ -789,6 +789,8 @@ class TestCostAggregation:
         filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": "#FF0000"}]
 
         with (
+            # The first run of this archive
+            patch("backend.app.services.print_log.other_runs_of", AsyncMock(return_value=0)),
             patch("backend.app.core.config.settings") as mock_settings,
             patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
             patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
@@ -872,6 +874,8 @@ class TestCostAggregation:
         filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FF0000"}]
 
         with (
+            # The first run of this archive
+            patch("backend.app.services.print_log.other_runs_of", AsyncMock(return_value=0)),
             patch("backend.app.core.config.settings") as mock_settings,
             patch("backend.app.api.routes.settings.get_setting", return_value="10.0"),
             patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
@@ -955,6 +959,8 @@ class TestCostAggregation:
         filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": "#FF0000"}]
 
         with (
+            # The first run of this archive
+            patch("backend.app.services.print_log.other_runs_of", AsyncMock(return_value=0)),
             patch("backend.app.core.config.settings") as mock_settings,
             patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
             patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),