Bladeren bron

Post-print outcome confirmation: good/reject verdicts, one-tap links, yield stats (#3047)

Thomansky 2 dagen geleden
bovenliggende
commit
44c7e6fb39
75 gewijzigde bestanden met toevoegingen van 5753 en 75 verwijderingen
  1. 270 1
      backend/app/api/routes/archives.py
  2. 6 0
      backend/app/api/routes/library.py
  3. 4 0
      backend/app/api/routes/notifications.py
  4. 5 0
      backend/app/api/routes/pipeline_runs.py
  5. 2 0
      backend/app/api/routes/print_queue.py
  6. 15 0
      backend/app/api/routes/printers.py
  7. 21 4
      backend/app/api/routes/projects.py
  8. 20 8
      backend/app/api/routes/settings.py
  9. 4 0
      backend/app/api/routes/webhook.py
  10. 65 0
      backend/app/core/database.py
  11. 10 0
      backend/app/core/websocket.py
  12. 223 0
      backend/app/main.py
  13. 35 0
      backend/app/models/archive.py
  14. 5 0
      backend/app/models/notification.py
  15. 14 0
      backend/app/models/notification_template.py
  16. 4 0
      backend/app/models/print_log.py
  17. 3 0
      backend/app/models/print_queue.py
  18. 14 0
      backend/app/schemas/archive.py
  19. 9 0
      backend/app/schemas/notification.py
  20. 1 0
      backend/app/schemas/print_log.py
  21. 5 0
      backend/app/schemas/print_queue.py
  22. 21 0
      backend/app/schemas/settings.py
  23. 26 0
      backend/app/services/failure_analysis.py
  24. 239 35
      backend/app/services/notification_service.py
  25. 1 0
      backend/app/services/print_batch.py
  26. 193 0
      backend/app/services/print_confirmation.py
  27. 18 0
      backend/app/services/print_scheduler.py
  28. 10 0
      backend/app/services/virtual_printer/manager.py
  29. 128 0
      backend/tests/integration/test_confirm_outcome_queue_defaults_1898.py
  30. 562 0
      backend/tests/integration/test_external_print_confirmation_1898.py
  31. 605 0
      backend/tests/integration/test_print_confirmation.py
  32. 323 0
      backend/tests/unit/test_confirm_link_unattended_fetch_1898.py
  33. 226 0
      backend/tests/unit/test_confirm_token_retirement_migration_1898.py
  34. 1 0
      backend/tests/unit/test_print_log.py
  35. 6 0
      backend/tests/unit/test_route_auth_coverage.py
  36. 103 0
      backend/tests/unit/test_telegram_outcome_buttons_1898.py
  37. 179 0
      frontend/src/__tests__/components/ConfirmOutcomeDialog.test.tsx
  38. 121 0
      frontend/src/__tests__/components/EditArchiveModal.test.tsx
  39. 154 1
      frontend/src/__tests__/components/Layout.test.tsx
  40. 87 0
      frontend/src/__tests__/components/PrintModal.test.tsx
  41. 68 0
      frontend/src/__tests__/pages/ArchivesPage.test.tsx
  42. 188 0
      frontend/src/__tests__/pages/PrintersPageCardVerdict.test.tsx
  43. 70 0
      frontend/src/__tests__/pages/QueuePage.test.tsx
  44. 87 0
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  45. 40 0
      frontend/src/api/client.ts
  46. 12 0
      frontend/src/components/AddNotificationModal.tsx
  47. 208 0
      frontend/src/components/ConfirmOutcomeDialog.tsx
  48. 52 5
      frontend/src/components/EditArchiveModal.tsx
  49. 56 1
      frontend/src/components/Layout.tsx
  50. 14 0
      frontend/src/components/NotificationProviderCard.tsx
  51. 2 0
      frontend/src/components/PrintModal/PrintOptions.tsx
  52. 21 1
      frontend/src/components/PrintModal/index.tsx
  53. 3 0
      frontend/src/components/PrintModal/types.ts
  54. 11 0
      frontend/src/hooks/useWebSocket.ts
  55. 56 0
      frontend/src/i18n/locales/de.ts
  56. 56 0
      frontend/src/i18n/locales/en.ts
  57. 56 0
      frontend/src/i18n/locales/es.ts
  58. 56 0
      frontend/src/i18n/locales/fr.ts
  59. 56 0
      frontend/src/i18n/locales/it.ts
  60. 56 0
      frontend/src/i18n/locales/ja.ts
  61. 56 1
      frontend/src/i18n/locales/ko.ts
  62. 56 0
      frontend/src/i18n/locales/nl.ts
  63. 56 0
      frontend/src/i18n/locales/pt-BR.ts
  64. 55 0
      frontend/src/i18n/locales/ru.ts
  65. 56 0
      frontend/src/i18n/locales/sv.ts
  66. 56 0
      frontend/src/i18n/locales/tr.ts
  67. 56 0
      frontend/src/i18n/locales/uk.ts
  68. 56 0
      frontend/src/i18n/locales/zh-CN.ts
  69. 56 0
      frontend/src/i18n/locales/zh-TW.ts
  70. 153 3
      frontend/src/pages/ArchivesPage.tsx
  71. 88 14
      frontend/src/pages/PrintersPage.tsx
  72. 14 1
      frontend/src/pages/QueuePage.tsx
  73. 49 0
      frontend/src/pages/SettingsPage.tsx
  74. 13 0
      frontend/src/pages/StatsPage.tsx
  75. 27 0
      frontend/src/utils/verdictSource.ts

+ 270 - 1
backend/app/api/routes/archives.py

@@ -7,6 +7,7 @@ import zipfile
 from collections import defaultdict
 from datetime import date, datetime, time, timedelta, timezone
 from decimal import ROUND_HALF_UP, Decimal
+from html import escape as html_escape
 from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
@@ -39,6 +40,12 @@ from backend.app.services.archive import ArchiveService
 from backend.app.services.bambu_ftp import ftps_handshake_blocked, list_files_result_async
 from backend.app.services.design_settings import overrides_from_config
 from backend.app.services.filament_requirements import annotate_rack_groups
+from backend.app.services.print_confirmation import (
+    is_one_tap_request,
+    is_unattended_fetch,
+    retire_confirm_token,
+    stamp_verdict,
+)
 from backend.app.services.print_storage import (
     REASON_FTP_TRANSFER_FAILED,
     REASON_FTPS_COOLOFF,
@@ -366,6 +373,14 @@ def archive_to_response(
         "cost": archive.cost,
         "photos": archive.photos,
         "failure_reason": archive.failure_reason,
+        # Post-print outcome confirmation (#1898). confirm_token stays
+        # server-side — it is a capability and never belongs in a response.
+        "user_verdict": archive.user_verdict,
+        "user_verdict_source": archive.user_verdict_source,
+        "user_verdict_at": archive.user_verdict_at,
+        # bool() because the column is nullable to match the migration; the
+        # response contract stays a strict bool either way.
+        "confirm_requested": bool(archive.confirm_requested),
         "quantity": archive.quantity,
         "energy_kwh": archive.energy_kwh,
         "energy_cost": archive.energy_cost,
@@ -1762,9 +1777,23 @@ async def update_archive(
     previous_filament_grams = archive.filament_used_grams
 
     update_payload = update_data.model_dump(exclude_unset=True)
+    # #1898: how the verdict arrived is recorded with it, never on its own.
+    verdict_source = update_payload.pop("user_verdict_source", None)
     for field, value in update_payload.items():
         setattr(archive, field, value)
 
+    # #1898: a landed verdict retires the one-tap capability token from the
+    # push notification — the links stop changing anything once someone
+    # decided, and report the recorded verdict instead. Clearing the verdict
+    # drops the provenance but leaves the token spent: it was used.
+    if "user_verdict" in update_payload:
+        if update_payload["user_verdict"] is None:
+            archive.user_verdict_source = None
+            archive.user_verdict_at = None
+        else:
+            stamp_verdict(archive, verdict_source or "api")
+            retire_confirm_token(archive)
+
     # #1444: Mirror per-run classification fields to the most recent
     # PrintLogEntry for this archive. PrintLogEntry.failure_reason is captured
     # once at print-completion time from archive.failure_reason — which is
@@ -1780,7 +1809,7 @@ async def update_archive(
     # ENTRY's grams, not the archive's, so correcting only the archive would fix
     # the card and leave every aggregate reading the old figure -- or, for a
     # print that archived without its 3MF, no figure at all.
-    mirror_fields = {"failure_reason", "status", "filament_used_grams"}
+    mirror_fields = {"failure_reason", "status", "filament_used_grams", "user_verdict"}
     to_mirror = {k: v for k, v in update_payload.items() if k in mirror_fields}
     if to_mirror:
         from backend.app.models.print_log import PrintLogEntry
@@ -3394,6 +3423,246 @@ async def delete_photo(
     return {"status": "deleted", "photos": archive.photos}
 
 
+# ============================================
+# Post-print outcome confirmation (#1898)
+# ============================================
+
+# English-only on purpose: this page is rendered by the backend for a phone
+# browser that carries no session and therefore no language preference.
+_VERDICT_LABELS = {"good": "Good part", "reject": "Rejected"}
+_VERDICT_SOURCE_PHRASES = {
+    "dialog": "in the app",
+    "link": "with a one-tap link",
+    "plate_clear": "automatically when the print plate was cleared",
+    "printer_card": "from the printer card",
+    "api": "through the API",
+    "reaction": "with a reaction in chat",
+}
+
+
+def _confirm_page(glyph: str, heading: str, body: str) -> str:
+    """The small HTML page every one-tap outcome link renders."""
+    return (
+        "<!doctype html><html><head><meta name='viewport' content='width=device-width, initial-scale=1'>"
+        "<title>Bambuddy</title></head>"
+        "<body style='font-family: system-ui, sans-serif; background:#1a1d21; color:#fff; display:flex;"
+        " align-items:center; justify-content:center; min-height:90vh; margin:0'>"
+        f"<div style='text-align:center; padding:0 1.5rem; max-width:32rem'>"
+        f"<div style='font-size:3rem'>{glyph}</div><h2>{heading}</h2>{body}</div></body></html>"
+    )
+
+
+async def _render_already_answered_page(db: AsyncSession, archive: PrintArchive) -> str:
+    """Explain a spent one-tap link instead of calling it invalid.
+
+    The plate-clear default, the app and the other link all answer the same
+    prompt, so the button in a push notification is routinely tapped after the
+    question is settled. Report the verdict on file, when and how it landed,
+    and where to change it.
+    """
+    from backend.app.api.routes.settings import get_external_base_url
+
+    name = html_escape(archive.print_name or archive.filename or "")
+    # The verdict's own timestamp, not the moment the token was spent: a
+    # verdict changed later in the app would otherwise be dated by the older
+    # event. Pre-#1898 rows have neither, and then the page omits the date.
+    used_at = archive.user_verdict_at or archive.confirm_token_used_at
+    if used_at is not None and used_at.tzinfo is not None:
+        used_at = used_at.astimezone(timezone.utc)
+    when = used_at.strftime("%Y-%m-%d %H:%M UTC") if used_at else None
+    how = _VERDICT_SOURCE_PHRASES.get(archive.user_verdict_source or "")
+    label = _VERDICT_LABELS.get(archive.user_verdict or "")
+
+    if label:
+        recorded = f"Recorded as <strong>{label}</strong>"
+        if how:
+            recorded += f" {how}"
+        if when:
+            recorded += f" on {when}"
+        recorded += "."
+    else:
+        # The verdict was cleared again in the app; the link stays spent.
+        recorded = "This prompt was already answered and the verdict has since been cleared."
+
+    base = await get_external_base_url(db)
+    link = html_escape(f"{base}/archives?confirm={archive.id}", quote=True)
+    glyph = "&#10003;" if archive.user_verdict == "good" else "&#10007;" if archive.user_verdict else "&#8505;"
+    return _confirm_page(
+        glyph,
+        "Already answered",
+        f"<p style='color:#9ca3af'>{name}</p>"
+        f"<p style='color:#9ca3af'>{recorded}</p>"
+        f"<p><a style='color:#00ae42' href='{link}'>Open this print in Bambuddy</a> to change it.</p>",
+    )
+
+
+def _render_confirm_prompt_page(request: Request, archive: PrintArchive, verdict: str) -> str:
+    """The page a one-tap verdict link opens. It has recorded nothing yet.
+
+    GET is where link unfurlers, mail-security scanners and browser prefetchers
+    arrive, uninvited and within seconds of the message being sent, so GET
+    writes nothing at all -- the verdict is recorded by the form below, over
+    POST, which none of them issue.
+
+    The form submits itself only for a page opened from a notification button,
+    which is what keeps the operator at one tap. The marker for that
+    (``?tap=1``) is put on the Telegram inline keyboard's URLs and nowhere else
+    -- never on a URL that travels in message text -- so a mail-security
+    sandbox that renders HTML and runs JavaScript cannot press the button for
+    the operator: it only ever sees the unmarked URL out of the body. The
+    User-Agent heuristic still runs on top of that, but it is no longer the
+    only thing between a scanner and the write; it cannot be, because such a
+    sandbox sends an ordinary Chrome string.
+
+    Every other arrival -- an unmarked link somebody typed or mailed, a browser
+    with JavaScript off -- gets the same page and presses the button.
+    """
+    name = html_escape(archive.print_name or archive.filename or "")
+    label = _VERDICT_LABELS.get(verdict, verdict)
+    # No action attribute: the form posts back to the URL the page was loaded
+    # from, so it works behind a reverse proxy and on a host external_url does
+    # not name.
+    form = (
+        "<form method='post' id='confirm-form'>"
+        "<button type='submit' style='font: inherit; font-size:1.1rem; padding:0.9rem 2rem;"
+        " border:0; border-radius:0.5rem; background:#00ae42; color:#fff'>Yes, record it</button>"
+        "</form>"
+    )
+    # The SPA's CSP allows inline scripts only with the per-request nonce the
+    # security-headers middleware mints (main.py). Without one -- no middleware,
+    # an unmarked URL, or an unattended-looking caller -- the page simply waits
+    # for the button.
+    nonce = getattr(request.state, "csp_nonce", None)
+    script = ""
+    if nonce and is_one_tap_request(request.query_params) and not is_unattended_fetch(request.method, request.headers):
+        script = (
+            f"<script nonce='{html_escape(nonce, quote=True)}'>"
+            "document.getElementById('confirm-form').submit();</script>"
+        )
+    return _confirm_page(
+        "&#63;",
+        "Confirm this outcome",
+        f"<p style='color:#9ca3af'>{name}</p>"
+        f"<p style='color:#9ca3af'>Record this print as <strong>{label}</strong>?</p>"
+        f"{form}{script}",
+    )
+
+
+def _confirm_response(html: str):
+    """The one-tap pages, never cached.
+
+    A proxy holding on to "Saved" or to the prompt would answer a later tap
+    from its cache, and the prompt page is a capability URL either way.
+    """
+    from fastapi.responses import HTMLResponse
+
+    return HTMLResponse(html, headers={"Cache-Control": "no-store"})
+
+
+async def _load_confirmable_archive(db: AsyncSession, token: str, verdict: str) -> PrintArchive:
+    """Resolve a one-tap capability token, or raise the route's 400/404."""
+    if verdict not in ("good", "reject"):
+        raise HTTPException(400, "Verdict must be 'good' or 'reject'")
+
+    result = await db.execute(
+        select(PrintArchive).where(PrintArchive.confirm_token == token, PrintArchive.confirm_token.isnot(None))
+    )
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Confirmation link is invalid or was already used")
+    return archive
+
+
+@router.get("/confirm/{token}/{verdict}")
+async def confirm_outcome_page(
+    request: Request,
+    token: str,
+    verdict: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Open a one-tap verdict link. Reads only — the verdict is recorded by POST.
+
+    This used to be the route that recorded, and that was the defect: a GET
+    that changes state is answered by everything that walks a URL. Telegram
+    and Slack fetch the links in a message body to build a preview card, mail
+    gateways detonate them before delivery, browsers prefetch them — any one
+    of those spent the single-use token and settled the outcome before the
+    operator had read the question, always in the "good" direction because
+    good_url came first. Suppressing previews per channel does not cover the
+    proxies and scanners in between; only removing the write from GET does.
+
+    So GET now hands back the prompt page and nothing else. The page's form
+    POSTs to this same URL, and :func:`confirm_outcome_by_token` records it.
+    The human cost is zero for the path the feature is built around: a URL
+    opened from a notification button carries ``?tap=1`` and the page submits
+    itself, so that tap is still the only tap. Nothing else gets that script,
+    including a scanner that runs JavaScript -- the marker is on the buttons,
+    not in the message text a scanner reads.
+    """
+    archive = await _load_confirmable_archive(db, token, verdict)
+
+    if archive.confirm_token_used_at is not None:
+        # Answered already — by hand, by the other link, by the plate-clear
+        # default or by a reaction. Report what is on file and change nothing.
+        return _confirm_response(await _render_already_answered_page(db, archive))
+
+    return _confirm_response(_render_confirm_prompt_page(request, archive, verdict))
+
+
+@router.post("/confirm/{token}/{verdict}")
+async def confirm_outcome_by_token(
+    token: str,
+    verdict: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Record a print-outcome verdict via the capability token from a push notification.
+
+    Deliberately unauthenticated: the token IS the credential. It is a 256-bit
+    per-archive capability, minted when the confirmation prompt fires, and it
+    only ever grants writing good/reject on that one archive, exactly once.
+    "Exactly once" is enforced by ``confirm_token_used_at`` rather than by
+    dropping the token value, so a link for a print that was already answered
+    can be recognised and explained instead of looking broken.
+
+    POST is what keeps the capability the operator's: unfurlers, scanners and
+    prefetchers issue GET, and the GET route above writes nothing. The two
+    callers that reach here are the prompt page's form and the ntfy action
+    button, which performs its own request from the phone and is configured
+    with ``method=POST``. Returns a small HTML page for the phone browser.
+    """
+    archive = await _load_confirmable_archive(db, token, verdict)
+
+    if archive.confirm_token_used_at is not None:
+        return _confirm_response(await _render_already_answered_page(db, archive))
+
+    archive.user_verdict = verdict
+    stamp_verdict(archive, "link")
+    retire_confirm_token(archive)
+
+    # Same mirror as the PATCH route (#1444): verdict-aware statistics read
+    # print_log_entries, so the latest run must carry the verdict too.
+    from backend.app.models.print_log import PrintLogEntry
+
+    latest_entry = await db.scalar(
+        select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc()).limit(1)
+    )
+    if latest_entry is not None:
+        latest_entry.user_verdict = verdict
+
+    await db.commit()
+
+    label = "Good part" if verdict == "good" else "Rejected"
+    name = archive.print_name or archive.filename
+    return _confirm_response(
+        _confirm_page(
+            "&#10003;" if verdict == "good" else "&#10007;",
+            label,
+            f"<p style='color:#9ca3af'>{html_escape(name)}</p>"
+            "<p style='color:#9ca3af'>Saved &mdash; you can close this page.</p>",
+        )
+    )
+
+
 # ============================================
 # QR Code Endpoint
 # ============================================

+ 6 - 0
backend/app/api/routes/library.py

@@ -78,6 +78,7 @@ from backend.app.services.design_settings import (
 from backend.app.services.filament_requirements import annotate_rack_groups
 from backend.app.services.pdf_thumbnail import generate_pdf_thumbnail
 from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
+from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
 from backend.app.services.process_overrides import apply_process_overrides
 from backend.app.services.slice_output_check import (
     missing_start_gcode_message,
@@ -2972,6 +2973,10 @@ async def add_files_to_queue(
     pos_result = await db.execute(select(func.coalesce(func.max(PrintQueueItem.position), 0)))
     max_position = pos_result.scalar() or 0
 
+    # There is no per-job ask-for-outcome toggle on a bulk add, so the rows take
+    # the same default the print dialog seeds itself from (#1898).
+    confirm_outcome = await confirm_outcome_for_new_queue_item(db)
+
     for file_id in request.file_ids:
         lib_file = files.get(file_id)
 
@@ -3065,6 +3070,7 @@ async def add_files_to_queue(
                 or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None),
                 position=max_position,
                 status="pending",
+                confirm_outcome=confirm_outcome,
                 # Without this the row is ownerless, and `queue:read_own` filters
                 # on `created_by_id` — so the user who queued the file could not
                 # see it in their own queue.

+ 4 - 0
backend/app/api/routes/notifications.py

@@ -66,6 +66,8 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         # Build plate detection
         "on_plate_not_empty": provider.on_plate_not_empty,
         "on_plate_clear_required": provider.on_plate_clear_required,
+        # Post-print outcome confirmation (#1898)
+        "on_print_confirm_request": provider.on_print_confirm_request,
         # Bed cooled
         "on_bed_cooled": provider.on_bed_cooled,
         # First layer complete
@@ -158,6 +160,8 @@ async def create_notification_provider(
         # Build plate detection
         on_plate_not_empty=provider_data.on_plate_not_empty,
         on_plate_clear_required=provider_data.on_plate_clear_required,
+        # Post-print outcome confirmation (#1898)
+        on_print_confirm_request=provider_data.on_print_confirm_request,
         # Bed cooled
         on_bed_cooled=provider_data.on_bed_cooled,
         # First layer complete

+ 5 - 0
backend/app/api/routes/pipeline_runs.py

@@ -61,6 +61,7 @@ from backend.app.services.pipeline_eligibility import (
     EligibilityReport,
     check_pipeline_eligibility,
 )
+from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
 
 logger = logging.getLogger(__name__)
 
@@ -571,6 +572,9 @@ def _make_orchestration_callable(
             if len(jobs) != copies:
                 logger.warning("pipeline_run %d expected %d jobs, found %d", run_id, copies, len(jobs))
 
+            # No per-job ask-for-outcome toggle on a pipeline run either (#1898).
+            confirm_outcome = await confirm_outcome_for_new_queue_item(session)
+
             for job, (printer_id, target_model) in zip(jobs, assignments, strict=False):
                 queue_item = PrintQueueItem(
                     printer_id=printer_id,
@@ -578,6 +582,7 @@ def _make_orchestration_callable(
                     library_file_id=slice_response.library_file_id,
                     created_by_id=creator_user_id,
                     status="pending",
+                    confirm_outcome=confirm_outcome,
                 )
                 session.add(queue_item)
                 await session.flush()

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

@@ -472,6 +472,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "timelapse": item.timelapse,
         "use_ams": item.use_ams,
         "nozzle_offset_cali": item.nozzle_offset_cali,
+        "confirm_outcome": item.confirm_outcome,
         "preheat_override": item.preheat_override,
         "preheat_chamber_target_override": item.preheat_chamber_target_override,
         "status": item.status,
@@ -1193,6 +1194,7 @@ async def add_to_queue(
             timelapse=data.timelapse,
             use_ams=data.use_ams,
             nozzle_offset_cali=data.nozzle_offset_cali,
+            confirm_outcome=data.confirm_outcome,
             preheat_override=data.preheat_override,
             preheat_chamber_target_override=data.preheat_chamber_target_override,
             gcode_injection=data.gcode_injection,

+ 15 - 0
backend/app/api/routes/printers.py

@@ -3537,6 +3537,21 @@ async def clear_plate(
 
     printer_manager.set_awaiting_plate_clear(printer_id, False)
 
+    # #1898: releasing the plate without answering the outcome prompt can
+    # count as "good" (opt-in setting) — this is the moment the operator
+    # moves on, so an unanswered prompt would otherwise linger unconfirmed.
+    from backend.app.api.routes.settings import get_setting, setting_is_true
+
+    # setting_is_true rather than a comparison of our own: one reader deciding
+    # for itself what "on" spells is how two parts of the app end up
+    # disagreeing about the same row.
+    if setting_is_true(await get_setting(db, "confirm_default_good_on_plate_clear")):
+        from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good
+
+        resolved = await resolve_pending_confirmation_as_good(db, printer_id)
+        if resolved is not None:
+            await db.commit()
+
     return {"success": True, "message": "Plate cleared, next print will start shortly"}
 
 

+ 21 - 4
backend/app/api/routes/projects.py

@@ -11,7 +11,7 @@ from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
 from fastapi.responses import FileResponse, StreamingResponse
-from sqlalchemy import case, func, select, update
+from sqlalchemy import and_, case, func, or_, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
@@ -54,6 +54,12 @@ router = APIRouter(prefix="/projects", tags=["projects"])
 
 _FAILURE_STATUSES = ("failed", "aborted", "cancelled", "stopped")
 
+# A completed run whose user verdict is 'reject' (#1898) finished on the
+# machine but produced scrap — everywhere a project counts good parts, that
+# run must not contribute. NULL verdict (never asked / not answered) counts
+# as good, matching behaviour before the feature existed.
+_NOT_REJECTED = or_(PrintLogEntry.user_verdict.is_(None), PrintLogEntry.user_verdict != "reject")
+
 # Soft-deleted archives (#1343) keep their row — and therefore their
 # ``project_id`` — after their files have been removed from disk, so that global
 # Quick Stats can still count their filament / time / cost. Nothing in this
@@ -140,8 +146,12 @@ async def _load_totals(db: AsyncSession, project_ids: Sequence[int]) -> dict[int
             func.coalesce(func.sum(PrintLogEntry.energy_kwh), 0).label("total_energy"),
             func.coalesce(func.sum(PrintLogEntry.energy_cost), 0).label("total_energy_cost"),
             func.coalesce(func.sum(PrintArchive.quantity), 0).label("total_items"),
+            # A completed run the user marked as reject (#1898) produced no
+            # usable parts — keep it out of the good-parts count.
             func.coalesce(
-                func.sum(case((PrintLogEntry.status == "completed", PrintArchive.quantity), else_=0)),
+                func.sum(
+                    case((and_(PrintLogEntry.status == "completed", _NOT_REJECTED), PrintArchive.quantity), else_=0)
+                ),
                 0,
             ).label("completed_items"),
             func.coalesce(
@@ -423,7 +433,9 @@ async def list_projects(
                 func.count(PrintLogEntry.id).label("archive_count"),
                 func.coalesce(func.sum(PrintArchive.quantity), 0).label("total_items"),
                 func.coalesce(
-                    func.sum(case((PrintLogEntry.status == "completed", PrintArchive.quantity), else_=0)),
+                    func.sum(
+                        case((and_(PrintLogEntry.status == "completed", _NOT_REJECTED), PrintArchive.quantity), else_=0)
+                    ),
                     0,
                 ).label("completed_count"),
                 func.coalesce(
@@ -1009,7 +1021,12 @@ async def get_project_file_progress(
             func.count(PrintLogEntry.id),
         )
         .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
-        .where(PrintArchive.project_id == project_id, PrintLogEntry.status == "completed", _LIVE_ARCHIVE)
+        .where(
+            PrintArchive.project_id == project_id,
+            PrintLogEntry.status == "completed",
+            _NOT_REJECTED,
+            _LIVE_ARCHIVE,
+        )
         .group_by(PrintArchive.library_file_id, PrintArchive.content_hash, PrintArchive.filename)
     )
 

+ 20 - 8
backend/app/api/routes/settings.py

@@ -129,6 +129,22 @@ def normalize_str_setting(key: str, value: object) -> str:
     raise HTTPException(400, f"{key} must be a string; got {type(value).__name__}")
 
 
+async def get_external_base_url(db: AsyncSession) -> str:
+    """Base URL for links Bambuddy hands to the outside world (no trailing slash).
+
+    ``external_url`` is optional and has no default, so anything that must be
+    absolute — a login link in an e-mail, the one-tap outcome verdict links
+    (#1898), whose Telegram/ntfy buttons are dropped for a relative URL — falls
+    back to APP_URL and finally to the dev origin.
+    """
+    import os
+
+    external_url = await get_setting(db, "external_url")
+    if external_url:
+        return external_url.rstrip("/")
+    return os.environ.get("APP_URL", "http://localhost:5173").rstrip("/")
+
+
 async def get_external_login_url(db: AsyncSession) -> str:
     """Get the external URL for the login page.
 
@@ -140,14 +156,7 @@ async def get_external_login_url(db: AsyncSession) -> str:
     Returns:
         Full URL to the login page
     """
-    import os
-
-    external_url = await get_setting(db, "external_url")
-    if external_url:
-        external_url = external_url.rstrip("/")
-    else:
-        external_url = os.environ.get("APP_URL", "http://localhost:5173")
-    return external_url + "/login"
+    return await get_external_base_url(db) + "/login"
 
 
 async def set_setting(db: AsyncSession, key: str, value: str) -> None:
@@ -199,6 +208,9 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "default_vibration_cali",
             "default_layer_inspect",
             "default_timelapse",
+            "default_confirm_outcome",
+            "confirm_outcome_external_prints",
+            "confirm_default_good_on_plate_clear",
             "billing_enabled",
             "printer_kill_switch_enabled",
             "ldap_enabled",

+ 4 - 0
backend/app/api/routes/webhook.py

@@ -11,6 +11,7 @@ from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
+from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
 from backend.app.services.printer_manager import printer_manager
 
 logger = logging.getLogger(__name__)
@@ -115,6 +116,9 @@ async def webhook_add_to_queue(
         scheduled_time=scheduled_time,
         require_previous_success=data.require_previous_success,
         auto_off_after=data.auto_off_after,
+        # No dialog to pick this per job, so the install-wide default decides
+        # whether the finished print asks for a verdict (#1898).
+        confirm_outcome=await confirm_outcome_for_new_queue_item(db),
         # Attribute to the key's owner so the item shows up under `queue:read_own`
         # for the person whose key it is. Legacy keys predating per-user ownership
         # have no `user_id`, and those rows stay ownerless.

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

@@ -5022,6 +5022,45 @@ async def run_migrations(conn):
         conn, "ALTER TABLE notification_providers ADD COLUMN on_location_ha_sensor_alert BOOLEAN DEFAULT FALSE"
     )
 
+    # Migration: post-print outcome confirmation (#1898). VARCHAR and the
+    # BOOLEAN DEFAULT FALSE/TRUE spellings are identical on SQLite and
+    # Postgres (see the on_ha_sensor_alert note above for why not DEFAULT 0).
+    await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN user_verdict VARCHAR(10)")
+    await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN confirm_requested BOOLEAN DEFAULT FALSE")
+    await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN confirm_token VARCHAR(64)")
+    await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN user_verdict_source VARCHAR(16)")
+    # ``DATETIME`` is a SQLite-only alias; PostgreSQL rejects it and
+    # _safe_execute would swallow the error, leaving the column missing.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN confirm_token_used_at DATETIME")
+    else:
+        await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN confirm_token_used_at TIMESTAMP")
+    # When the verdict on file was recorded (#1898): the "already answered"
+    # page dates the verdict by this, not by the moment the one-tap token was
+    # spent, so a verdict changed later in the app reads correctly.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN user_verdict_at DATETIME")
+    else:
+        await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN user_verdict_at TIMESTAMP")
+    await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN user_verdict VARCHAR(10)")
+    await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN confirm_outcome BOOLEAN DEFAULT FALSE")
+    await _safe_execute(
+        conn, "ALTER TABLE notification_providers ADD COLUMN on_print_confirm_request BOOLEAN DEFAULT TRUE"
+    )
+    # The one-tap verdict route looks archives up by this token and runs with no
+    # authentication, so an upgraded install needs the index too — without it
+    # every tap, and every unauthenticated request carrying a bogus token, is a
+    # sequential scan of print_archives.
+    await _safe_execute(
+        conn,
+        "CREATE UNIQUE INDEX IF NOT EXISTS ix_print_archives_confirm_token ON print_archives (confirm_token)",
+    )
+    # Migration: take the capability URLs out of the outcome prompt's body
+    # (#1898). Seeding only ever inserts a template that is missing, so an
+    # install that already ran an earlier build of this feature would keep
+    # sending the verdict links as body text for every channel.
+    await _migrate_confirm_prompt_body_template(conn)
+
     # Migration: rename the ha_sensor_alert template (#2824). "Home Assistant
     # Sensor Alert" was fine as a name while it was the only such template;
     # next to the new "Storage Location Sensor Alert" it no longer says which
@@ -5057,6 +5096,32 @@ async def run_migrations(conn):
     )
 
 
+async def _migrate_confirm_prompt_body_template(conn) -> None:
+    """Replace the one-tap verdict URLs in the outcome prompt's body (#1898).
+
+    The first shape of this template put ``{good_url}`` and ``{reject_url}``
+    into the message body, where a link unfurler, a mail gateway or a proxy
+    reaches them and spends the single-use token before the operator has read
+    the question. The body now carries ``{confirm_url}``, which only opens the
+    archive in Bambuddy; the capability links travel in the ntfy action buttons
+    and the Telegram inline keyboard instead.
+
+    Rewrites only a body that is still the old default verbatim — an admin who
+    edited the template keeps their own text. Same shape as the two template
+    renames below.
+    """
+    from sqlalchemy import text
+
+    await conn.execute(
+        text("UPDATE notification_templates SET body_template = :new WHERE event_type = :et AND body_template = :old"),
+        {
+            "new": "{printer}: {filename}\nConfirm: {confirm_url}",
+            "et": "print_confirm_request",
+            "old": "{printer}: {filename}\nGood: {good_url}\nReject: {reject_url}",
+        },
+    )
+
+
 async def _migrate_rename_ha_sensor_alert_template(conn) -> None:
     """Rename the ha_sensor_alert template to "Printer Sensor Alert" (#2824).
 

+ 10 - 0
backend/app/core/websocket.py

@@ -109,6 +109,16 @@ class ConnectionManager:
             }
         )
 
+    async def send_print_confirm_request(self, printer_id: int, data: dict):
+        """Ask connected clients for a post-print outcome verdict (#1898)."""
+        await self.broadcast(
+            {
+                "type": "print_confirm_request",
+                "printer_id": printer_id,
+                "data": data,
+            }
+        )
+
     async def send_archive_created(self, archive: dict):
         """Notify clients that a new archive was created."""
         await self.broadcast(

+ 223 - 0
backend/app/main.py

@@ -3397,6 +3397,159 @@ def _schedule_fallback_3mf_retry(
     )
 
 
+async def _ask_outcome_for_external_print(db, printer_id: int, observed_name: str | None = None) -> bool:
+    """Whether an archive created here should ask for the print's outcome (#1898).
+
+    Only a print Bambuddy did not dispatch reaches the archive-*creating*
+    branches below: a queued job already has its archive and takes the
+    expected-print branch, where the queue item's own ``confirm_outcome``
+    decides. The queue is still consulted, because a restart mid-print empties
+    ``_expected_prints`` — without the check the setting could override a queue
+    item that deliberately has the flag off. Any failure answers "don't ask":
+    an unwanted prompt is worse than a missing one, and this must never be the
+    reason a print goes unarchived.
+
+    A ``printing`` row is not proof on its own, though: Bambuddy deliberately
+    leaves one behind when a completion cannot be matched to it
+    (``_completion_belongs_to_queue_item``), and the scheduler's stranded sweep
+    only takes it back once the printer has sat connected and terminal for
+    minutes. Until then every screen-started print on that printer would
+    silently lose its prompt, so the row is held against ``observed_name`` by
+    the same comparison a completion uses: a positive disagreement means the row
+    is about some other run and this print is external after all.
+    """
+    logger = logging.getLogger(__name__)
+    try:
+        from backend.app.api.routes.settings import get_setting, setting_is_true
+
+        if not setting_is_true(await get_setting(db, "confirm_outcome_external_prints")):
+            return False
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        dispatched_here = await db.scalar(
+            select(PrintQueueItem)
+            .where(
+                PrintQueueItem.printer_id == printer_id,
+                PrintQueueItem.status == "printing",
+            )
+            .limit(1)
+        )
+        if dispatched_here is None:
+            return True
+
+        expected = await _queue_item_dispatched_name(db, dispatched_here)
+        observed = (observed_name or "").strip()
+        if not expected or not observed or _subtask_names_match(expected, observed):
+            logger.info(
+                "[CALLBACK] Not asking for the outcome on printer %s: queue item %s is still printing, so "
+                "Bambuddy dispatched this run and the item's own ask-for-outcome flag decides.",
+                printer_id,
+                dispatched_here.id,
+            )
+            return False
+
+        logger.info(
+            "[CALLBACK] Queue item %s is still marked printing on printer %s but was dispatched as %r, not "
+            "%r; treating this as an externally started print.",
+            dispatched_here.id,
+            printer_id,
+            expected,
+            observed,
+        )
+        return True
+    except Exception as e:
+        logger.warning("[CALLBACK] Could not decide the outcome prompt for printer %s: %s", printer_id, e)
+        # A failed statement deactivates the transaction, so without this the
+        # caller's own add()/commit() would raise PendingRollbackError and the
+        # print would go unarchived over a question that answers "no".
+        try:
+            await db.rollback()
+        except Exception:
+            pass
+        return False
+
+
+async def dispatch_outcome_confirmation(
+    db,
+    printer_id: int,
+    printer_name: str,
+    data: dict,
+    archive_id: int,
+    archive_data: dict | None = None,
+) -> bool:
+    """Emit the post-print outcome prompt for a completed archive (#1898).
+
+    Gated on the archive itself: ``confirm_requested`` is the opt-in (from the
+    queue item, or from ``confirm_outcome_external_prints`` for a print
+    Bambuddy did not start) and ``user_verdict`` being unset is what makes the
+    question still open. Mints the per-archive capability token the one-tap
+    verdict links carry.
+
+    Lives here rather than inline in ``on_print_complete``'s notification task
+    because that task swallows every exception: extracted, the gate and the two
+    emissions can be driven by a test, which is the only thing standing between
+    a regression here and a farm that quietly stops asking.
+
+    Returns whether a prompt was sent.
+    """
+    logger = logging.getLogger(__name__)
+    from backend.app.models.archive import PrintArchive
+
+    confirm_archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
+    if not (confirm_archive and confirm_archive.confirm_requested and confirm_archive.user_verdict is None):
+        return False
+
+    import secrets as _secrets
+
+    # A spent token stays on the row so the one-tap route can recognise it, so
+    # "has a token" no longer means "answerable". A prompt going out now needs a
+    # live one: mint a new token whenever the stored one is already spent, or
+    # every button in the new message would land on the already-answered page.
+    if not confirm_archive.confirm_token or confirm_archive.confirm_token_used_at is not None:
+        confirm_archive.confirm_token = _secrets.token_urlsafe(32)
+        confirm_archive.confirm_token_used_at = None
+        await db.commit()
+
+    from backend.app.api.routes.settings import get_external_base_url, get_setting
+
+    base = await get_external_base_url(db)
+    if not await get_setting(db, "external_url"):
+        # Both the Telegram inline keyboard and the ntfy action buttons need an
+        # absolute URL, so an unconfigured install used to get a message body
+        # with two unusable relative paths and no buttons at all. The shared
+        # fallback at least produces tappable links; say which setting makes
+        # them resolve from a phone.
+        logger.warning(
+            "[#1898] No external_url configured — the outcome prompt's Good/Reject links point at %s. "
+            "Set Settings → External URL so they resolve away from this host.",
+            base,
+        )
+    token = confirm_archive.confirm_token
+    good_url = f"{base}/api/v1/archives/confirm/{token}/good"
+    reject_url = f"{base}/api/v1/archives/confirm/{token}/reject"
+    confirm_url = f"{base}/archives?confirm={archive_id}"
+
+    await ws_manager.send_print_confirm_request(
+        printer_id,
+        {
+            "archive_id": archive_id,
+            "print_name": confirm_archive.print_name or confirm_archive.filename,
+        },
+    )
+    await notification_service.on_print_confirm_request(
+        printer_id,
+        printer_name,
+        data,
+        db,
+        archive_data=archive_data,
+        good_url=good_url,
+        reject_url=reject_url,
+        confirm_url=confirm_url,
+    )
+    return True
+
+
 async def on_print_start(printer_id: int, data: dict):
     """Handle print start - archive the 3MF file immediately."""
     logger = logging.getLogger(__name__)
@@ -3721,6 +3874,18 @@ async def on_print_start(printer_id: int, data: dict):
                 archive.status = "printing"
                 archive.started_at = datetime.now(timezone.utc)
 
+                # The previous run's answer is still on this row and the
+                # completion prompt is gated on ``user_verdict is None`` (#1898),
+                # so without a reset the second run inherits the first run's
+                # verdict: no prompt at all, and a green "good" badge on a run
+                # nobody ever judged.
+                if archive.confirm_requested:
+                    archive.user_verdict = None
+                    archive.user_verdict_source = None
+                    archive.user_verdict_at = None
+                    archive.confirm_token = None
+                    archive.confirm_token_used_at = None
+
                 # Reprint of an archive reuses the source row. Without resetting
                 # ``timelapse_path`` _scan_for_timelapse_with_retries early-returns
                 # ("already has timelapse") and _capture_finish_photo_from_timelapse
@@ -4529,6 +4694,7 @@ async def on_print_start(printer_id: int, data: dict):
                     status="printing",
                     started_at=datetime.now(timezone.utc),
                     subtask_id=subtask_id,
+                    confirm_requested=await _ask_outcome_for_external_print(db, printer_id, subtask_name),
                     filament_type=mqtt_filament_meta.get("filament_type"),
                     filament_color=mqtt_filament_meta.get("filament_color"),
                     extra_data={
@@ -4662,6 +4828,25 @@ async def on_print_start(printer_id: int, data: dict):
             )
 
             if archive:
+                # Ask-for-outcome for a print Bambuddy did not dispatch (#1898).
+                # Set on the row rather than passed to archive_print, which also
+                # serves the queue dispatcher — there the queue item decides.
+                # Guarded because this branch has no ``except``: an unhandled
+                # write error would take the _active_prints registration, the
+                # start notification, the energy reading and the timelapse
+                # baseline below it with it, and a missing prompt is by far the
+                # cheaper failure.
+                try:
+                    if await _ask_outcome_for_external_print(db, printer_id, subtask_name):
+                        archive.confirm_requested = True
+                        await db.commit()
+                except Exception as e:
+                    logger.warning("Could not flag archive %s for the outcome prompt: %s", archive.id, e)
+                    try:
+                        await db.rollback()
+                    except Exception:
+                        pass
+
                 # Track this active print (use both original filename and downloaded filename)
                 _active_prints[(printer_id, downloaded_filename)] = archive.id
                 if filename and filename != downloaded_filename:
@@ -6222,6 +6407,23 @@ def _subtask_names_match(expected: str, observed: str) -> bool:
     return False
 
 
+async def _queue_item_dispatched_name(db, item) -> str:
+    """The subtask name *item* was dispatched under, or "" when unknowable.
+
+    A row with no archive, or an archive with no file name, is unverifiable
+    rather than wrong; every caller answers that with its permissive branch.
+    """
+    if item.archive_id is None:
+        return ""
+
+    from backend.app.models.archive import PrintArchive
+
+    archive = await db.get(PrintArchive, item.archive_id)
+    if archive is None or not archive.filename:
+        return ""
+    return _subtask_name_from_filename(archive.filename)
+
+
 async def _completion_belongs_to_queue_item(db, item, data: dict) -> bool:
     """Whether this completion event is plausibly about *item*'s print.
 
@@ -7681,6 +7883,17 @@ async def on_print_complete(printer_id: int, data: dict):
                 else:
                     logger.info("[NOTIFY-BG] Skipped duplicate kill-switch provider notification")
 
+                # Post-print outcome confirmation (#1898). Runs in this
+                # background task so the finish photo fetched above rides
+                # along with the prompt.
+                if print_status == "completed" and archive_id:
+                    try:
+                        await dispatch_outcome_confirmation(
+                            db, printer_id, printer_name, data, archive_id, archive_data
+                        )
+                    except Exception as e:
+                        logger.error("[NOTIFY-BG] Outcome-confirmation dispatch failed: %s", e, exc_info=True)
+
                 # Send user-specific email notification
                 if archive_data:
                     created_by_id = archive_data.get("created_by_id")
@@ -9504,6 +9717,12 @@ PUBLIC_API_PREFIXES = [
     "/api/v1/ws",
     # OIDC authorize redirects — include provider_id in path
     "/api/v1/auth/oidc/authorize/",
+    # One-tap outcome-verdict links from push notifications (#1898). Tapped on
+    # a phone with no session, so no header can carry a JWT — the single-use
+    # capability token in the path IS the credential (same reasoning as the
+    # /dl/ slicer downloads below). The route grants nothing beyond writing
+    # good/reject on the one archive the token was minted for.
+    "/api/v1/archives/confirm/",
 ]
 
 # Route patterns that are public (read-only display data)
@@ -9633,6 +9852,10 @@ async def security_headers_middleware(request, call_next):
     # script passes the policy without us needing 'unsafe-inline'. See
     # https://developers.cloudflare.com/cloudflare-challenges/challenge-types/javascript-detections/#if-you-have-a-content-security-policy-csp
     csp_nonce = secrets.token_urlsafe(16)
+    # Routes that render their own HTML need it too, or their inline script is
+    # blocked by the policy below. The outcome-confirmation page (#1898) is the
+    # one that does: it submits its own form so a verdict still costs one tap.
+    request.state.csp_nonce = csp_nonce
     response = await call_next(request)
     response.headers["X-Content-Type-Options"] = "nosniff"
     # X-Frame-Options is the legacy cross-origin embedding control. Modern

+ 35 - 0
backend/app/models/archive.py

@@ -106,6 +106,41 @@ class PrintArchive(Base):
     failure_reason: Mapped[str | None] = mapped_column(String(100))  # For failed prints
     quantity: Mapped[int] = mapped_column(Integer, default=1)  # Number of items printed
 
+    # Post-print outcome confirmation (#1898). user_verdict is the USER's
+    # quality judgement ('good' / 'reject'), deliberately orthogonal to the
+    # machine-reported `status`: completed + reject means "printer finished
+    # it, part is scrap". confirm_requested is copied from the queue item's
+    # opt-in flag at dispatch (like plate_id) and drives the prompt + the
+    # "unconfirmed" badge; confirm_token is a per-archive capability for the
+    # one-tap verdict links in push notifications, minted when the prompt
+    # fires. A landed verdict RETIRES the token by stamping
+    # confirm_token_used_at rather than clearing the value: the link stays
+    # resolvable so a second tap can say "already answered, here is what was
+    # recorded" instead of the bare "invalid or already used" 404.
+    # user_verdict_source records how the verdict arrived ('dialog', 'link',
+    # 'plate_clear', 'printer_card', 'api', 'reaction') so the UI can explain
+    # a verdict nobody remembers giving.
+    user_verdict: Mapped[str | None] = mapped_column(String(10), nullable=True)
+    user_verdict_source: Mapped[str | None] = mapped_column(String(16), nullable=True)
+    # When the verdict on file was recorded (#1898). Written with every verdict,
+    # unlike `confirm_token_used_at`, which marks the one moment the one-tap
+    # capability was spent — a verdict changed later in the app must not be
+    # dated by that older event.
+    user_verdict_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    # Nullable to match the ALTER that adds it to an existing install: a fresh
+    # database would otherwise get NOT NULL while an upgraded one gets a
+    # nullable column, and the two would disagree about the same table. The
+    # default still means every row written by Bambuddy is True or False; only
+    # `is_(True)` and truthiness read it, both of which treat NULL as off.
+    confirm_requested: Mapped[bool | None] = mapped_column(Boolean, nullable=True, default=False, server_default="0")
+    # Indexed and unique: the one-tap route is reachable with no credential
+    # at all, so an unindexed lookup would let anyone turn a stream of
+    # garbage tokens into a stream of full scans of this table. Uniqueness
+    # costs nothing (the only writer is secrets.token_urlsafe(32)) and keeps
+    # scalar_one_or_none from ever raising MultipleResultsFound.
+    confirm_token: Mapped[str | None] = mapped_column(String(64), nullable=True, unique=True, index=True)
+    confirm_token_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
+
     # Energy tracking
     energy_kwh: Mapped[float | None] = mapped_column(Float)  # Energy consumed in kWh
     energy_cost: Mapped[float | None] = mapped_column(Float)  # Cost of energy consumed

+ 5 - 0
backend/app/models/notification.py

@@ -101,6 +101,11 @@ class NotificationProvider(Base):
     # Off by default: fires after every print, alongside the print-complete alert (#2525)
     on_plate_clear_required = Column(Boolean, default=False)  # Print ended, queue gated until plate is confirmed clear
 
+    # Print asked for an outcome verdict (#1898). Defaults ON: it only ever
+    # fires for prints where the user opted in per-job, so the provider-level
+    # toggle exists to silence a channel, not to enable the feature.
+    on_print_confirm_request = Column(Boolean, default=True)
+
     # Event triggers - Bed cooled after print
     on_bed_cooled = Column(Boolean, default=False)  # Bed cooled below threshold after print
     on_first_layer_complete = Column(Boolean, default=False)  # First layer finished printing

+ 14 - 0
backend/app/models/notification_template.py

@@ -55,6 +55,20 @@ DEFAULT_TEMPLATES = [
         "title_template": "Print {progress}% Complete",
         "body_template": "{printer}: {filename}\nRemaining: {remaining_time}",
     },
+    {
+        # Post-print outcome confirmation (#1898). The body carries
+        # {confirm_url}, which only opens the archive in Bambuddy: anything
+        # that walks a URL out of a message body — a preview card, a mail
+        # gateway, a proxy — then reaches a page that changes nothing.
+        # {good_url} / {reject_url} are the single-use capability links and
+        # stay available as variables, but they travel by default only in the
+        # affordances nothing prefetches: the ntfy action buttons and the
+        # Telegram inline keyboard, both built in notification_service.
+        "event_type": "print_confirm_request",
+        "name": "Print Outcome Confirmation",
+        "title_template": "How did your print come out?",
+        "body_template": "{printer}: {filename}\nConfirm: {confirm_url}",
+    },
     {
         "event_type": "print_missing_spool_assignment",
         "name": "Missing Spool Assignment",

+ 4 - 0
backend/app/models/print_log.py

@@ -45,6 +45,10 @@ class PrintLogEntry(Base):
     energy_kwh: Mapped[float | None] = mapped_column(Float)
     energy_cost: Mapped[float | None] = mapped_column(Float)
     failure_reason: Mapped[str | None] = mapped_column(String(100))
+    # User quality verdict ('good' / 'reject'), mirrored from the archive on
+    # PATCH exactly like status/failure_reason (#1444 mirror) so verdict-aware
+    # statistics can stay on this table (#1898).
+    user_verdict: Mapped[str | None] = mapped_column(String(10), nullable=True)
     thumbnail_path: Mapped[str | None] = mapped_column(String(500))
     created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
     created_by_username: Mapped[str | None] = mapped_column(String(100))

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

@@ -118,6 +118,9 @@ class PrintQueueItem(Base):
     use_ams: Mapped[bool] = mapped_column(Boolean, default=True)
     # Nozzle offset calibration — dual-nozzle printers only, MQTT-gated (#1682)
     nozzle_offset_cali: Mapped[str] = mapped_column(String(8), default="auto")
+    # Ask for a post-print outcome verdict when this job completes (#1898).
+    # Copied onto the archive as confirm_requested at dispatch, like plate_id.
+    confirm_outcome: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
 
     # Preheat / heat-soak override (#1468). 'inherit' uses the global
     # preheat_enabled setting; 'on' / 'off' force the per-item decision. The

+ 14 - 0
backend/app/schemas/archive.py

@@ -32,6 +32,14 @@ class ArchiveUpdate(ArchiveBase):
     project_id: int | None = None
     # Allow changing status (e.g., clearing failed flag)
     status: str | None = None
+    # Post-print quality verdict (#1898): 'good' / 'reject'; null clears it.
+    user_verdict: str | None = Field(default=None, pattern="^(good|reject)$")
+    # Which surface the verdict came from, for the "recorded when the plate was
+    # cleared" hint. Only the sources a client can honestly claim are accepted;
+    # 'link', 'plate_clear' and 'reaction' are stamped server-side by the paths
+    # that own them and must not be forgeable over this route. Omitted means
+    # 'api' — some script or integration did it.
+    user_verdict_source: str | None = Field(default=None, pattern="^(dialog|printer_card|api)$")
     # Editable because a print archived without its 3MF has no figure at all,
     # and nothing else can supply one after the fact -- rescan needs a file
     # this archive does not have (#1820). Bounded because it feeds the filament
@@ -109,6 +117,12 @@ class ArchiveResponse(BaseModel):
     failure_reason: str | None
     quantity: int = 1  # Number of items printed
 
+    # Post-print outcome confirmation (#1898)
+    user_verdict: str | None = None
+    user_verdict_source: str | None = None
+    user_verdict_at: datetime | None = None
+    confirm_requested: bool = False
+
     # Energy tracking
     energy_kwh: float | None = None
     energy_cost: float | None = None

+ 9 - 0
backend/app/schemas/notification.py

@@ -82,6 +82,12 @@ class NotificationProviderBase(BaseModel):
         default=False, description="Notify when a finished print is waiting for plate-clear confirmation"
     )
 
+    # Event triggers - Post-print outcome confirmation (#1898)
+    on_print_confirm_request: bool = Field(
+        default=True,
+        description="Notify with one-tap verdict links when a print that opted in asks for its outcome",
+    )
+
     # Event triggers - Bed cooled
     on_bed_cooled: bool = Field(default=False, description="Notify when bed cools after print")
 
@@ -188,6 +194,9 @@ class NotificationProviderUpdate(BaseModel):
     on_plate_not_empty: bool | None = None
     on_plate_clear_required: bool | None = None
 
+    # Event triggers - Post-print outcome confirmation (#1898)
+    on_print_confirm_request: bool | None = None
+
     # Event triggers - Bed cooled
     on_bed_cooled: bool | None = None
 

+ 1 - 0
backend/app/schemas/print_log.py

@@ -28,6 +28,7 @@ class PrintLogEntrySchema(BaseModel):
     energy_kwh: float | None = None
     energy_cost: float | None = None
     failure_reason: str | None = None
+    user_verdict: str | None = None  # Post-print quality verdict (#1898)
     thumbnail_path: str | None = None
     created_by_id: int | None = None
     created_by_username: str | None = None

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

@@ -103,6 +103,8 @@ class PrintQueueItemCreate(BaseModel):
     # Nozzle offset calibration — dual-nozzle printers only (#1682). The MQTT
     # layer ignores the value on single-nozzle printers so the wire stays "skip".
     nozzle_offset_cali: TriState = "auto"
+    # Ask for a post-print outcome verdict when this job completes (#1898)
+    confirm_outcome: bool = False
     # Preheat / heat-soak per-item override (#1468). 'inherit' uses the global
     # preheat_enabled setting; 'on' / 'off' force the decision. The chamber
     # target falls through: this override → max(filament-map[loaded tray]) → 0.
@@ -155,6 +157,7 @@ class PrintQueueItemUpdate(BaseModel):
     timelapse: bool | None = None
     use_ams: bool | None = None
     nozzle_offset_cali: TriState | None = None
+    confirm_outcome: bool | None = None
     preheat_override: Literal["inherit", "on", "off"] | None = None
     preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
     # Auto-print G-code injection
@@ -215,6 +218,7 @@ class PrintQueueItemResponse(BaseModel):
     timelapse: bool = False
     use_ams: bool = True
     nozzle_offset_cali: TriState = "auto"
+    confirm_outcome: bool = False
     preheat_override: Literal["inherit", "on", "off"] = "inherit"
     preheat_chamber_target_override: int | None = None
     status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
@@ -339,6 +343,7 @@ class PrintQueueBulkUpdate(BaseModel):
     timelapse: bool | None = None
     use_ams: bool | None = None
     nozzle_offset_cali: TriState | None = None
+    confirm_outcome: bool | None = None
     preheat_override: Literal["inherit", "on", "off"] | None = None
     preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
     # Auto-print G-code injection

+ 21 - 0
backend/app/schemas/settings.py

@@ -397,6 +397,24 @@ class AppSettings(BaseModel):
         default="auto",
         description="Default nozzle offset calibration option for new prints (dual-nozzle printers only)",
     )
+    default_confirm_outcome: bool = Field(
+        default=False,
+        description="Default for asking for a post-print outcome verdict on new prints (#1898)",
+    )
+    confirm_outcome_external_prints: bool = Field(
+        default=False,
+        description=(
+            "Also ask for the outcome of prints Bambuddy archived but did not dispatch — started at "
+            "the printer, in Bambu Studio or in the Handy app (#1898)"
+        ),
+    )
+    confirm_default_good_on_plate_clear: bool = Field(
+        default=False,
+        description=(
+            "When the build plate is released (manual acknowledgment or next dispatch) with the "
+            "outcome prompt still unanswered, record the print as a good part (#1898)"
+        ),
+    )
 
     # Staggered batch start for multi-printer jobs
     stagger_group_size: int = Field(
@@ -737,6 +755,9 @@ class AppSettingsUpdate(BaseModel):
     default_layer_inspect: bool | None = None
     default_timelapse: bool | None = None
     default_nozzle_offset_cali: TriState | None = None
+    default_confirm_outcome: bool | None = None
+    confirm_outcome_external_prints: bool | None = None
+    confirm_default_good_on_plate_clear: bool | None = None
     stagger_group_size: int | None = Field(default=None, ge=1, le=50)
     stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
     billing_enabled: bool | None = None

+ 26 - 0
backend/app/services/failure_analysis.py

@@ -103,6 +103,29 @@ class FailureAnalysisService:
         outcome_prints = successful_prints + failed_prints
         failure_rate = (failed_prints / outcome_prints * 100) if outcome_prints > 0 else 0
 
+        # Quality dimension (#1898): a completed print the user marked as
+        # reject is machine-success but scrap. Kept OUT of failure_rate — that
+        # number stays the machine's — and reported separately, with a yield
+        # rate that treats rejects as non-good output.
+        rejected_result = await self.db.execute(
+            select(func.count(PrintLogEntry.id)).where(
+                and_(*base_filter, PrintLogEntry.status == "completed", PrintLogEntry.user_verdict == "reject")
+            )
+        )
+        rejected_prints = rejected_result.scalar() or 0
+        yield_rate = ((successful_prints - rejected_prints) / outcome_prints * 100) if outcome_prints > 0 else 0
+
+        rejects_reason_result = await self.db.execute(
+            select(
+                PrintLogEntry.failure_reason,
+                func.count(PrintLogEntry.id).label("count"),
+            )
+            .where(and_(*base_filter, PrintLogEntry.status == "completed", PrintLogEntry.user_verdict == "reject"))
+            .group_by(PrintLogEntry.failure_reason)
+            .order_by(func.count(PrintLogEntry.id).desc())
+        )
+        rejects_by_reason = {(row[0] or "Unknown"): row[1] for row in rejects_reason_result.fetchall()}
+
         # Failures by reason
         reason_result = await self.db.execute(
             select(
@@ -257,6 +280,9 @@ class FailureAnalysisService:
             "total_prints": total_prints,
             "failed_prints": failed_prints,
             "failure_rate": round(failure_rate, 1),
+            "rejected_prints": rejected_prints,
+            "yield_rate": round(yield_rate, 1),
+            "rejects_by_reason": rejects_by_reason,
             "failures_by_reason": failures_by_reason,
             "failures_by_filament": failures_by_filament,
             "failures_by_printer": failures_by_printer,

+ 239 - 35
backend/app/services/notification_service.py

@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.models.notification import NotificationDigestQueue, NotificationLog, NotificationProvider
 from backend.app.models.notification_template import NotificationTemplate
+from backend.app.services.print_confirmation import one_tap_url
 
 logger = logging.getLogger(__name__)
 
@@ -318,11 +319,13 @@ class NotificationService:
         else:
             return False, f"HTTP {response.status_code}: {response.text[:200]}"
 
-    async def _send_bark(self, config: dict, title: str, message: str) -> tuple[bool, str]:
+    async def _send_bark(self, config: dict, title: str, message: str, url: str | None = None) -> tuple[bool, str]:
         """Send notification via Bark, the self-hostable iOS push service (#1495).
 
         POSTs JSON to {server}/push. Defaults to the official api.day.app
         relay; a self-hosted bark-server works by overriding the server URL.
+        ``url`` opens on tap — the outcome confirmation (#1898) deep-links
+        into the archive's confirmation dialog with it.
         """
         server = (config.get("server") or "https://api.day.app").strip().rstrip("/")
         device_key = (config.get("device_key") or "").strip()
@@ -348,6 +351,8 @@ class NotificationService:
         level = (config.get("level") or "").strip()
         if level in ("active", "timeSensitive", "critical", "passive"):
             payload["level"] = level
+        if url:
+            payload["url"] = url
 
         client = await self._get_client()
         response = await client.post(f"{server}/push", json=payload)
@@ -375,8 +380,14 @@ class NotificationService:
         message: str,
         image_data: bytes | None = None,
         event_type: str | None = None,
+        actions: str | None = None,
     ) -> tuple[bool, str]:
-        """Send notification via ntfy."""
+        """Send notification via ntfy.
+
+        ``actions`` is a pre-built value for ntfy's Actions header (simple
+        format), used by the outcome-confirmation event (#1898) to put
+        one-tap Good/Reject buttons directly into the push notification.
+        """
         server = config.get("server", "https://ntfy.sh").rstrip("/")
         topic = config.get("topic", "").strip()
         auth_token = config.get("auth_token", "").strip()
@@ -420,6 +431,9 @@ class NotificationService:
         if auth_token:
             headers["Authorization"] = f"Bearer {auth_token}"
 
+        if actions:
+            headers["Actions"] = actions
+
         client = await self._get_client()
 
         if image_data:
@@ -452,7 +466,13 @@ class NotificationService:
         return False, _opaque_http_failure(response, label="ntfy server")
 
     async def _send_pushover(
-        self, config: dict, title: str, message: str, image_data: bytes | None = None
+        self,
+        config: dict,
+        title: str,
+        message: str,
+        image_data: bytes | None = None,
+        url: str | None = None,
+        url_title: str | None = None,
     ) -> tuple[bool, str]:
         """Send notification via Pushover.
 
@@ -461,6 +481,8 @@ class NotificationService:
             title: Notification title
             message: Notification body
             image_data: Optional JPEG image bytes to attach (max 2.5MB)
+            url: Optional supplementary URL shown under the message
+            url_title: Optional label for that URL
         """
         user_key = config.get("user_key", "").strip()
         app_token = config.get("app_token", "").strip()
@@ -472,7 +494,7 @@ class NotificationService:
         if not user_key or not app_token:
             return False, "User key and app token are required"
 
-        url = "https://api.pushover.net/1/messages.json"
+        api_url = "https://api.pushover.net/1/messages.json"
         data = {
             "token": app_token,
             "user": user_key,
@@ -480,6 +502,10 @@ class NotificationService:
             "message": message,
             "priority": priority,
         }
+        if url:
+            data["url"] = url
+            if url_title:
+                data["url_title"] = url_title
 
         # Emergency priority (2) keeps re-alerting until acknowledged, so
         # Pushover *requires* retry (how often, >= 30s) and expire (when to
@@ -502,9 +528,9 @@ class NotificationService:
         if image_data:
             # Pushover supports image attachments via multipart form-data
             files = {"attachment": ("photo.jpg", image_data, "image/jpeg")}
-            response = await client.post(url, data=data, files=files)
+            response = await client.post(api_url, data=data, files=files)
         else:
-            response = await client.post(url, data=data)
+            response = await client.post(api_url, data=data)
 
         if response.status_code == 200:
             return True, "Message sent successfully"
@@ -516,8 +542,19 @@ class NotificationService:
             except Exception:
                 return False, f"HTTP {response.status_code}: {response.text[:200]}"
 
-    async def _send_telegram(self, config: dict, message: str, image_data: bytes | None = None) -> tuple[bool, str]:
-        """Send notification via Telegram bot."""
+    async def _send_telegram(
+        self,
+        config: dict,
+        message: str,
+        image_data: bytes | None = None,
+        buttons: list[dict] | None = None,
+    ) -> tuple[bool, str]:
+        """Send notification via Telegram bot.
+
+        ``buttons`` is one row of inline URL buttons (``{"text", "url"}``
+        entries), used by the outcome-confirmation event (#1898) to put
+        one-tap Good/Reject under the message.
+        """
         bot_token = config.get("bot_token", "").strip()
         chat_id = config.get("chat_id", "").strip()
 
@@ -548,36 +585,64 @@ class NotificationService:
 
         client = await self._get_client()
 
-        if image_data:
-            # Use sendPhoto to attach the thumbnail with the caption
-            url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
-            form: dict[str, Any] = {"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"}
-            if message_thread_id is not None:
-                form["message_thread_id"] = message_thread_id
-            response = await client.post(
-                url,
-                data=form,
-                files={"photo": ("photo.jpg", image_data, "image/jpeg")},
-            )
-        else:
+        async def _post(with_buttons: bool):
+            if image_data:
+                # Use sendPhoto to attach the thumbnail with the caption
+                url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
+                form: dict[str, Any] = {"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"}
+                if message_thread_id is not None:
+                    form["message_thread_id"] = message_thread_id
+                if with_buttons:
+                    # Multipart form fields are strings — reply_markup goes JSON-encoded.
+                    form["reply_markup"] = json.dumps({"inline_keyboard": [buttons]})
+                return await client.post(
+                    url,
+                    data=form,
+                    files={"photo": ("photo.jpg", image_data, "image/jpeg")},
+                )
+
             url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
-            data: dict[str, Any] = {
+            payload: dict[str, Any] = {
                 "chat_id": chat_id,
                 "text": message,
                 "parse_mode": "Markdown",
+                # Telegram's servers GET the first URL in the text to build a
+                # preview card. The outcome prompt (#1898) carries single-use
+                # verdict links in its body, so that fetch would answer the
+                # question before the operator saw it. Bambuddy's messages are
+                # status text; a preview card adds nothing to any of them.
+                "disable_web_page_preview": True,
             }
             if message_thread_id is not None:
-                data["message_thread_id"] = message_thread_id
-            response = await client.post(url, json=data)
-
-        if response.status_code == 200:
-            result = response.json()
+                payload["message_thread_id"] = message_thread_id
+            if with_buttons:
+                payload["reply_markup"] = {"inline_keyboard": [buttons]}
+            return await client.post(url, json=payload)
+
+        def _failure(resp) -> str | None:
+            """What Telegram objected to, or None when the send went through."""
+            if resp.status_code != 200:
+                return f"HTTP {resp.status_code}: {resp.text[:200]}"
+            result = resp.json()
             if result.get("ok"):
-                return True, "Message sent successfully"
-            else:
-                return False, f"Telegram error: {result.get('description', 'Unknown error')}"
-        else:
-            return False, f"HTTP {response.status_code}: {response.text[:200]}"
+                return None
+            return f"Telegram error: {result.get('description', 'Unknown error')}"
+
+        response = await _post(bool(buttons))
+        failure = _failure(response)
+        if failure and buttons:
+            # Telegram validates every inline-keyboard URL and refuses the whole
+            # send when one of them is not a URL it accepts — which is what an
+            # install without a public external_url produces for the #1898
+            # verdict links. Dropping the buttons is survivable; dropping the
+            # message the user is waiting for is not.
+            logger.warning("Telegram refused the message with inline buttons (%s); retrying without them", failure)
+            response = await _post(False)
+            failure = _failure(response)
+
+        if failure:
+            return False, failure
+        return True, "Message sent successfully"
 
     async def _send_email(
         self,
@@ -777,6 +842,21 @@ class NotificationService:
         if payload_format == "slack":
             # Slack/Mattermost format - just text field
             data = {"text": f"*{title}*\n{message}"}
+            if event_type == "print_confirm_request":
+                # Slack and Mattermost fetch every URL in the text to build
+                # preview cards, and the outcome prompt (#1898) is the one
+                # message whose links are single-use capabilities — that fetch
+                # would be a machine answering the operator's question. Off
+                # here for the same reason Telegram's preview is.
+                #
+                # Only here: the slack payload never attaches image bytes (the
+                # base64 attach below is generic-format only), so unfurling is
+                # the only way a {finish_photo_url} in a print_complete body
+                # can render as a photo in the channel. Switching it off for
+                # every event would quietly take that away with no setting to
+                # get it back.
+                data["unfurl_links"] = False
+                data["unfurl_media"] = False
         else:
             # Generic format with custom field names
             custom_field_title = config.get("field_title", "title").strip() or "title"
@@ -947,11 +1027,73 @@ class NotificationService:
             if provider.provider_type == "callmebot":
                 return await self._send_callmebot(config, f"{title}\n{message}")
             elif provider.provider_type == "ntfy":
-                return await self._send_ntfy(config, title, message, image_data=image_data, event_type=event_type)
+                # Outcome confirmation (#1898): render the verdict capability
+                # links as one-tap buttons on the notification itself. http so
+                # no browser needs to open; POST because that is the method
+                # that records — a GET only opens the confirmation page, which
+                # is what keeps unfurlers from answering the prompt.
+                # clear=true dismisses the notification once a button was tapped.
+                ntfy_actions = None
+                good_url = (variables or {}).get("good_url")
+                reject_url = (variables or {}).get("reject_url")
+                # Buttons need absolute URLs; without a configured external_url
+                # the links are relative, and the body's deep link into the
+                # archive has to do.
+                if (
+                    event_type == "print_confirm_request"
+                    and good_url
+                    and reject_url
+                    and good_url.startswith("http")
+                    and reject_url.startswith("http")
+                ):
+                    ntfy_actions = (
+                        f"http, Good, {good_url}, method=POST, clear=true; "
+                        f"http, Reject, {reject_url}, method=POST, clear=true"
+                    )
+                return await self._send_ntfy(
+                    config, title, message, image_data=image_data, event_type=event_type, actions=ntfy_actions
+                )
             elif provider.provider_type == "pushover":
-                return await self._send_pushover(config, title, message, image_data=image_data)
+                # Outcome confirmation (#1898): Pushover has no arbitrary
+                # buttons, but supports one supplementary URL — deep-link into
+                # the archive's confirmation dialog.
+                supplement_url = None
+                supplement_url_title = None
+                _confirm_url = (variables or {}).get("confirm_url")
+                if event_type == "print_confirm_request" and _confirm_url and _confirm_url.startswith("http"):
+                    supplement_url = _confirm_url
+                    supplement_url_title = "Confirm print outcome"
+                return await self._send_pushover(
+                    config, title, message, image_data=image_data, url=supplement_url, url_title=supplement_url_title
+                )
             elif provider.provider_type == "telegram":
-                return await self._send_telegram(config, f"*{title}*\n{message}", image_data=image_data)
+                # Outcome confirmation (#1898): inline URL buttons under the
+                # message — one tap records the verdict via the capability
+                # link. Same absolute-URL requirement as the ntfy actions.
+                # Telegram has no way to POST, so this is the one affordance
+                # that opens a browser, and it is the one that gets the one-tap
+                # marker: the page submits itself only for a URL that came off
+                # a button. Telegram does not fetch inline-keyboard URLs and
+                # nothing else can read them, so the marker never reaches a
+                # scanner — which is the difference between this and trusting
+                # the User-Agent.
+                tg_buttons = None
+                _tg_good = (variables or {}).get("good_url")
+                _tg_reject = (variables or {}).get("reject_url")
+                if (
+                    event_type == "print_confirm_request"
+                    and _tg_good
+                    and _tg_reject
+                    and _tg_good.startswith("http")
+                    and _tg_reject.startswith("http")
+                ):
+                    tg_buttons = [
+                        {"text": "\U0001f44d Good", "url": one_tap_url(_tg_good)},
+                        {"text": "\U0001f44e Reject", "url": one_tap_url(_tg_reject)},
+                    ]
+                return await self._send_telegram(
+                    config, f"*{title}*\n{message}", image_data=image_data, buttons=tg_buttons
+                )
             elif provider.provider_type == "email":
                 # finish_photo_url is pulled from the rendered template variables
                 # so _send_email can detect whether the template referenced the
@@ -969,7 +1111,13 @@ class NotificationService:
             elif provider.provider_type == "homeassistant":
                 return await self._send_homeassistant(config, title, message, db=db)
             elif provider.provider_type == "bark":
-                return await self._send_bark(config, title, message)
+                # Outcome confirmation (#1898): Bark opens one URL on tap —
+                # deep-link into the confirmation dialog, like Pushover.
+                bark_url = None
+                _bark_confirm = (variables or {}).get("confirm_url")
+                if event_type == "print_confirm_request" and _bark_confirm and _bark_confirm.startswith("http"):
+                    bark_url = _bark_confirm
+                return await self._send_bark(config, title, message, url=bark_url)
             else:
                 return False, f"Unknown provider type: {provider.provider_type}"
         except Exception as e:
@@ -1311,6 +1459,62 @@ class NotificationService:
             variables=variables,
         )
 
+    async def on_print_confirm_request(
+        self,
+        printer_id: int,
+        printer_name: str,
+        data: dict,
+        db: AsyncSession,
+        archive_data: dict | None = None,
+        good_url: str | None = None,
+        reject_url: str | None = None,
+        confirm_url: str | None = None,
+    ):
+        """Ask for a post-print outcome verdict (#1898).
+
+        Fires only for completed prints whose queue item opted in — the
+        provider-level toggle exists to mute a channel, not to enable the
+        feature. good_url / reject_url are the one-tap capability links
+        (rendered as ntfy action buttons), confirm_url deep-links into the
+        archive's confirmation dialog in the web UI.
+        """
+        providers = await self._get_providers_for_event(db, "on_print_confirm_request", printer_id)
+        if not providers:
+            return
+
+        subtask_name = data.get("subtask_name")
+        if subtask_name:
+            filename = subtask_name.replace("_", " ")
+        else:
+            filename = self._clean_filename(data.get("filename", "Unknown"))
+
+        variables = {"printer": printer_name, "filename": filename}
+        if good_url:
+            variables["good_url"] = good_url
+        if reject_url:
+            variables["reject_url"] = reject_url
+        if confirm_url:
+            variables["confirm_url"] = confirm_url
+
+        image_data = None
+        if archive_data:
+            if archive_data.get("finish_photo_url"):
+                variables["finish_photo_url"] = archive_data["finish_photo_url"]
+            image_data = archive_data.get("image_data")
+
+        title, message = await self._build_message_from_template(db, "print_confirm_request", variables)
+        await self._send_to_providers(
+            providers,
+            title,
+            message,
+            db,
+            "print_confirm_request",
+            printer_id,
+            printer_name,
+            image_data=image_data,
+            variables=variables,
+        )
+
     async def on_print_progress(
         self,
         printer_id: int,

+ 1 - 0
backend/app/services/print_batch.py

@@ -72,6 +72,7 @@ CLONED_SETTING_COLUMNS = (
     "timelapse",
     "use_ams",
     "nozzle_offset_cali",
+    "confirm_outcome",
     "preheat_override",
     "preheat_chamber_target_override",
     "skip_filament_check",

+ 193 - 0
backend/app/services/print_confirmation.py

@@ -0,0 +1,193 @@
+"""Post-print outcome confirmation helpers (#1898)."""
+
+import logging
+from collections.abc import Mapping
+from datetime import datetime, timezone
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import PrintLogEntry
+
+logger = logging.getLogger(__name__)
+
+# How a verdict reached the archive. 'reaction' is written by the Telegram
+# reaction handler (#3046), which lives on its own branch — listed here so the
+# vocabulary is complete and the UI can label it the day that lands.
+VERDICT_SOURCES = ("dialog", "link", "plate_clear", "printer_card", "api", "reaction")
+
+# Link-preview unfurlers and mail-security scanners fetch every URL they find in
+# a message, unattended, within seconds of it being sent. Nothing they can do
+# with a GET records a verdict any more -- that is the POST route's job -- so
+# this list is the second layer: it decides whether the confirmation page
+# submits its own form, which is what keeps a human at one tap. A scanner that
+# runs JavaScript would otherwise press the button on the operator's behalf.
+# Matched as case-insensitive substrings of the User-Agent; the generic "bot"
+# token covers TelegramBot, Discordbot, Slackbot-LinkExpanding, Twitterbot and
+# LinkedInBot in one go.
+UNATTENDED_FETCH_AGENTS = (
+    "bot",
+    "crawler",
+    "spider",
+    "facebookexternalhit",
+    "whatsapp",
+    "skypeuripreview",
+    "bingpreview",
+    "safelinks",
+    "urldefense",
+    "proofpoint",
+    "mimecast",
+    "barracuda",
+    "forcepoint",
+)
+
+# Prefetch / preload hints. A finger on a notification button is never one.
+UNATTENDED_FETCH_HEADERS = {
+    "purpose": ("prefetch", "preview"),
+    "x-purpose": ("prefetch", "preview"),
+    "x-moz": ("prefetch",),
+    "sec-purpose": ("prefetch",),
+}
+
+
+def is_unattended_fetch(method: str, headers: Mapping[str, str]) -> bool:
+    """Whether a request for a one-tap verdict link came from a machine.
+
+    Decides whether the confirmation page submits itself. False positives are
+    deliberately cheap -- a browser mistaken for a bot gets the same page with
+    a button to press -- so the lists above err towards catching more.
+    """
+    if method.upper() != "GET":
+        # Anything that is not the page load is not a page load: only the GET
+        # route renders, and only a GET can be widened to HEAD by a future
+        # router change.
+        return True
+    for header, markers in UNATTENDED_FETCH_HEADERS.items():
+        value = (headers.get(header) or "").lower()
+        if value and any(marker in value for marker in markers):
+            return True
+    agent = (headers.get("user-agent") or "").lower()
+    return any(marker in agent for marker in UNATTENDED_FETCH_AGENTS)
+
+
+# The one-tap marker. The confirmation page submits its own form only when the
+# URL it was opened from carries this, and the marker is put on exactly one
+# thing: the Telegram inline keyboard's buttons -- an affordance no unfurler,
+# gateway or proxy reads, for the same reason the capability URLs themselves no
+# longer travel in message text.
+#
+# It is what closes the gap the User-Agent list above cannot: a mail-security
+# sandbox that renders HTML and runs JavaScript sends an ordinary Chrome string
+# (so does literal HeadlessChrome), and a verdict URL that reached it did so out
+# of the message BODY -- where the marker never appears. That fetch now gets the
+# page with a button on it and records nothing. The operator's tap on the
+# notification button still costs exactly one tap.
+ONE_TAP_PARAM = "tap"
+
+
+def one_tap_url(url: str) -> str:
+    """Mark a verdict URL as one a human is about to press.
+
+    Only for the affordances a person taps directly. A URL that goes into text
+    anybody's machine might follow is left unmarked on purpose.
+    """
+    return f"{url}{'&' if '?' in url else '?'}{ONE_TAP_PARAM}=1"
+
+
+def is_one_tap_request(query_params: Mapping[str, str]) -> bool:
+    """Whether this page load came from a button rather than from message text."""
+    return (query_params.get(ONE_TAP_PARAM) or "") == "1"
+
+
+def stamp_verdict(archive: PrintArchive, source: str) -> None:
+    """Record a verdict's provenance and the moment it landed (#1898).
+
+    Both fields move together on every verdict write, which is what keeps the
+    "already answered" page from pairing a new source with the timestamp of an
+    older decision. `retire_confirm_token` is separate on purpose: spending the
+    one-tap capability happens once, recording a verdict can happen again.
+    """
+    archive.user_verdict_source = source
+    archive.user_verdict_at = datetime.now(timezone.utc)
+
+
+def retire_confirm_token(archive: PrintArchive) -> None:
+    """Spend the one-tap capability token without destroying it.
+
+    The token is still single-use: once ``confirm_token_used_at`` is stamped,
+    no verdict path accepts it again. Keeping the VALUE is what lets the
+    one-tap route recognise a link belonging to an already-answered print and
+    say so, instead of 404ing as if the link had never been real (the live-farm
+    case: the plate-clear default answered the prompt, then the user tapped the
+    Telegram button and got "invalid or already used").
+    """
+    if archive.confirm_token and archive.confirm_token_used_at is None:
+        archive.confirm_token_used_at = datetime.now(timezone.utc)
+
+
+async def resolve_pending_confirmation_as_good(db: AsyncSession, printer_id: int) -> int | None:
+    """Mark the printer's latest pending-confirmation archive as good.
+
+    Backs the opt-in ``confirm_default_good_on_plate_clear`` setting: releasing
+    the build plate is the moment the operator moves on to the next job, so an
+    unanswered outcome prompt can default to "good part" right there instead of
+    lingering as unconfirmed. Only the LATEST pending archive is resolved — the
+    plate release refers to the print that just came off the plate, not to
+    older unanswered prompts.
+
+    Mirrors the verdict onto the latest PrintLogEntry (the #1444 mirror) and
+    retires the one-tap capability token. Deliberately does NOT commit — both
+    callers (the clear-plate route and the queue dispatcher) manage their own
+    transaction.
+
+    Returns the resolved archive id, or None when nothing was pending.
+    """
+    archive = await db.scalar(
+        select(PrintArchive)
+        .where(
+            PrintArchive.printer_id == printer_id,
+            PrintArchive.status == "completed",
+            PrintArchive.confirm_requested.is_(True),
+            PrintArchive.user_verdict.is_(None),
+        )
+        .order_by(PrintArchive.id.desc())
+        .limit(1)
+    )
+    if archive is None:
+        return None
+
+    archive.user_verdict = "good"
+    stamp_verdict(archive, "plate_clear")
+    retire_confirm_token(archive)
+
+    latest_entry = await db.scalar(
+        select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc()).limit(1)
+    )
+    if latest_entry is not None:
+        latest_entry.user_verdict = "good"
+
+    logger.info("[#1898] Plate clear defaulted archive %s to 'good' (printer %s)", archive.id, printer_id)
+    return archive.id
+
+
+async def confirm_outcome_for_new_queue_item(db: AsyncSession, *, started_outside_bambuddy: bool = False) -> bool:
+    """The ask-for-outcome flag for a queue item created without the print dialog.
+
+    The dialog seeds its own per-job toggle from ``default_confirm_outcome``.
+    Every other queue-creation path -- the virtual printer, the library bulk
+    add, the webhook, a pipeline run -- has no toggle to seed and used to leave
+    the column at its ``False`` default, so "Ask for Outcome" only ever reached
+    jobs queued by hand.
+
+    ``started_outside_bambuddy`` additionally honours
+    ``confirm_outcome_external_prints``: a plate sent from Bambu Studio to a
+    virtual printer is one of the prints that setting's description names, but
+    it arrives with a queue item, so ``on_print_start`` never sees it as
+    external and the setting could not otherwise reach it.
+    """
+    from backend.app.api.routes.settings import get_setting, setting_is_true
+
+    if setting_is_true(await get_setting(db, "default_confirm_outcome")):
+        return True
+    return started_outside_bambuddy and setting_is_true(await get_setting(db, "confirm_outcome_external_prints"))

+ 18 - 0
backend/app/services/print_scheduler.py

@@ -6379,6 +6379,12 @@ class PrintScheduler:
             if archive.plate_id is None and item.plate_id is not None:
                 archive.plate_id = item.plate_id
 
+            # Ask-for-outcome opt-in rides from the queue item to the archive
+            # the same way (#1898); never cleared here so a reprint of an
+            # archive that already asked keeps asking.
+            if item.confirm_outcome:
+                archive.confirm_requested = True
+
             file_path = settings.base_dir / archive.file_path
             filename = archive.filename
 
@@ -6439,6 +6445,8 @@ class PrintScheduler:
                 )
                 if archive:
                     item.archive_id = archive.id
+                    if item.confirm_outcome:
+                        archive.confirm_requested = True  # ask-for-outcome opt-in (#1898)
                     if budget_reservation is not None:
                         budget_reservation.print_archive_id = archive.id
                     if item.cleanup_library_after_dispatch and not library_file.is_external:
@@ -6968,6 +6976,16 @@ class PrintScheduler:
 
         # Clear the awaiting-plate-clear flag now that we're starting a new print
         printer_manager.set_awaiting_plate_clear(item.printer_id, False)
+
+        # #1898: with the opt-in default-good setting, moving on to the next
+        # print resolves the previous print's unanswered outcome prompt as
+        # "good" — this path also covers the camera-based plate detection,
+        # which releases the gate by allowing dispatch rather than by an
+        # explicit acknowledgment. Rides on the dispatch transaction.
+        if await self._get_bool_setting(db, "confirm_default_good_on_plate_clear", default=False):
+            from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good
+
+            await resolve_pending_confirmation_as_good(db, item.printer_id)
         logger.info("Queue item %s: Status set to 'printing', sending print command...", item.id)
 
         # Capture state before dispatch so the watchdog can detect whether the

+ 10 - 0
backend/app/services/virtual_printer/manager.py

@@ -838,6 +838,7 @@ class VirtualPrinterInstance:
             from backend.app.models.print_queue import PrintQueueItem
             from backend.app.services.archive import ArchiveService
             from backend.app.services.filament_requirements import extract_filament_requirements
+            from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
 
             async with self._session_factory() as db:
                 name_source = await get_setting(db, "virtual_printer_archive_name_source")
@@ -905,6 +906,14 @@ class VirtualPrinterInstance:
                 )
                 timelapse = _slicer_or("timelapse", _bool_setting(await get_setting(db, "default_timelapse"), False))
 
+                # "Ask for Outcome" is a default print option like the ones
+                # above. A plate sent here from Bambu Studio is also one of the
+                # prints `confirm_outcome_external_prints` names — but it
+                # arrives with a queue item, so on_print_start resumes the
+                # archive created below instead of treating it as external, and
+                # without this the setting could never reach it (#1898).
+                confirm_outcome = await confirm_outcome_for_new_queue_item(db, started_outside_bambuddy=True)
+
                 # H2C dual-nozzle-rack slicer-pick preservation (#1780).
                 # BambuStudio's project_file MQTT command for rack-swap models
                 # (O1C2 today) carries `nozzle_mapping` — a per-filament array
@@ -1133,6 +1142,7 @@ class VirtualPrinterInstance:
                             vibration_cali=vibration_cali,
                             layer_inspect=layer_inspect,
                             timelapse=timelapse,
+                            confirm_outcome=confirm_outcome,
                             # Per-VP opt-in for auto-print G-code injection (#1516).
                             # Default off; when on, the scheduler still no-ops unless
                             # gcode_snippets are configured for the target model, so it's

+ 128 - 0
backend/tests/integration/test_confirm_outcome_queue_defaults_1898.py

@@ -0,0 +1,128 @@
+"""Every queue-creation path has to be able to ask for the outcome (#1898).
+
+``confirm_outcome`` rides from a queue item onto the archive at dispatch, and
+the only place that ever set it was the print dialog. Jobs created anywhere
+else -- a plate sent from Bambu Studio to a virtual printer, the Library's bulk
+"Add to queue", the webhook, a pipeline run -- carried the column default and
+were never asked about, however the install's settings were configured.
+
+That mattered most for the virtual printer: a Bambu Studio plate is one of the
+prints ``confirm_outcome_external_prints`` names in its own description, but it
+arrives with a queue item, so ``on_print_start`` resumes its archive instead of
+treating it as external and the setting could not reach it at all.
+"""
+
+from pathlib import Path
+from uuid import uuid4
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app.core.config import settings as app_settings
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.settings import Settings
+from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
+
+pytestmark = pytest.mark.integration
+
+
+async def _set_setting(db_session, key: str, value: str) -> None:
+    db_session.add(Settings(key=key, value=value))
+    await db_session.commit()
+
+
+class TestTheDefaultForAQueueItemNobodyToggled:
+    @pytest.mark.asyncio
+    async def test_off_when_nothing_is_configured(self, db_session):
+        assert await confirm_outcome_for_new_queue_item(db_session) is False
+        assert await confirm_outcome_for_new_queue_item(db_session, started_outside_bambuddy=True) is False
+
+    @pytest.mark.asyncio
+    async def test_the_per_job_default_covers_every_path(self, db_session):
+        """Same setting the print dialog seeds its own toggle from."""
+        await _set_setting(db_session, "default_confirm_outcome", "true")
+
+        assert await confirm_outcome_for_new_queue_item(db_session) is True
+        assert await confirm_outcome_for_new_queue_item(db_session, started_outside_bambuddy=True) is True
+
+    @pytest.mark.asyncio
+    async def test_the_external_setting_only_covers_external_origins(self, db_session):
+        """A Bambu Studio plate counts; a pipeline run Bambuddy started itself
+        does not -- that one follows the per-job default like any queued job."""
+        await _set_setting(db_session, "confirm_outcome_external_prints", "true")
+
+        assert await confirm_outcome_for_new_queue_item(db_session, started_outside_bambuddy=True) is True
+        assert await confirm_outcome_for_new_queue_item(db_session) is False
+
+    @pytest.mark.asyncio
+    async def test_an_odd_stored_value_counts_as_off(self, db_session):
+        await _set_setting(db_session, "default_confirm_outcome", "None")
+
+        assert await confirm_outcome_for_new_queue_item(db_session) is False
+
+
+@pytest.fixture
+async def sliced_library_file(db_session):
+    """A library file that passes add-to-queue's gates: the filename has to look
+    sliced and the bytes have to exist under ``base_dir``."""
+    from backend.app.models.library import LibraryFile
+
+    # Unique per test: the suite runs with xdist and the teardown below would
+    # otherwise delete the bytes a sibling worker is still relying on, which the
+    # route answers with a 400 for a bulk add that queued nothing (#3112).
+    name = f"confirm_outcome_probe_{uuid4().hex}.gcode.3mf"
+    rel_path = f"archive/library/files/{name}"
+    abs_path = Path(app_settings.base_dir) / rel_path
+    abs_path.parent.mkdir(parents=True, exist_ok=True)
+    abs_path.write_bytes(b"probe")
+
+    lib_file = LibraryFile(
+        filename=name,
+        file_path=rel_path,
+        file_size=5,
+        file_type="3mf",
+    )
+    db_session.add(lib_file)
+    await db_session.commit()
+    await db_session.refresh(lib_file)
+
+    yield lib_file
+
+    abs_path.unlink(missing_ok=True)
+
+
+async def _read_item(test_engine, item_id: int) -> PrintQueueItem:
+    """Fresh-session read: the route ran on its own ``Depends(get_db)`` session."""
+    maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    async with maker() as fresh:
+        return (await fresh.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
+
+
+class TestTheLibraryBulkAdd:
+    """The route has no per-job toggle at all, so the install-wide default is
+    the only thing that can decide."""
+
+    async def _add(self, async_client: AsyncClient, file_id: int) -> int:
+        response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [file_id]})
+        assert response.status_code == 200
+        added = response.json()["added"]
+        assert len(added) == 1
+        return added[0]["queue_item_id"]
+
+    @pytest.mark.asyncio
+    async def test_follows_the_default(self, async_client, db_session, test_engine, sliced_library_file):
+        await _set_setting(db_session, "default_confirm_outcome", "true")
+
+        item_id = await self._add(async_client, sliced_library_file.id)
+
+        assert (await _read_item(test_engine, item_id)).confirm_outcome is True
+
+    @pytest.mark.asyncio
+    async def test_stays_off_for_an_install_that_never_asked_for_it(
+        self, async_client, db_session, test_engine, sliced_library_file
+    ):
+        item_id = await self._add(async_client, sliced_library_file.id)
+
+        assert (await _read_item(test_engine, item_id)).confirm_outcome is False

+ 562 - 0
backend/tests/integration/test_external_print_confirmation_1898.py

@@ -0,0 +1,562 @@
+"""Ask for the outcome of prints Bambuddy did not start (#1898 follow-up).
+
+The ask-for-outcome flag rides from a queue item onto the archive at dispatch.
+On a farm where most jobs are started at the printer's screen, in Bambu Studio
+or in the Handy app there is no queue item to ride from, so every archive
+``on_print_start`` created had ``confirm_requested`` false and the feature
+looked broken. ``confirm_outcome_external_prints`` is the missing source.
+
+Both archive-creating branches of ``on_print_start`` are driven here against a
+real database: the no-3MF fallback (what a P1S/A1 farm actually hits) and the
+normal path that archives a downloaded 3MF.
+"""
+
+from contextlib import ExitStack
+from datetime import datetime, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app.main import (
+    _active_prints,
+    _expected_print_creators,
+    _expected_print_registered_at,
+    _expected_prints,
+    _print_ams_mappings,
+)
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.settings import Settings
+
+DISPATCH = "/data/Metadata/plate_1.gcode"
+SUBTASK = "Bracket_plate_1"
+
+
+@pytest.fixture(autouse=True)
+def _clear_print_state():
+    dicts = (
+        _expected_prints,
+        _expected_print_registered_at,
+        _expected_print_creators,
+        _print_ams_mappings,
+        _active_prints,
+    )
+    for d in dicts:
+        d.clear()
+    yield
+    for d in dicts:
+        d.clear()
+
+
+class _StubArchiveService:
+    """Stands in for ArchiveService on the downloaded-3MF branch.
+
+    Writes the row the real service would write, without needing a parseable
+    3MF on disk. Everything this test asserts happens *after* the row exists.
+    """
+
+    def __init__(self, db):
+        self.db = db
+
+    async def archive_print(self, **kwargs):
+        archive = PrintArchive(
+            printer_id=kwargs.get("printer_id"),
+            filename=f"{SUBTASK}.gcode.3mf",
+            file_path=f"archives/test/{SUBTASK}.gcode.3mf",
+            file_size=2048,
+            print_name=SUBTASK,
+            status="printing",
+            started_at=datetime.now(timezone.utc),
+        )
+        self.db.add(archive)
+        await self.db.commit()
+        await self.db.refresh(archive)
+        return archive
+
+
+async def _drive_print_start(test_engine, printer, *, download_ok: bool) -> None:
+    """Run ``on_print_start`` against the test database.
+
+    ``download_ok`` picks the branch: False leaves the 3MF unreachable and the
+    no-3MF fallback archive is written inline; True takes the ArchiveService
+    branch.
+    """
+    session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    state = MagicMock(
+        current_project_url=f"ftp://{SUBTASK}.gcode.3mf",
+        sdcard=True,
+        sdcard_reported=True,
+    )
+
+    patches = [
+        patch("backend.app.main.async_session", session_maker),
+        patch("backend.app.core.database.async_session", session_maker),
+        patch("backend.app.main.download_file_async", new=AsyncMock(return_value=download_ok)),
+        patch("backend.app.main.download_file_try_paths_async", new=AsyncMock(return_value=None)),
+        patch("backend.app.main.get_cached_3mf", return_value=None),
+        patch("backend.app.main.cache_3mf_download"),
+        patch("backend.app.main.peek_plate_index_in_3mf", return_value=None),
+        patch("backend.app.main.ArchiveService", _StubArchiveService),
+        # Imported inside the function, so patching it anywhere else lets the
+        # directory walk open real sockets.
+        patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(return_value=[])),
+        patch("backend.app.main.ftps_handshake_blocked", return_value=False),
+        patch("backend.app.main.get_ftp_retry_settings", new=AsyncMock(return_value=(False, 3, 2.0, 30))),
+        patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
+        patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
+        patch("backend.app.main._maybe_start_layer_timelapse"),
+        patch("backend.app.main._capture_timelapse_baseline_at_start", new_callable=AsyncMock),
+        # Real, it would spawn a task that outlives the test by a minute.
+        patch("backend.app.main._schedule_fallback_3mf_retry"),
+        patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
+        # Imported inside on_print_start; it would try to persist the mocked
+        # printer state's AMS trays and fail on the MagicMock values.
+        patch("backend.app.services.usage_tracker.on_print_start", new_callable=AsyncMock),
+    ]
+
+    with ExitStack() as stack:
+        for p in patches:
+            stack.enter_context(p)
+        notif = stack.enter_context(patch("backend.app.main.notification_service"))
+        plug = stack.enter_context(patch("backend.app.main.smart_plug_manager"))
+        ws = stack.enter_context(patch("backend.app.main.ws_manager"))
+        relay = stack.enter_context(patch("backend.app.main.mqtt_relay"))
+        pm = stack.enter_context(patch("backend.app.main.printer_manager"))
+
+        notif.on_print_start = AsyncMock()
+        plug.on_print_start = AsyncMock()
+        ws.send_print_start = AsyncMock()
+        ws.send_archive_created = AsyncMock()
+        ws.send_archive_updated = AsyncMock()
+        relay.on_print_start = AsyncMock()
+        relay.on_archive_created = AsyncMock()
+        pm.get_status = MagicMock(return_value=state)
+        pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TEST1898"))
+
+        from backend.app.main import on_print_start
+
+        await on_print_start(printer.id, {"filename": DISPATCH, "subtask_name": SUBTASK})
+
+
+async def _set_setting(db_session, key: str, value: str) -> None:
+    db_session.add(Settings(key=key, value=value))
+    await db_session.commit()
+
+
+async def _created_archive(db_session, printer_id: int) -> PrintArchive:
+    db_session.expire_all()
+    archive = await db_session.scalar(
+        select(PrintArchive).where(PrintArchive.printer_id == printer_id).order_by(PrintArchive.id.desc()).limit(1)
+    )
+    assert archive is not None, "on_print_start created no archive"
+    return archive
+
+
+class TestExternalPrintGetsTheOutcomePrompt:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_3mf_fallback_asks_when_the_setting_is_on(self, test_engine, db_session, printer_factory):
+        """The P1S/A1 case: the 3MF cannot be fetched, the archive is written
+        inline — and that is the row the completion path reads the flag off."""
+        printer = await printer_factory()
+        await _set_setting(db_session, "confirm_outcome_external_prints", "true")
+
+        await _drive_print_start(test_engine, printer, download_ok=False)
+
+        archive = await _created_archive(db_session, printer.id)
+        assert archive.extra_data.get("no_3mf_available") is True
+        assert archive.confirm_requested is True
+        # What the completion path gates the prompt on.
+        assert archive.user_verdict is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_archive_reaches_the_rest_of_the_1898_machinery(self, test_engine, db_session, printer_factory):
+        """An externally started print is now a pending confirmation like any
+        other: the prompt the completion path emits is gated on exactly these
+        two fields, and the plate-release default resolves the same row."""
+        from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good
+
+        printer = await printer_factory()
+        printer_id = printer.id
+        await _set_setting(db_session, "confirm_outcome_external_prints", "true")
+
+        await _drive_print_start(test_engine, printer, download_ok=False)
+
+        archive = await _created_archive(db_session, printer_id)
+        archive.status = "completed"
+        await db_session.commit()
+
+        assert await resolve_pending_confirmation_as_good(db_session, printer_id) == archive.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_downloaded_3mf_archive_asks_when_the_setting_is_on(self, test_engine, db_session, printer_factory):
+        """The other creation branch: the 3MF arrived and ArchiveService wrote
+        the row. The flag is set on the row afterwards rather than passed into
+        archive_print, which also serves the queue dispatcher."""
+        printer = await printer_factory()
+        await _set_setting(db_session, "confirm_outcome_external_prints", "true")
+
+        await _drive_print_start(test_engine, printer, download_ok=True)
+
+        archive = await _created_archive(db_session, printer.id)
+        assert archive.file_path.endswith(".3mf")
+        assert archive.confirm_requested is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_default_leaves_external_prints_alone(self, test_engine, db_session, printer_factory):
+        """Default off: nothing about today's behaviour changes for an install
+        that never touches the new setting — no row for it at all."""
+        printer = await printer_factory()
+
+        await _drive_print_start(test_engine, printer, download_ok=False)
+
+        archive = await _created_archive(db_session, printer.id)
+        assert archive.confirm_requested is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_setting_off_does_not_ask(self, test_engine, db_session, printer_factory):
+        printer = await printer_factory()
+        await _set_setting(db_session, "confirm_outcome_external_prints", "false")
+
+        await _drive_print_start(test_engine, printer, download_ok=False)
+
+        assert (await _created_archive(db_session, printer.id)).confirm_requested is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_odd_stored_value_counts_as_off(self, test_engine, db_session, printer_factory):
+        """Settings live in a VARCHAR column and every other reader treats
+        anything that is not "true" as off."""
+        printer = await printer_factory()
+        await _set_setting(db_session, "confirm_outcome_external_prints", "None")
+
+        await _drive_print_start(test_engine, printer, download_ok=False)
+
+        assert (await _created_archive(db_session, printer.id)).confirm_requested is False
+
+
+class TestAQueuedPrintStillDecidesForItself:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_dispatched_job_is_not_overridden_by_the_setting(
+        self, test_engine, db_session, printer_factory, archive_factory
+    ):
+        """A queue item that deliberately has the ask-for-outcome flag off must
+        stay off. A restart mid-print empties the expected-print registry, so
+        the queue row — which the scheduler commits to "printing" before the
+        MQTT send — is the durable record that Bambuddy started this."""
+        printer = await printer_factory()
+        # Dispatched but not yet reported as started: the archive the scheduler
+        # attached to the row only turns "printing" once on_print_start runs, so
+        # the name-match resume above cannot find it and the queue row is the
+        # only record that Bambuddy sent this print.
+        source = await archive_factory(printer.id, filename=f"{SUBTASK}.gcode.3mf", status="pending", with_run=False)
+        db_session.add(
+            PrintQueueItem(
+                printer_id=printer.id,
+                archive_id=source.id,
+                status="printing",
+                confirm_outcome=False,
+            )
+        )
+        await db_session.commit()
+        await _set_setting(db_session, "confirm_outcome_external_prints", "true")
+
+        await _drive_print_start(test_engine, printer, download_ok=False)
+
+        archive = await _created_archive(db_session, printer.id)
+        assert archive.confirm_requested is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_queue_items_own_yes_survives_print_start(
+        self, test_engine, db_session, printer_factory, archive_factory
+    ):
+        """The dispatcher copies ``confirm_outcome`` onto the archive before
+        the print starts; the expected-print branch of ``on_print_start`` must
+        leave that alone even while the external-print setting is off."""
+        from backend.app.main import register_expected_print
+
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            filename=f"{SUBTASK}.gcode.3mf",
+            status="pending",
+            confirm_requested=True,
+            with_run=False,
+        )
+        db_session.add(
+            PrintQueueItem(
+                printer_id=printer.id,
+                archive_id=archive.id,
+                status="printing",
+                confirm_outcome=True,
+            )
+        )
+        await db_session.commit()
+        archive_id, printer_id = archive.id, printer.id
+        register_expected_print(printer_id, f"{SUBTASK}.gcode.3mf", archive_id)
+
+        await _drive_print_start(test_engine, printer, download_ok=False)
+
+        db_session.expire_all()
+        refreshed = await db_session.get(PrintArchive, archive_id)
+        assert refreshed.status == "printing"
+        assert refreshed.confirm_requested is True
+        # No second row for the same print.
+        rows = (await db_session.scalars(select(PrintArchive).where(PrintArchive.printer_id == printer_id))).all()
+        assert len(rows) == 1
+
+
+class TestSettingsRoundTrip:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_defaults_to_off_and_round_trips(self, async_client):
+        response = await async_client.get("/api/v1/settings/")
+        assert response.status_code == 200
+        assert response.json()["confirm_outcome_external_prints"] is False
+
+        response = await async_client.put("/api/v1/settings/", json={"confirm_outcome_external_prints": True})
+        assert response.status_code == 200
+        assert response.json()["confirm_outcome_external_prints"] is True
+
+        assert (await async_client.get("/api/v1/settings/")).json()["confirm_outcome_external_prints"] is True
+
+        response = await async_client.put("/api/v1/settings/", json={"confirm_outcome_external_prints": False})
+        assert response.status_code == 200
+        assert (await async_client.get("/api/v1/settings/")).json()["confirm_outcome_external_prints"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_updating_it_leaves_the_per_job_default_alone(self, async_client):
+        """Two different questions: one seeds the per-job toggle in the print
+        dialog, the other covers prints that never see that dialog."""
+        await async_client.put("/api/v1/settings/", json={"default_confirm_outcome": True})
+
+        body = (await async_client.put("/api/v1/settings/", json={"confirm_outcome_external_prints": True})).json()
+        assert body["default_confirm_outcome"] is True
+        assert body["confirm_outcome_external_prints"] is True
+
+
+class TestAStrandedQueueRowDoesNotMuteTheSetting:
+    """A ``printing`` row is not proof that Bambuddy started what is printing now.
+
+    ``_completion_belongs_to_queue_item`` deliberately leaves a row open when a
+    completion's subtask name disagrees with the file it was dispatched with,
+    and the scheduler's stranded sweep only takes it back once the printer has
+    sat connected and terminal for minutes. Treating any such row as "we
+    dispatched this" switched the setting off for every screen-started print in
+    between -- the exact symptom the setting exists to cure.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_row_left_open_for_another_file_still_asks(
+        self, test_engine, db_session, printer_factory, archive_factory
+    ):
+        printer = await printer_factory()
+        stranded_for = await archive_factory(
+            printer.id, filename="SomeOtherJob.gcode.3mf", status="printing", with_run=False
+        )
+        db_session.add(
+            PrintQueueItem(
+                printer_id=printer.id,
+                archive_id=stranded_for.id,
+                status="printing",
+                confirm_outcome=False,
+            )
+        )
+        await db_session.commit()
+        await _set_setting(db_session, "confirm_outcome_external_prints", "true")
+        stranded_filename = stranded_for.filename
+
+        await _drive_print_start(test_engine, printer, download_ok=False)
+
+        archive = await _created_archive(db_session, printer.id)
+        assert archive.filename != stranded_filename
+        assert archive.confirm_requested is True
+
+
+class TestAReprintAsksAgain:
+    """The reprint reuses the archive row, and the completion prompt is gated on
+    ``user_verdict is None`` -- so without a reset the second run inherits the
+    first run's answer and is never asked about."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_previous_runs_verdict_does_not_carry_over(
+        self, test_engine, db_session, printer_factory, archive_factory
+    ):
+        from backend.app.main import register_expected_print
+
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            filename=f"{SUBTASK}.gcode.3mf",
+            status="completed",
+            confirm_requested=True,
+            user_verdict="good",
+            confirm_token="token-from-the-first-run",
+            with_run=False,
+        )
+        archive_id, printer_id = archive.id, printer.id
+        register_expected_print(printer_id, f"{SUBTASK}.gcode.3mf", archive_id)
+
+        await _drive_print_start(test_engine, printer, download_ok=False)
+
+        db_session.expire_all()
+        refreshed = await db_session.get(PrintArchive, archive_id)
+        assert refreshed.status == "printing"
+        assert refreshed.confirm_requested is True
+        assert refreshed.user_verdict is None
+        assert refreshed.confirm_token is None
+
+        # And the completion really does ask again, which is the point.
+        refreshed.status = "completed"
+        await db_session.commit()
+        sent, ws, notif = await _dispatch(db_session, printer_id, archive_id)
+        assert sent is True
+        ws.send_print_confirm_request.assert_awaited_once()
+        notif.on_print_confirm_request.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_archive_that_was_never_asked_about_keeps_its_verdict(
+        self, test_engine, db_session, printer_factory, archive_factory
+    ):
+        """The reset is scoped to rows that ask. An archive answered once and
+        later reprinted with the flag off must keep the answer it has."""
+        from backend.app.main import register_expected_print
+
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            filename=f"{SUBTASK}.gcode.3mf",
+            status="completed",
+            confirm_requested=False,
+            user_verdict="reject",
+            with_run=False,
+        )
+        archive_id = archive.id
+        register_expected_print(printer.id, f"{SUBTASK}.gcode.3mf", archive_id)
+
+        await _drive_print_start(test_engine, printer, download_ok=False)
+
+        db_session.expire_all()
+        assert (await db_session.get(PrintArchive, archive_id)).user_verdict == "reject"
+
+
+async def _dispatch(db_session, printer_id: int, archive_id: int, **kwargs):
+    """Run the completion path's outcome dispatch with the two emitters mocked."""
+    from backend.app.main import dispatch_outcome_confirmation
+
+    with (
+        patch("backend.app.main.ws_manager") as ws,
+        patch("backend.app.main.notification_service") as notif,
+    ):
+        ws.send_print_confirm_request = AsyncMock()
+        notif.on_print_confirm_request = AsyncMock()
+        sent = await dispatch_outcome_confirmation(
+            db_session,
+            printer_id,
+            "Bench P1S",
+            {"subtask_name": SUBTASK},
+            archive_id,
+            **kwargs,
+        )
+    return sent, ws, notif
+
+
+class TestTheCompletionEmitsThePrompt:
+    """The half of the feature the user actually sees.
+
+    Everything else here asserts a column value; this drives the block
+    ``on_print_complete``'s notification task runs -- which is wrapped in a bare
+    ``except Exception`` that only logs, so a regression in it is invisible
+    unless something pins it.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_external_print_gets_a_prompt_with_one_tap_links(self, test_engine, db_session, printer_factory):
+        printer = await printer_factory()
+        printer_id = printer.id
+        await _set_setting(db_session, "confirm_outcome_external_prints", "true")
+        await _set_setting(db_session, "external_url", "https://farm.example.com/")
+
+        await _drive_print_start(test_engine, printer, download_ok=False)
+
+        archive = await _created_archive(db_session, printer_id)
+        archive_id = archive.id
+        assert archive.confirm_requested is True
+        archive.status = "completed"
+        await db_session.commit()
+
+        sent, ws, notif = await _dispatch(db_session, printer_id, archive_id)
+
+        assert sent is True
+        ws.send_print_confirm_request.assert_awaited_once()
+        assert ws.send_print_confirm_request.await_args.args[1]["archive_id"] == archive_id
+
+        notif.on_print_confirm_request.assert_awaited_once()
+        kwargs = notif.on_print_confirm_request.await_args.kwargs
+        token = (await db_session.get(PrintArchive, archive_id)).confirm_token
+        assert token, "the one-tap links need a minted capability token"
+        assert kwargs["good_url"] == f"https://farm.example.com/api/v1/archives/confirm/{token}/good"
+        assert kwargs["reject_url"] == f"https://farm.example.com/api/v1/archives/confirm/{token}/reject"
+        assert kwargs["confirm_url"] == f"https://farm.example.com/archives?confirm={archive_id}"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_links_stay_absolute_without_an_external_url(self, test_engine, db_session, printer_factory):
+        """Telegram's inline keyboard and ntfy's action buttons are both dropped
+        for a relative URL, so an install that never set external_url used to
+        get a message carrying two unusable paths and no buttons at all."""
+        printer = await printer_factory()
+        printer_id = printer.id
+        await _set_setting(db_session, "confirm_outcome_external_prints", "true")
+
+        await _drive_print_start(test_engine, printer, download_ok=False)
+
+        archive = await _created_archive(db_session, printer_id)
+        archive_id = archive.id
+        archive.status = "completed"
+        await db_session.commit()
+
+        _, _, notif = await _dispatch(db_session, printer_id, archive_id)
+
+        kwargs = notif.on_print_confirm_request.await_args.kwargs
+        assert kwargs["good_url"].startswith("http")
+        assert kwargs["reject_url"].startswith("http")
+        assert kwargs["confirm_url"].startswith("http")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_already_answered_archive_is_not_asked_again(self, db_session, printer_factory, archive_factory):
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id, status="completed", confirm_requested=True, user_verdict="good", with_run=False
+        )
+
+        sent, ws, notif = await _dispatch(db_session, printer.id, archive.id)
+
+        assert sent is False
+        ws.send_print_confirm_request.assert_not_awaited()
+        notif.on_print_confirm_request.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_archive_that_never_opted_in_is_not_asked(self, db_session, printer_factory, archive_factory):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, status="completed", confirm_requested=False, with_run=False)
+
+        sent, _, notif = await _dispatch(db_session, printer.id, archive.id)
+
+        assert sent is False
+        notif.on_print_confirm_request.assert_not_awaited()

+ 605 - 0
backend/tests/integration/test_print_confirmation.py

@@ -0,0 +1,605 @@
+"""Integration tests for the post-print outcome confirmation (#1898).
+
+Covers the verdict PATCH (incl. the #1444-style mirror to the latest
+PrintLogEntry and token retirement), the unauthenticated capability-token
+endpoint, response defaults, and the verdict-aware statistics.
+"""
+
+import re
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.print_log import PrintLogEntry
+
+
+class TestOutcomeVerdictPatch:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_defaults_present_on_response(self, async_client: AsyncClient, archive_factory, printer_factory):
+        """Archives created without any verdict expose the new fields with
+        their defaults — no verdict, no pending confirmation."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+
+        response = await async_client.get(f"/api/v1/archives/{archive.id}")
+        assert response.status_code == 200
+        body = response.json()
+        assert body["user_verdict"] is None
+        assert body["confirm_requested"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_verdict_mirrors_to_latest_log_entry(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Setting the verdict via PATCH lands on the archive AND the latest
+        PrintLogEntry (verdict-aware statistics read the log), and retires a
+        pending confirmation token."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="test-token-mirror")
+
+        response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"user_verdict": "reject"})
+        assert response.status_code == 200
+        assert response.json()["user_verdict"] == "reject"
+
+        entry = await db_session.scalar(
+            select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc())
+        )
+        assert entry is not None
+        assert entry.user_verdict == "reject"
+
+        await db_session.refresh(archive)
+        assert archive.user_verdict == "reject"
+        # Retired by stamping, not by dropping the value: the link stays
+        # resolvable so a later tap can be told it is already answered.
+        assert archive.confirm_token == "test-token-mirror"
+        assert archive.confirm_token_used_at is not None
+        assert archive.user_verdict_source == "api"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_rejects_unknown_verdict(self, async_client: AsyncClient, archive_factory, printer_factory):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+
+        response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"user_verdict": "meh"})
+        assert response.status_code == 422
+
+
+class TestConfirmTokenEndpoint:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_token_records_verdict_and_retires_token(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """The one-tap link from a push notification records the verdict
+        without auth, mirrors it to the log entry, and single-uses the token."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="test-token-good")
+
+        response = await async_client.post("/api/v1/archives/confirm/test-token-good/good")
+        assert response.status_code == 200
+        assert "text/html" in response.headers["content-type"]
+
+        await db_session.refresh(archive)
+        assert archive.user_verdict == "good"
+        assert archive.user_verdict_source == "link"
+        assert archive.confirm_token == "test-token-good"
+        assert archive.confirm_token_used_at is not None
+
+        entry = await db_session.scalar(
+            select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc())
+        )
+        assert entry is not None
+        assert entry.user_verdict == "good"
+
+        # Second use of the same token: spent, and said so rather than 404.
+        response = await async_client.get("/api/v1/archives/confirm/test-token-good/reject")
+        assert response.status_code == 200
+        assert "Already answered" in response.text
+        await db_session.refresh(archive)
+        assert archive.user_verdict == "good"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_spent_link_reports_the_recorded_verdict_without_changing_it(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """The live-farm case (#1898): the plate-clear default answered the
+        prompt, then the Telegram button was tapped. The link must name the
+        verdict on file, say how it got there, and leave it alone."""
+        from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="plate-cleared-token")
+
+        assert await resolve_pending_confirmation_as_good(db_session, printer.id) == archive.id
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/archives/confirm/plate-cleared-token/reject")
+        assert response.status_code == 200
+        body = response.text
+        assert "Already answered" in body
+        assert "Good part" in body
+        assert "plate was cleared" in body
+        assert f"/archives?confirm={archive.id}" in body
+
+        await db_session.refresh(archive)
+        assert archive.user_verdict == "good"
+        assert archive.user_verdict_source == "plate_clear"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_spent_link_after_the_verdict_was_cleared_again(
+        self, async_client: AsyncClient, archive_factory, printer_factory
+    ):
+        """Clearing the verdict in the app does not un-spend the link: the
+        capability was used, so the page explains rather than re-opening it."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="cleared-again-token")
+
+        assert (await async_client.post("/api/v1/archives/confirm/cleared-again-token/good")).status_code == 200
+        assert (
+            await async_client.patch(f"/api/v1/archives/{archive.id}", json={"user_verdict": None})
+        ).status_code == 200
+
+        response = await async_client.get("/api/v1/archives/confirm/cleared-again-token/good")
+        assert response.status_code == 200
+        assert "Already answered" in response.text
+        assert (await async_client.get(f"/api/v1/archives/{archive.id}")).json()["user_verdict"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unknown_token_and_garbage_verdict(self, async_client: AsyncClient):
+        assert (await async_client.get("/api/v1/archives/confirm/no-such-token/good")).status_code == 404
+        assert (await async_client.get("/api/v1/archives/confirm/whatever/maybe")).status_code == 400
+        assert (await async_client.post("/api/v1/archives/confirm/no-such-token/good")).status_code == 404
+        assert (await async_client.post("/api/v1/archives/confirm/whatever/maybe")).status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_get_records_nothing_whoever_sends_it(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """The blocker. Telegram and Slack GET the URLs in a message to build a
+        preview card, mail gateways detonate them before delivery, proxies and
+        browsers prefetch. While GET was the route that recorded, any of those
+        settled the outcome before the operator read the question — always
+        towards 'good', because good_url came first — and spent the token, so
+        the real tap landed on "already answered". A scrap part counted as a
+        success for good, in the statistics this branch adds.
+
+        The heuristic below decides how the page behaves, not whether the
+        verdict is written: nothing a GET can say records anything.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="unfurler-token")
+
+        for agent in (
+            "TelegramBot (like TwitterBot)",
+            "Slackbot-LinkExpanding 1.0",
+            "Mimecast-Link-Protect",
+            # The one that used to be allowed straight through to the write.
+            "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 "
+            "(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
+        ):
+            response = await async_client.get(
+                "/api/v1/archives/confirm/unfurler-token/good", headers={"user-agent": agent}
+            )
+            assert response.status_code == 200, agent
+            assert "Confirm this outcome" in response.text, agent
+            assert "<form method='post'" in response.text, agent
+
+            await db_session.refresh(archive)
+            assert archive.user_verdict is None, agent
+            assert archive.confirm_token_used_at is None, f"{agent} must leave the capability spendable"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_forms_post_records_the_verdict(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """What the page's form does when it is submitted. Unfurlers, scanners
+        and prefetchers issue GET; none of them POSTs."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="unfurler-then-tap")
+
+        response = await async_client.post("/api/v1/archives/confirm/unfurler-then-tap/reject")
+        assert response.status_code == 200
+        assert "Rejected" in response.text
+
+        await db_session.refresh(archive)
+        assert archive.user_verdict == "reject"
+        assert archive.user_verdict_source == "link"
+        assert archive.confirm_token_used_at is not None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_phone_browser_still_records_in_one_tap(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """The split must not cost the feature its point. The page opened from
+        a notification button submits itself, so the tap on the notification is
+        still the only tap — and the script carries the CSP nonce, without
+        which the policy in main.py would block it and cost the operator a
+        second tap.
+
+        The ``?tap=1`` is what the Telegram inline keyboard carries. This test
+        used to load the URL without it and still expect the script, which was
+        the remaining hole rather than the feature: that unmarked URL is the
+        one a scanner gets out of a message body.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="phone-token")
+
+        response = await async_client.get(
+            "/api/v1/archives/confirm/phone-token/good?tap=1",
+            headers={
+                "user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) "
+                "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
+            },
+        )
+        assert response.status_code == 200
+        assert "getElementById('confirm-form').submit()" in response.text
+
+        nonce = re.search(r"<script nonce='([^']+)'", response.text)
+        assert nonce, "the inline script needs the per-request nonce or the CSP blocks it"
+        assert f"'nonce-{nonce.group(1)}'" in response.headers["content-security-policy"]
+
+        # And the submit that script performs is the thing that records. The
+        # form has no action attribute, so a browser POSTs back to the URL it
+        # loaded — query string and all, which the route must not mind.
+        await db_session.refresh(archive)
+        assert archive.user_verdict is None
+        assert (await async_client.post("/api/v1/archives/confirm/phone-token/good?tap=1")).status_code == 200
+        await db_session.refresh(archive)
+        assert archive.user_verdict == "good"
+        assert archive.confirm_token_used_at is not None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        "headers",
+        [
+            {"user-agent": "TelegramBot (like TwitterBot)"},
+            {
+                "user-agent": "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 "
+                "(KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36",
+                "purpose": "prefetch",
+            },
+        ],
+    )
+    async def test_an_unattended_fetch_gets_no_self_submitting_script(
+        self, async_client: AsyncClient, archive_factory, printer_factory, headers
+    ):
+        """Second layer, for the scanners that do run JavaScript: a request
+        that already looks automated is served the button without the script
+        that would press it."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="no-script-token")
+        assert archive.confirm_token == "no-script-token"
+
+        response = await async_client.get("/api/v1/archives/confirm/no-script-token/good?tap=1", headers=headers)
+        assert response.status_code == 200
+        assert "<form method='post'" in response.text
+        assert "<script" not in response.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        "agent",
+        [
+            # A mail-security sandbox detonating the link: renders HTML, runs
+            # JavaScript, and says it is Chrome — because as far as it is
+            # concerned it is.
+            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
+            "Chrome/126.0.0.0 Safari/537.36",
+            # Not even hiding, and the User-Agent list still has no word for it.
+            "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
+            "HeadlessChrome/126.0.0.0 Safari/537.36",
+        ],
+    )
+    async def test_a_link_out_of_the_message_body_never_submits_itself(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session, agent
+    ):
+        """The residual after taking the write off GET, now closed.
+
+        The User-Agent list catches the fetchers that announce themselves, and
+        none of those run JavaScript anyway. The ones that do run it — Safe
+        Links detonation, click-time sandboxing, a browser-isolation proxy —
+        send an ordinary Chrome string, so the list cannot name them and the
+        page used to press its own button for them.
+
+        What it can never have is the ``?tap=1`` off a notification button: a
+        verdict URL only reaches a scanner by travelling in message text, where
+        the marker is not. An install that kept ``{good_url}`` in its prompt
+        body (the migration leaves an edited body alone, on purpose) is covered
+        by this, which is the case the plain URL below stands for.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="detonated-token")
+
+        response = await async_client.get(
+            "/api/v1/archives/confirm/detonated-token/good", headers={"user-agent": agent}
+        )
+        assert response.status_code == 200
+        assert "Confirm this outcome" in response.text
+        assert "<form method='post'" in response.text
+        assert "submit()" not in response.text, "a body link must never press its own button"
+
+        await db_session.refresh(archive)
+        assert archive.user_verdict is None
+        assert archive.confirm_token_used_at is None, "the operator's tap must still be worth something"
+
+        # And the same browser, arriving from the button, is still one tap.
+        marked = await async_client.get(
+            "/api/v1/archives/confirm/detonated-token/good?tap=1", headers={"user-agent": agent}
+        )
+        assert "getElementById('confirm-form').submit()" in marked.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_head_request_records_nothing(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Mail scanners probe with HEAD. FastAPI's APIRoute does not widen a
+        GET route to HEAD the way a plain Starlette Route does, so the probe is
+        refused outright — and the GET it would widen to writes nothing now."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="head-token")
+
+        assert (await async_client.head("/api/v1/archives/confirm/head-token/good")).status_code == 405
+
+        await db_session.refresh(archive)
+        assert archive.user_verdict is None
+        assert archive.confirm_token_used_at is None
+
+    def test_confirm_links_exempt_from_auth_middleware(self):
+        """The one-tap links are tapped on a phone with no session, so the
+        global auth middleware must step aside for them — the capability token
+        in the path is the credential. Pins the PUBLIC_API_PREFIXES entry;
+        without it, enabling authentication 401s every verdict link."""
+        from backend.app.main import PUBLIC_API_PREFIXES
+
+        assert "/api/v1/archives/confirm/" in PUBLIC_API_PREFIXES
+
+
+class TestDefaultGoodOnPlateClear:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_helper_resolves_only_the_latest_pending(self, archive_factory, printer_factory, db_session):
+        """Releasing the plate refers to the print that just came off it: only
+        the LATEST pending archive flips to good; older unanswered prompts and
+        already-answered ones stay untouched. The verdict mirrors to the run."""
+        from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good
+
+        printer = await printer_factory()
+        older = await archive_factory(printer.id, confirm_requested=True)
+        newest_pending = await archive_factory(printer.id, confirm_requested=True, confirm_token="pending-token")
+        answered = await archive_factory(printer.id, confirm_requested=True, user_verdict="reject")
+
+        resolved = await resolve_pending_confirmation_as_good(db_session, printer.id)
+        await db_session.commit()
+
+        assert resolved == newest_pending.id
+        await db_session.refresh(newest_pending)
+        assert newest_pending.user_verdict == "good"
+        assert newest_pending.user_verdict_source == "plate_clear"
+        assert newest_pending.confirm_token == "pending-token"
+        assert newest_pending.confirm_token_used_at is not None
+        await db_session.refresh(older)
+        assert older.user_verdict is None
+        await db_session.refresh(answered)
+        assert answered.user_verdict == "reject"
+
+        entry = await db_session.scalar(
+            select(PrintLogEntry).where(PrintLogEntry.archive_id == newest_pending.id).order_by(PrintLogEntry.id.desc())
+        )
+        assert entry is not None and entry.user_verdict == "good"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_helper_noop_without_pending(self, archive_factory, printer_factory, db_session):
+        from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good
+
+        printer = await printer_factory()
+        await archive_factory(printer.id)  # completed, never asked
+
+        assert await resolve_pending_confirmation_as_good(db_session, printer.id) is None
+
+
+class TestVerdictStatistics:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_failure_analysis_reports_rejects_separately(
+        self, async_client: AsyncClient, archive_factory, printer_factory
+    ):
+        """A completed-but-rejected print stays OUT of the machine failure
+        rate but shows up as rejected_prints and lowers the yield rate."""
+        printer = await printer_factory()
+        good = await archive_factory(printer.id, print_name="Good Part")
+        rejected = await archive_factory(printer.id, print_name="Scrap Part")
+        await archive_factory(printer.id, print_name="Machine Failure", status="failed", run_status="failed")
+
+        # Confirm one good, reject one — through the API so the mirror runs.
+        assert (
+            await async_client.patch(f"/api/v1/archives/{good.id}", json={"user_verdict": "good"})
+        ).status_code == 200
+        assert (
+            await async_client.patch(
+                f"/api/v1/archives/{rejected.id}", json={"user_verdict": "reject", "failure_reason": "other"}
+            )
+        ).status_code == 200
+
+        response = await async_client.get("/api/v1/archives/analysis/failures?days=30")
+        assert response.status_code == 200
+        body = response.json()
+
+        # Machine numbers unchanged by the quality dimension:
+        assert body["failed_prints"] == 1
+        assert body["failure_rate"] == pytest.approx(33.3, abs=0.1)
+        # Quality dimension:
+        assert body["rejected_prints"] == 1
+        assert body["yield_rate"] == pytest.approx(33.3, abs=0.1)
+        assert body["rejects_by_reason"] == {"other": 1}
+
+
+class TestVerdictSource:
+    """How a verdict came in (#1898) — every writer on this branch stamps it."""
+
+    def test_every_source_has_a_phrase_for_the_already_answered_page(self):
+        """Including 'reaction', which the Telegram reaction work (#3046) will
+        write from its own branch: the page that explains a spent link must not
+        fall silent the day it lands."""
+        from backend.app.api.routes.archives import _VERDICT_SOURCE_PHRASES
+        from backend.app.services.print_confirmation import VERDICT_SOURCES
+
+        assert set(VERDICT_SOURCES) == set(_VERDICT_SOURCE_PHRASES)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_response_exposes_the_source(self, async_client: AsyncClient, archive_factory, printer_factory):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True)
+
+        assert (await async_client.get(f"/api/v1/archives/{archive.id}")).json()["user_verdict_source"] is None
+
+        assert (
+            await async_client.patch(
+                f"/api/v1/archives/{archive.id}", json={"user_verdict": "good", "user_verdict_source": "dialog"}
+            )
+        ).status_code == 200
+        body = (await async_client.get(f"/api/v1/archives/{archive.id}")).json()
+        assert body["user_verdict"] == "good"
+        assert body["user_verdict_source"] == "dialog"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_printer_card_and_bare_patch_sources(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        printer = await printer_factory()
+        from_card = await archive_factory(printer.id, confirm_requested=True)
+        from_script = await archive_factory(printer.id, confirm_requested=True)
+
+        await async_client.patch(
+            f"/api/v1/archives/{from_card.id}", json={"user_verdict": "good", "user_verdict_source": "printer_card"}
+        )
+        # No source claimed: some script or integration did it.
+        await async_client.patch(f"/api/v1/archives/{from_script.id}", json={"user_verdict": "reject"})
+
+        await db_session.refresh(from_card)
+        await db_session.refresh(from_script)
+        assert from_card.user_verdict_source == "printer_card"
+        assert from_script.user_verdict_source == "api"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_server_owned_sources_cannot_be_claimed_over_the_api(
+        self, async_client: AsyncClient, archive_factory, printer_factory
+    ):
+        """'link', 'plate_clear' and 'reaction' are stamped by the paths that
+        own them — a PATCH may not forge them."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True)
+
+        for forged in ("link", "plate_clear", "reaction", "nonsense"):
+            response = await async_client.patch(
+                f"/api/v1/archives/{archive.id}", json={"user_verdict": "good", "user_verdict_source": forged}
+            )
+            assert response.status_code == 422, forged
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_page_dates_the_verdict_on_file_not_the_spent_token(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """A verdict changed later must not be reported with the older decision's time.
+
+        The live case: the plate-clear default answers a print, the operator
+        changes the verdict in the app an hour later, then the old Telegram link
+        is tapped. Reporting the new verdict with the old timestamp would
+        describe two different events as one.
+        """
+        from datetime import datetime, timedelta, timezone
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="dates-token")
+
+        await async_client.patch(
+            f"/api/v1/archives/{archive.id}", json={"user_verdict": "good", "user_verdict_source": "dialog"}
+        )
+        await db_session.refresh(archive)
+        first_at = archive.user_verdict_at
+        assert first_at is not None
+        # Age the first decision so the two timestamps cannot coincide.
+        archive.user_verdict_at = first_at - timedelta(hours=1)
+        archive.confirm_token_used_at = first_at - timedelta(hours=1)
+        await db_session.commit()
+
+        await async_client.patch(
+            f"/api/v1/archives/{archive.id}", json={"user_verdict": "reject", "user_verdict_source": "dialog"}
+        )
+        await db_session.refresh(archive)
+        assert archive.user_verdict == "reject"
+        assert archive.user_verdict_at > archive.confirm_token_used_at
+
+        page = await async_client.get(f"/api/v1/archives/confirm/{archive.confirm_token}/good")
+        assert page.status_code == 200
+
+        # SQLite hands back naive datetimes; they are already UTC, which is how
+        # the route formats them too.
+        def _as_shown(value):
+            if value.tzinfo is not None:
+                value = value.astimezone(timezone.utc)
+            return value.strftime("%Y-%m-%d %H:%M UTC")
+
+        shown = _as_shown(archive.user_verdict_at)
+        stale = _as_shown(archive.confirm_token_used_at)
+        assert shown in page.text
+        assert stale not in page.text
+        # And the link still changed nothing.
+        await db_session.refresh(archive)
+        assert archive.user_verdict == "reject"
+
+    async def test_a_re_sent_prompt_carries_a_live_token(self, archive_factory, printer_factory, db_session):
+        """A spent token must never be re-used for a new prompt.
+
+        Since a verdict keeps the token value on the row, "has a token" stopped
+        meaning "answerable": without minting a fresh one, every button in the
+        new message would land on the already-answered page.
+        """
+        from backend.app.main import dispatch_outcome_confirmation
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="spent-token")
+        from backend.app.services.print_confirmation import retire_confirm_token
+
+        retire_confirm_token(archive)
+        await db_session.commit()
+        assert archive.confirm_token_used_at is not None
+
+        await dispatch_outcome_confirmation(db_session, printer.id, printer.name, {}, archive.id, None)
+
+        await db_session.refresh(archive)
+        assert archive.confirm_token != "spent-token"
+        assert archive.confirm_token_used_at is None
+
+    async def test_clearing_the_verdict_drops_the_source(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="drop-source-token")
+
+        await async_client.patch(
+            f"/api/v1/archives/{archive.id}", json={"user_verdict": "good", "user_verdict_source": "dialog"}
+        )
+        await async_client.patch(f"/api/v1/archives/{archive.id}", json={"user_verdict": None})
+
+        await db_session.refresh(archive)
+        assert archive.user_verdict is None
+        assert archive.user_verdict_source is None
+        # ...but the capability stays spent.
+        assert archive.confirm_token_used_at is not None

+ 323 - 0
backend/tests/unit/test_confirm_link_unattended_fetch_1898.py

@@ -0,0 +1,323 @@
+"""A machine must not be able to spend a one-tap verdict token (#1898).
+
+Telegram and Slack fetch the URLs in a message to build a preview card, mail
+gateways detonate links before delivery, and browsers prefetch. While the
+verdict URLs went out as plain text in the notification body -- which is what
+``DEFAULT_TEMPLATES['print_confirm_request']`` used to ship -- any one of those
+GETs recorded a verdict nobody chose and retired the token, so the operator's
+real tap landed on "already answered" and a scrap part counted as good for the
+rest of time.
+
+Four defences, in the order they matter. The capability URLs no longer travel
+in body text at all. Recording is a POST, so a GET from anything that walks a
+URL changes nothing (the route side of that is in the integration tests). The
+confirmation page submits its own form only for a URL carrying the one-tap
+marker, which rides on the Telegram inline keyboard and on nothing a machine
+can read — so the scanners that render HTML and run JavaScript, which send an
+ordinary Chrome string and which no User-Agent list can name, get a page with a
+button on it. And the channels that build previews are asked not to, on the one
+message whose links are capabilities.
+
+The unattended-fetch heuristic is the fifth and the weakest, which is why it is
+no longer the only thing in front of the write.
+"""
+
+import json
+
+import httpx
+import pytest
+
+from backend.app.models.notification import NotificationProvider
+from backend.app.models.notification_template import DEFAULT_TEMPLATES
+from backend.app.services.notification_service import NotificationService
+from backend.app.services.print_confirmation import is_one_tap_request, is_unattended_fetch, one_tap_url
+
+CONFIG = {"bot_token": "123456:AAbbCC", "chat_id": "-1002520100736"}
+
+# Real link-preview and mail-security fetchers, plus the browsers that must
+# keep working. The phone UAs are the ones a notification tap actually opens.
+BROWSER_AGENTS = [
+    "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 "
+    "(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
+    "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) "
+    "Chrome/126.0.0.0 Mobile Safari/537.36",
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
+    "Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0",
+    # The ntfy app performs its action buttons itself, with an HTTP client UA.
+    "okhttp/4.12.0",
+    "python-httpx/0.27.0",
+]
+
+MACHINE_AGENTS = [
+    "TelegramBot (like TwitterBot)",
+    "Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)",
+    "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)",
+    "facebookexternalhit/1.1",
+    "Twitterbot/1.0",
+    "WhatsApp/2.23.20.0 A",
+    "Mozilla/5.0 (compatible; SkypeUriPreview Preview/0.5)",
+    "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
+    "Barracuda Sentinel (EE)",
+    "Mimecast-Link-Protect",
+]
+
+
+class _Client:
+    """Stand-in for httpx.AsyncClient that records what would be sent."""
+
+    def __init__(self):
+        self.is_closed = False
+        self.calls: list[dict] = []
+
+    async def post(self, url, data=None, files=None, json=None, headers=None):
+        self.calls.append(json if json is not None else (data or {}))
+        return httpx.Response(200, json={"ok": True, "result": {}})
+
+
+class TestUnattendedFetchDetection:
+    @pytest.mark.parametrize("agent", BROWSER_AGENTS)
+    def test_a_real_browser_is_left_alone(self, agent):
+        assert is_unattended_fetch("GET", {"user-agent": agent}) is False
+
+    @pytest.mark.parametrize("agent", MACHINE_AGENTS)
+    def test_link_fetchers_are_recognised(self, agent):
+        assert is_unattended_fetch("GET", {"user-agent": agent}) is True
+
+    def test_prefetch_hints_count_even_from_a_browser_ua(self):
+        """Chrome and Firefox preload links before they are clicked; Safari's
+        preview sends the same hint. A finger on a button never does."""
+        browser = BROWSER_AGENTS[1]
+        assert is_unattended_fetch("GET", {"user-agent": browser, "purpose": "prefetch"}) is True
+        assert is_unattended_fetch("GET", {"user-agent": browser, "x-purpose": "preview"}) is True
+        assert is_unattended_fetch("GET", {"user-agent": browser, "x-moz": "prefetch"}) is True
+        assert is_unattended_fetch("GET", {"user-agent": browser, "sec-purpose": "prefetch;prerender"}) is True
+
+    def test_head_is_never_a_tap(self):
+        """Only the GET route renders the page, so only a GET can be widened
+        to HEAD by a future router change. A probe must not come back looking
+        like a page load."""
+        assert is_unattended_fetch("HEAD", {"user-agent": BROWSER_AGENTS[0]}) is True
+
+    def test_a_missing_user_agent_is_not_held_against_the_caller(self):
+        """Some notification clients send none; the token is still the
+        credential, and guessing here would cost real verdicts."""
+        assert is_unattended_fetch("GET", {}) is False
+
+
+class TestPromptKeepsTheCapabilityOutOfBodyText:
+    """This file used to pin the opposite: that both verdict URLs were in the
+    body. They were, and that was the defect — the body is the one part of a
+    notification that every unfurler, gateway and proxy reads. The capability
+    links now travel only in affordances nothing prefetches."""
+
+    def test_the_default_template_carries_only_the_deep_link(self):
+        template = next(t for t in DEFAULT_TEMPLATES if t["event_type"] == "print_confirm_request")
+        assert "{good_url}" not in template["body_template"]
+        assert "{reject_url}" not in template["body_template"]
+        assert "{confirm_url}" in template["body_template"]
+
+
+class TestNtfyButtonsRecordOverPost:
+    @pytest.mark.asyncio
+    async def test_the_action_buttons_use_post(self):
+        """The ntfy app issues the action itself, so it can use the method that
+        records. A GET would only open the confirmation page — which is the
+        point of the split, and is what every crawler gets."""
+        service = NotificationService()
+        captured: dict = {}
+
+        async def _fake_ntfy(config, title, message, image_data=None, event_type=None, actions=None):
+            captured["actions"] = actions
+            return True, "ok"
+
+        service._send_ntfy = _fake_ntfy
+        provider = NotificationProvider(
+            name="farm", provider_type="ntfy", config=json.dumps({"server": "https://ntfy.sh", "topic": "farm"})
+        )
+
+        ok, _ = await service._send_to_provider(
+            provider,
+            "How did your print come out?",
+            "X1C: bracket.3mf",
+            event_type="print_confirm_request",
+            variables={
+                "good_url": "https://farm.example.com/api/v1/archives/confirm/tok/good",
+                "reject_url": "https://farm.example.com/api/v1/archives/confirm/tok/reject",
+            },
+        )
+
+        assert ok
+        assert "method=POST" in captured["actions"]
+        assert "method=GET" not in captured["actions"]
+
+
+class TestTheOneTapMarkerRidesOnlyOnButtons:
+    """The marker is what decides whether the confirmation page submits itself,
+    and it is on the Telegram inline keyboard and nowhere else.
+
+    The User-Agent list above catches the fetchers that say what they are, and
+    none of those run JavaScript. The ones that do — a mail-security sandbox
+    detonating the link, a browser-isolation proxy — send an ordinary Chrome
+    string, so no list can name them. What they cannot have is a URL that never
+    appeared in any text they can read.
+    """
+
+    def test_a_marked_url_is_the_only_thing_that_opens_the_script(self):
+        assert is_one_tap_request({"tap": "1"}) is True
+        assert is_one_tap_request({}) is False, "a link out of a message body must not qualify"
+        assert is_one_tap_request({"tap": "0"}) is False
+        assert is_one_tap_request({"tap": "yes"}) is False
+
+    def test_marking_a_url_that_already_carries_a_query(self):
+        assert one_tap_url("https://host/api/v1/archives/confirm/tok/good") == (
+            "https://host/api/v1/archives/confirm/tok/good?tap=1"
+        )
+        assert one_tap_url("https://host/confirm/tok/good?from=ntfy") == "https://host/confirm/tok/good?from=ntfy&tap=1"
+
+    @pytest.mark.asyncio
+    async def test_the_telegram_buttons_are_marked_and_the_body_link_is_not(self):
+        """Telegram cannot POST, so its buttons are the one affordance that
+        opens a browser — and the one that needs the page to submit itself.
+        Telegram never fetches an inline-keyboard URL, so the marker does not
+        leak into anything a machine reads."""
+        service = NotificationService()
+        captured: dict = {}
+
+        async def _fake_telegram(config, message, image_data=None, buttons=None):
+            captured["buttons"] = buttons
+            captured["message"] = message
+            return True, "ok"
+
+        service._send_telegram = _fake_telegram
+        provider = NotificationProvider(name="farm", provider_type="telegram", config=json.dumps(CONFIG))
+
+        ok, _ = await service._send_to_provider(
+            provider,
+            "How did your print come out?",
+            "X1C: bracket.3mf\nGood: https://farm.example.com/api/v1/archives/confirm/tok/good",
+            event_type="print_confirm_request",
+            variables={
+                "good_url": "https://farm.example.com/api/v1/archives/confirm/tok/good",
+                "reject_url": "https://farm.example.com/api/v1/archives/confirm/tok/reject",
+            },
+        )
+
+        assert ok
+        assert [b["url"] for b in captured["buttons"]] == [
+            "https://farm.example.com/api/v1/archives/confirm/tok/good?tap=1",
+            "https://farm.example.com/api/v1/archives/confirm/tok/reject?tap=1",
+        ]
+        # An install that kept {good_url} in its edited body still sends the
+        # plain URL in the text — and that is exactly the one that must not
+        # press its own button when a scanner opens it.
+        assert "?tap=1" not in captured["message"]
+
+    @pytest.mark.asyncio
+    async def test_the_ntfy_actions_stay_unmarked(self):
+        """They POST, so no page is rendered and there is nothing to submit.
+        Marking them would put the marker in an Authorization-free HTTP header
+        for no gain."""
+        service = NotificationService()
+        captured: dict = {}
+
+        async def _fake_ntfy(config, title, message, image_data=None, event_type=None, actions=None):
+            captured["actions"] = actions
+            return True, "ok"
+
+        service._send_ntfy = _fake_ntfy
+        provider = NotificationProvider(
+            name="farm", provider_type="ntfy", config=json.dumps({"server": "https://ntfy.sh", "topic": "farm"})
+        )
+
+        ok, _ = await service._send_to_provider(
+            provider,
+            "How did your print come out?",
+            "X1C: bracket.3mf",
+            event_type="print_confirm_request",
+            variables={
+                "good_url": "https://farm.example.com/api/v1/archives/confirm/tok/good",
+                "reject_url": "https://farm.example.com/api/v1/archives/confirm/tok/reject",
+            },
+        )
+
+        assert ok
+        assert "tap=1" not in captured["actions"]
+
+
+class TestSlackDoesNotUnfurl:
+    @pytest.mark.asyncio
+    async def test_the_slack_payload_turns_previews_off(self):
+        """Slack and Mattermost unfurl the URLs in `text` the same way
+        Telegram builds a preview card."""
+        service = NotificationService()
+        client = _Client()
+        service._http_client = client
+
+        ok, _ = await service._send_webhook(
+            {"webhook_url": "https://hooks.slack.example.com/services/T/B/x", "payload_format": "slack"},
+            "How did your print come out?",
+            "X1C: bracket.3mf",
+            event_type="print_confirm_request",
+        )
+
+        assert ok
+        assert client.calls[0]["unfurl_links"] is False
+        assert client.calls[0]["unfurl_media"] is False
+
+    @pytest.mark.asyncio
+    async def test_every_other_event_keeps_its_previews(self):
+        """Only the outcome prompt's links are capabilities. The slack payload
+        never attaches image bytes — the base64 attach is generic-format only —
+        so the unfurl is the only way a {finish_photo_url} in a print_complete
+        body ever becomes a photo in the channel, and there is no setting that
+        turns it back on."""
+        service = NotificationService()
+        client = _Client()
+        service._http_client = client
+
+        ok, _ = await service._send_webhook(
+            {"webhook_url": "https://hooks.slack.example.com/services/T/B/x", "payload_format": "slack"},
+            "Print complete",
+            "X1C: bracket.3mf\nhttps://farm.example.com/api/v1/archives/7/photos/finish_a.jpg",
+            event_type="print_complete",
+        )
+
+        assert ok
+        assert "unfurl_links" not in client.calls[0]
+        assert "unfurl_media" not in client.calls[0]
+
+
+class TestTelegramDoesNotAskForAPreview:
+    @pytest.mark.asyncio
+    async def test_send_message_disables_the_link_preview(self):
+        """Telegram's servers GET the first URL in the text to build the
+        preview card. That fetch is the one that answered the prompt."""
+        service = NotificationService()
+        client = _Client()
+        service._http_client = client
+
+        ok, _ = await service._send_telegram(
+            CONFIG,
+            "*How did your print come out?*\nX1C: bracket.3mf\nGood: https://host/api/v1/archives/confirm/tok/good",
+        )
+
+        assert ok
+        assert client.calls[0]["disable_web_page_preview"] is True
+
+    @pytest.mark.asyncio
+    async def test_the_inline_buttons_variant_disables_it_too(self):
+        """The shape the outcome prompt actually sends on a camera-less
+        printer: no photo, so sendMessage rather than sendPhoto."""
+        service = NotificationService()
+        client = _Client()
+        service._http_client = client
+
+        buttons = [
+            {"text": "Good", "url": "https://host/api/v1/archives/confirm/tok/good"},
+            {"text": "Reject", "url": "https://host/api/v1/archives/confirm/tok/reject"},
+        ]
+        ok, _ = await service._send_telegram(CONFIG, "*T*\nbody", buttons=buttons)
+
+        assert ok
+        assert client.calls[0]["disable_web_page_preview"] is True
+        assert client.calls[0]["reply_markup"] == {"inline_keyboard": [buttons]}

+ 226 - 0
backend/tests/unit/test_confirm_token_retirement_migration_1898.py

@@ -0,0 +1,226 @@
+"""Migration coverage for the one-tap token columns, index and prompt body (#1898).
+
+``confirm_token_used_at`` is what turns a spent link into "already answered"
+instead of a 404, and ``user_verdict_source`` is what the hint next to the
+verdict badge reads — both have to reach an install that upgraded rather than
+one created fresh from the models. So does the index on ``confirm_token``: the
+route that reads it runs with no authentication, so without the index anyone
+who can reach the host turns a stream of invented tokens into a stream of full
+scans of print_archives.
+"""
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+import backend.app.models.external_link  # noqa: F401 - required by a legacy ALTER in run_migrations
+import backend.app.models.print_log  # noqa: F401 - required by a legacy ALTER in run_migrations
+import backend.app.models.virtual_printer  # noqa: F401 - required by a legacy ALTER in run_migrations
+from backend.app.core.database import Base, run_migrations
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    """run_migrations branches on the global dialect, not on the connection, so
+    a dev config pointing at Postgres would run Postgres-only syntax against the
+    SQLite engine below. Same fixture as test_billing_run_id_migration.py."""
+    from backend.app.core import db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    # database.py imported is_sqlite at module load time — patch there too.
+    from backend.app.core import database as database_module
+
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+async def _archive_columns(conn) -> set[str]:
+    return {row[1] for row in (await conn.execute(text("PRAGMA table_info(print_archives)"))).all()}
+
+
+@pytest.mark.asyncio
+async def test_retirement_columns_are_added_and_migration_is_idempotent(tmp_path):
+    engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'confirm-retirement.db'}")
+    try:
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+            # Simulate the pre-#1898-follow-up schema: the columns only exist
+            # here because create_all built the table from today's models.
+            await conn.execute(text("ALTER TABLE print_archives DROP COLUMN confirm_token_used_at"))
+            await conn.execute(text("ALTER TABLE print_archives DROP COLUMN user_verdict_source"))
+            assert "confirm_token_used_at" not in await _archive_columns(conn)
+
+            await run_migrations(conn)
+            columns = await _archive_columns(conn)
+            assert "confirm_token_used_at" in columns
+            assert "user_verdict_source" in columns
+
+            # Re-running the migrations on an already-migrated install is a
+            # no-op, not an error: _safe_execute swallows the duplicate ALTER.
+            await run_migrations(conn)
+            assert await _archive_columns(conn) >= {"confirm_token_used_at", "user_verdict_source"}
+
+            # ...with the SQLite-flavoured types the ALTERs declare, so a
+            # stamp round-trips as a datetime rather than as opaque text.
+            types = {
+                row[1]: row[2].upper() for row in (await conn.execute(text("PRAGMA table_info(print_archives)"))).all()
+            }
+            assert types["confirm_token_used_at"] == "DATETIME"
+            assert types["user_verdict_source"].startswith("VARCHAR")
+    finally:
+        await engine.dispose()
+
+
+async def _archive_indexes(conn) -> dict[str, bool]:
+    """Index name -> whether it is UNIQUE, for print_archives."""
+    rows = (await conn.execute(text("PRAGMA index_list(print_archives)"))).all()
+    return {row[1]: bool(row[2]) for row in rows}
+
+
+def test_the_model_declares_the_index():
+    """A fresh install gets its schema from the models, not from
+    run_migrations, so the declaration is half the fix."""
+    from backend.app.models.archive import PrintArchive
+
+    column = PrintArchive.__table__.c.confirm_token
+    assert column.index is True
+    assert column.unique is True
+
+
+@pytest.mark.asyncio
+async def test_confirm_requested_is_nullable_on_both_kinds_of_install(tmp_path):
+    """A fresh database and an upgraded one must describe the column the same way.
+
+    ``ALTER TABLE ... ADD COLUMN confirm_requested BOOLEAN DEFAULT FALSE`` cannot
+    carry NOT NULL, so an upgraded install has a nullable column. The model has
+    to agree, or every install created from it has a stricter table than every
+    install that grew into it -- and the difference only ever shows up as an
+    IntegrityError on somebody else's machine.
+    """
+    engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'confirm-nullable.db'}")
+    try:
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+
+            def notnull(rows):
+                return {row[1]: row[3] for row in rows}
+
+            fresh = notnull((await conn.execute(text("PRAGMA table_info(print_archives)"))).all())
+            assert fresh["confirm_requested"] == 0
+
+            # ...and the column an upgrade adds, for comparison.
+            await conn.execute(text("ALTER TABLE print_archives DROP COLUMN confirm_requested"))
+            await run_migrations(conn)
+            upgraded = notnull((await conn.execute(text("PRAGMA table_info(print_archives)"))).all())
+            assert upgraded["confirm_requested"] == fresh["confirm_requested"]
+    finally:
+        await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_the_confirm_token_index_reaches_an_upgraded_install(tmp_path):
+    engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'confirm-token-index.db'}")
+    try:
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+            # An install that upgraded from before the index: the column is
+            # there, the index is not.
+            await conn.execute(text("DROP INDEX ix_print_archives_confirm_token"))
+            assert "ix_print_archives_confirm_token" not in await _archive_indexes(conn)
+
+            await run_migrations(conn)
+            indexes = await _archive_indexes(conn)
+            assert "ix_print_archives_confirm_token" in indexes
+            assert indexes["ix_print_archives_confirm_token"] is True, "must be UNIQUE"
+
+            # Re-running is a no-op, not an error.
+            await run_migrations(conn)
+            assert "ix_print_archives_confirm_token" in await _archive_indexes(conn)
+
+            # Most archives never get a token, so the unique index has to
+            # tolerate any number of NULLs — otherwise the second archive on a
+            # fresh install would fail to insert.
+            from backend.app.models.archive import PrintArchive
+
+            rows = [
+                {"filename": "a.3mf", "file_path": "a", "file_size": 1, "status": "completed"},
+                {"filename": "b.3mf", "file_path": "b", "file_size": 1, "status": "completed"},
+            ]
+            await conn.execute(PrintArchive.__table__.insert(), rows)
+            stored = (await conn.execute(text("SELECT confirm_token FROM print_archives"))).scalars().all()
+            assert stored == [None, None]
+    finally:
+        await engine.dispose()
+
+
+OLD_CONFIRM_BODY = "{printer}: {filename}\nGood: {good_url}\nReject: {reject_url}"
+
+
+async def _confirm_body(conn) -> str | None:
+    return (
+        await conn.execute(
+            text("SELECT body_template FROM notification_templates WHERE event_type = 'print_confirm_request'")
+        )
+    ).scalar_one_or_none()
+
+
+@pytest.mark.asyncio
+async def test_the_capability_urls_leave_the_prompt_body_on_an_upgraded_install(tmp_path):
+    """Seeding only inserts templates that are missing, so an install already
+    running this feature would keep the shape the review found: both single-use
+    verdict URLs as plain text in every channel's message body, where the
+    unfurler that answers the prompt reads them."""
+    engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'confirm-body.db'}")
+    try:
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+            await conn.execute(
+                text(
+                    "INSERT INTO notification_templates (event_type, name, title_template, body_template, is_default) "
+                    "VALUES ('print_confirm_request', 'Print Outcome Confirmation', "
+                    "'How did your print come out?', :body, 1)"
+                ),
+                {"body": OLD_CONFIRM_BODY},
+            )
+
+            await run_migrations(conn)
+
+            body = await _confirm_body(conn)
+            assert body is not None
+            assert "{good_url}" not in body
+            assert "{reject_url}" not in body
+            assert "{confirm_url}" in body
+
+            # Idempotent, and it agrees with what a fresh install seeds.
+            await run_migrations(conn)
+            from backend.app.models.notification_template import DEFAULT_TEMPLATES
+
+            seeded = next(t for t in DEFAULT_TEMPLATES if t["event_type"] == "print_confirm_request")
+            assert await _confirm_body(conn) == seeded["body_template"]
+    finally:
+        await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_a_template_the_admin_edited_is_left_alone(tmp_path):
+    """Same guard as the two template renames: match the old default verbatim
+    or do not touch the row."""
+    engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'confirm-body-custom.db'}")
+    custom = "Plate off {printer}? {good_url}"
+    try:
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+            await conn.execute(
+                text(
+                    "INSERT INTO notification_templates (event_type, name, title_template, body_template, is_default) "
+                    "VALUES ('print_confirm_request', 'Mine', 'Well?', :body, 0)"
+                ),
+                {"body": custom},
+            )
+
+            await run_migrations(conn)
+
+            assert await _confirm_body(conn) == custom
+    finally:
+        await engine.dispose()

+ 1 - 0
backend/tests/unit/test_print_log.py

@@ -180,6 +180,7 @@ class TestSchemaValidatesFromOrmRow:
         # Non-null so `test_every_declared_field_is_carried` can assert that
         # nothing falls back to its default.
         row.failure_reason = "warping"
+        row.user_verdict = "good"
         row.thumbnail_path = "archives/1/x/thumbnail.png"
         row.created_by_id = 2
         row.created_by_username = "martin"

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

@@ -91,6 +91,12 @@ _PUBLIC_ROUTES: frozenset[tuple[str, str]] = frozenset(
         # Attached archive media target — validates a token bound to the
         # archive ID before returning the local timelapse.
         ("GET", "/api/v1/archives/{archive_id}/media/dl/{token}/{filename}"),
+        # Outcome-confirmation link (#1898) — the single-use capability token in
+        # the path is the credential; it is retired on the first verdict and
+        # only ever sets one verdict on one archive. The GET renders the
+        # confirmation page and writes nothing; the POST behind it records.
+        ("GET", "/api/v1/archives/confirm/{token}/{verdict}"),
+        ("POST", "/api/v1/archives/confirm/{token}/{verdict}"),
         # Obico cached frame — one-time nonce embedded in <img> tags.
         ("GET", "/api/v1/obico/cached-frame/{nonce}"),
         # MakerWorld thumbnail proxy — fetches external URL; no Bambuddy data exposed.

+ 103 - 0
backend/tests/unit/test_telegram_outcome_buttons_1898.py

@@ -0,0 +1,103 @@
+"""The outcome prompt must arrive even when Telegram refuses its buttons (#1898).
+
+The Good / Reject affordance on Telegram is an inline URL keyboard. Telegram
+validates every button URL and rejects the whole ``sendMessage`` when one of
+them is not a URL it accepts -- which is what an install without a public
+``external_url`` produces. Losing the buttons is survivable; losing the message
+the user is waiting for is not, and "I never got a message" is precisely the
+report this feature keeps drawing.
+"""
+
+import httpx
+import pytest
+
+from backend.app.services.notification_service import NotificationService
+
+CONFIG = {"bot_token": "123456:AAbbCC", "chat_id": "-1002520100736"}
+BUTTONS = [
+    {"text": "\U0001f44d Good", "url": "http://bambuddy.local:8000/api/v1/archives/confirm/tok/good"},
+    {"text": "\U0001f44e Reject", "url": "http://bambuddy.local:8000/api/v1/archives/confirm/tok/reject"},
+]
+PNG = b"\x89PNG\r\n\x1a\n"
+
+
+class _Client:
+    """Stand-in for httpx.AsyncClient that rejects any send carrying buttons."""
+
+    def __init__(self, reject_buttons: bool):
+        self.is_closed = False
+        self.reject_buttons = reject_buttons
+        self.calls: list[dict] = []
+
+    async def post(self, url, data=None, files=None, json=None):
+        body = json if json is not None else (data or {})
+        self.calls.append(body)
+        if self.reject_buttons and "reply_markup" in body:
+            return httpx.Response(
+                400, json={"ok": False, "description": "Bad Request: inline keyboard button URL is invalid"}
+            )
+        return httpx.Response(200, json={"ok": True, "result": {}})
+
+
+def _service(reject_buttons: bool) -> tuple[NotificationService, _Client]:
+    service = NotificationService()
+    client = _Client(reject_buttons)
+    service._http_client = client  # bypass real HTTP
+    return service, client
+
+
+@pytest.mark.asyncio
+async def test_a_rejected_keyboard_falls_back_to_a_plain_message():
+    service, client = _service(reject_buttons=True)
+
+    ok, detail = await service._send_telegram(CONFIG, "*How did your print come out?*\nbody", buttons=BUTTONS)
+
+    assert ok, detail
+    assert len(client.calls) == 2, "expected one attempt with buttons and one without"
+    assert "reply_markup" in client.calls[0]
+    assert "reply_markup" not in client.calls[1]
+    assert client.calls[1]["text"].endswith("body")
+
+
+@pytest.mark.asyncio
+async def test_the_photo_variant_falls_back_too():
+    """The finish photo rides along with the prompt, so this is the shape most
+    completed prints actually send."""
+    service, client = _service(reject_buttons=True)
+
+    ok, _ = await service._send_telegram(CONFIG, "*T*\nbody", image_data=PNG, buttons=BUTTONS)
+
+    assert ok
+    assert len(client.calls) == 2
+    assert "reply_markup" not in client.calls[1]
+
+
+@pytest.mark.asyncio
+async def test_buttons_are_kept_when_telegram_accepts_them():
+    service, client = _service(reject_buttons=False)
+
+    ok, _ = await service._send_telegram(CONFIG, "*T*\nbody", buttons=BUTTONS)
+
+    assert ok
+    assert len(client.calls) == 1
+    assert client.calls[0]["reply_markup"] == {"inline_keyboard": [BUTTONS]}
+
+
+@pytest.mark.asyncio
+async def test_a_buttonless_failure_is_still_reported():
+    """The retry exists for the keyboard, not to paper over a bad token."""
+
+    class _AlwaysFails(_Client):
+        async def post(self, url, data=None, files=None, json=None):
+            self.calls.append(json if json is not None else (data or {}))
+            return httpx.Response(401, text="Unauthorized")
+
+    service = NotificationService()
+    client = _AlwaysFails(reject_buttons=False)
+    service._http_client = client
+
+    ok, detail = await service._send_telegram(CONFIG, "*T*\nbody")
+
+    assert ok is False
+    assert "401" in detail
+    assert len(client.calls) == 1

+ 179 - 0
frontend/src/__tests__/components/ConfirmOutcomeDialog.test.tsx

@@ -0,0 +1,179 @@
+/**
+ * Tests for the post-print outcome confirmation dialog (#1898).
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { ConfirmOutcomeDialog } from '../../components/ConfirmOutcomeDialog';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+// The reject flow can hand off into the full PrintModal — not under test here.
+vi.mock('../../components/PrintModal', () => ({
+  PrintModal: () => <div data-testid="print-modal" />,
+}));
+
+const baseArchive = {
+  id: 42,
+  printer_id: 1,
+  filename: 'bracket.gcode.3mf',
+  file_path: 'archives/bracket.gcode.3mf',
+  file_size: 1024,
+  content_hash: null,
+  thumbnail_path: null,
+  timelapse_path: null,
+  print_name: 'Bracket',
+  print_time_seconds: 3600,
+  filament_used_grams: 20,
+  filament_type: 'PLA',
+  filament_color: null,
+  layer_height: 0.2,
+  total_layers: 100,
+  nozzle_diameter: 0.4,
+  bed_temperature: 60,
+  nozzle_temperature: 220,
+  status: 'completed',
+  started_at: null,
+  completed_at: null,
+  extra_data: null,
+  makerworld_url: null,
+  designer: null,
+  external_url: null,
+  is_favorite: false,
+  tags: null,
+  notes: null,
+  cost: null,
+  photos: ['finish_1.jpg'],
+  failure_reason: null,
+  user_verdict: null,
+  user_verdict_source: null,
+  confirm_requested: true,
+  quantity: 1,
+  energy_kwh: null,
+  energy_cost: null,
+  created_at: '2026-09-01T00:00:00Z',
+  created_by_id: null,
+  created_by_username: null,
+  run_count: 1,
+  last_run_at: null,
+  total_filament_actual_grams: null,
+  successful_run_count: 1,
+  failed_run_count: 0,
+};
+
+describe('ConfirmOutcomeDialog', () => {
+  let patchPayload: Record<string, unknown> | null;
+
+  beforeEach(() => {
+    patchPayload = null;
+    server.use(
+      http.get('/api/v1/archives/42', () => HttpResponse.json(baseArchive)),
+      http.patch('/api/v1/archives/42', async ({ request }) => {
+        patchPayload = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json({ ...baseArchive, ...patchPayload });
+      }),
+      http.get('/api/v1/settings/', () => HttpResponse.json({})),
+    );
+  });
+
+  it('shows the finish photo and records a good verdict', async () => {
+    const user = userEvent.setup();
+    const onClose = vi.fn();
+    render(<ConfirmOutcomeDialog archiveId={42} onClose={onClose} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Bracket')).toBeInTheDocument();
+    });
+    // Finish photo (photos[0]) is rendered
+    expect(screen.getByAltText('Bracket')).toBeInTheDocument();
+
+    await user.click(screen.getByText('Good'));
+
+    await waitFor(() => {
+      expect(patchPayload).toEqual({ user_verdict: 'good', user_verdict_source: 'dialog' });
+    });
+    await waitFor(() => {
+      expect(onClose).toHaveBeenCalled();
+    });
+  });
+
+  it('records a reject with an optional reason', async () => {
+    const user = userEvent.setup();
+    const onClose = vi.fn();
+    render(<ConfirmOutcomeDialog archiveId={42} onClose={onClose} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Bracket')).toBeInTheDocument();
+    });
+
+    await user.click(screen.getByText('Reject'));
+    // Reason step appears
+    const select = await screen.findByRole('combobox');
+    await user.selectOptions(select, 'warping');
+    await user.click(screen.getByText('Save'));
+
+    await waitFor(() => {
+      expect(patchPayload).toEqual({
+        user_verdict: 'reject',
+        failure_reason: 'warping',
+        user_verdict_source: 'dialog',
+      });
+    });
+    await waitFor(() => {
+      expect(onClose).toHaveBeenCalled();
+    });
+  });
+
+  it('reject-and-reprint hands off into the print modal after saving', async () => {
+    const user = userEvent.setup();
+    render(<ConfirmOutcomeDialog archiveId={42} onClose={vi.fn()} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Bracket')).toBeInTheDocument();
+    });
+
+    await user.click(screen.getByText('Reject'));
+    await user.click(await screen.findByText('Print again'));
+
+    await waitFor(() => {
+      expect(patchPayload).toEqual({ user_verdict: 'reject', user_verdict_source: 'dialog' });
+    });
+    await waitFor(() => {
+      expect(screen.getByTestId('print-modal')).toBeInTheDocument();
+    });
+  });
+
+  it('shows the already-decided state instead of the buttons', async () => {
+    server.use(
+      http.get('/api/v1/archives/42', () =>
+        HttpResponse.json({ ...baseArchive, user_verdict: 'reject' })
+      ),
+    );
+    render(<ConfirmOutcomeDialog archiveId={42} onClose={vi.fn()} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Already marked as rejected.')).toBeInTheDocument();
+    });
+    expect(screen.queryByText('Good')).not.toBeInTheDocument();
+    // No source on file (an archive answered before #1898 recorded it): no hint.
+    expect(screen.queryByTestId('verdict-source-hint')).not.toBeInTheDocument();
+  });
+
+  it('explains a verdict the plate-clear default recorded', async () => {
+    server.use(
+      http.get('/api/v1/archives/42', () =>
+        HttpResponse.json({ ...baseArchive, user_verdict: 'good', user_verdict_source: 'plate_clear' })
+      ),
+    );
+    render(<ConfirmOutcomeDialog archiveId={42} onClose={vi.fn()} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Already confirmed as a good part.')).toBeInTheDocument();
+    });
+    expect(screen.getByTestId('verdict-source-hint')).toHaveTextContent(
+      'Recorded when the plate was cleared.'
+    );
+  });
+});

+ 121 - 0
frontend/src/__tests__/components/EditArchiveModal.test.tsx

@@ -588,4 +588,125 @@ describe('EditArchiveModal', () => {
       expect(Number(field.value)).toBeGreaterThanOrEqual(0);
     });
   });
+
+  // Post-print outcome confirmation (#1898)
+  describe('outcome verdict source', () => {
+    const completed = {
+      ...mockArchive,
+      status: 'completed',
+      user_verdict: 'good',
+      confirm_requested: true,
+    };
+
+    it('explains a verdict the plate-clear default recorded', () => {
+      render(
+        <EditArchiveModal
+          archive={{ ...completed, user_verdict_source: 'plate_clear' }}
+          onClose={mockOnClose}
+          onSave={mockOnSave}
+        />,
+      );
+
+      expect(screen.getByTestId('verdict-source-hint')).toHaveTextContent(
+        'Recorded when the plate was cleared.',
+      );
+    });
+
+    it('shows no hint for a verdict with no recorded source', () => {
+      render(
+        <EditArchiveModal
+          archive={{ ...completed, user_verdict_source: null }}
+          onClose={mockOnClose}
+          onSave={mockOnSave}
+        />,
+      );
+
+      expect(screen.queryByTestId('verdict-source-hint')).not.toBeInTheDocument();
+    });
+
+    it('stamps the dialog as the source when the verdict is changed here', async () => {
+      const user = userEvent.setup();
+      let seen: Record<string, unknown> | null = null;
+      server.use(
+        http.patch('/api/v1/archives/:id', async ({ request }) => {
+          seen = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ ...completed, ...seen });
+        }),
+      );
+
+      render(
+        <EditArchiveModal
+          archive={{ ...completed, user_verdict_source: 'plate_clear' }}
+          onClose={mockOnClose}
+          onSave={mockOnSave}
+        />,
+      );
+
+      await user.selectOptions(screen.getByLabelText(/outcome verdict/i), 'reject');
+      await user.click(screen.getByRole('button', { name: /save/i }));
+
+      await waitFor(() => expect(seen).not.toBeNull());
+      expect(seen!.user_verdict).toBe('reject');
+      expect(seen!.user_verdict_source).toBe('dialog');
+    });
+  });
+
+  describe('failure_reason on a machine status the dropdown cannot show', () => {
+    // The reject reason (#1898) reuses failure_reason, so the clearing branch
+    // grew "|| archive.failure_reason" to drop it when the verdict goes away.
+    // That clause also catches a 'cancelled' archive: print_queue writes
+    // status 'cancelled' with "Stopped by user (printer was offline)" when a
+    // stop is issued to an offline printer, the status dropdown has no
+    // 'cancelled' option, and so opening the editor and pressing Save with
+    // nothing touched wiped a reason the user could not put back.
+    const cancelled = {
+      ...mockArchive,
+      status: 'cancelled',
+      failure_reason: 'Stopped by user (printer was offline)',
+      user_verdict: null,
+    };
+
+    it('keeps the machine-written reason on an untouched save', async () => {
+      const user = userEvent.setup();
+      let seen: Record<string, unknown> | null = null;
+      server.use(
+        http.patch('/api/v1/archives/:id', async ({ request }) => {
+          seen = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ ...cancelled, ...seen });
+        }),
+      );
+
+      render(<EditArchiveModal archive={cancelled} onClose={mockOnClose} onSave={mockOnSave} />);
+      await user.click(screen.getByRole('button', { name: /save/i }));
+
+      await waitFor(() => expect(seen).not.toBeNull());
+      expect(seen!).not.toHaveProperty('failure_reason');
+    });
+
+    it('still drops the reason when a reject verdict is taken back', async () => {
+      // The behaviour the clause was written for, on the archive it was
+      // written for: a completed print whose reject reason no longer applies.
+      const user = userEvent.setup();
+      let seen: Record<string, unknown> | null = null;
+      const rejected = {
+        ...mockArchive,
+        status: 'completed',
+        user_verdict: 'reject',
+        failure_reason: 'warping',
+      };
+      server.use(
+        http.patch('/api/v1/archives/:id', async ({ request }) => {
+          seen = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ ...rejected, ...seen });
+        }),
+      );
+
+      render(<EditArchiveModal archive={rejected} onClose={mockOnClose} onSave={mockOnSave} />);
+      await user.selectOptions(screen.getByLabelText(/outcome verdict/i), 'good');
+      await user.click(screen.getByRole('button', { name: /save/i }));
+
+      await waitFor(() => expect(seen).not.toBeNull());
+      expect(seen!.failure_reason).toBeNull();
+    });
+  });
 });

+ 154 - 1
frontend/src/__tests__/components/Layout.test.tsx

@@ -3,7 +3,7 @@
  */
 
 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import { waitFor } from '@testing-library/react';
+import { screen, waitFor } from '@testing-library/react';
 import { render } from '../utils';
 import { Layout } from '../../components/Layout';
 import { getAuthToken, setAuthToken } from '../../api/client';
@@ -731,4 +731,157 @@ describe('Layout', () => {
       expect(sidebarLink('/queue')).toBeNull();
     });
   });
+  describe('outcome confirmation deep link (#1898)', () => {
+    // The URL a Pushover / Bark notification opens in a brand new tab. Layout
+    // is the PARENT of the page that renders at /archives, and React flushes a
+    // child's effects before its parent's — so while the page owned this deep
+    // link it dispatched `print-confirm-request` before Layout had added the
+    // listener, and then stripped the parameter, leaving nothing to recover.
+    // These tests pin the read where the dialog state lives.
+    const archive = {
+      id: 42,
+      printer_id: 1,
+      filename: 'bracket.gcode.3mf',
+      print_name: 'Bracket',
+      status: 'completed',
+      photos: null,
+      user_verdict: null,
+      user_verdict_source: null,
+      confirm_requested: true,
+    };
+
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/archives/42', () => HttpResponse.json(archive))
+      );
+    });
+
+    afterEach(() => {
+      window.history.replaceState({}, '', '/');
+    });
+
+    it('opens the dialog on a cold load, with no page mounted to relay the event', async () => {
+      window.history.replaceState({}, '', '/archives?confirm=42');
+
+      render(<Layout />);
+
+      expect(await screen.findByTestId('confirm-outcome-dialog')).toBeInTheDocument();
+    });
+
+    it('strips the parameter but keeps the rest of the query', async () => {
+      window.history.replaceState({}, '', '/archives?confirm=42&status=completed');
+
+      render(<Layout />);
+
+      await screen.findByTestId('confirm-outcome-dialog');
+      await waitFor(() => {
+        expect(window.location.search).toBe('?status=completed');
+      });
+      expect(window.location.pathname).toBe('/archives');
+    });
+
+    it('ignores a confirm parameter that is not an archive id', async () => {
+      window.history.replaceState({}, '', '/archives?confirm=not-an-id');
+
+      render(<Layout />);
+
+      await waitFor(() => {
+        expect(document.querySelector('aside')).toBeInTheDocument();
+      });
+      expect(screen.queryByTestId('confirm-outcome-dialog')).toBeNull();
+      // Nothing was consumed, so nothing is rewritten either.
+      expect(window.location.search).toBe('?confirm=not-an-id');
+    });
+
+    // Round 4: the dialog's only outcome for a user who may not record a
+    // verdict is a 403 from the PATCH, so it is gated like plate-not-empty —
+    // on archives:update_all / archives:update_own, the names the June
+    // permission migration left in the default groups.
+    describe('without permission to record a verdict', () => {
+      const withPermissions = (permissions: string[]) => {
+        server.use(
+          http.get('/api/v1/auth/status', () =>
+            HttpResponse.json({ auth_enabled: true, requires_setup: false }),
+          ),
+          http.get('/api/v1/auth/me', () =>
+            HttpResponse.json({
+              id: 1,
+              username: 'tester',
+              role: 'user',
+              is_active: true,
+              is_admin: false,
+              groups: [{ id: 2, name: 'Standard Users' }],
+              permissions,
+              created_at: '2026-01-01T00:00:00Z',
+            }),
+          ),
+        );
+        // localStorage is a mock in this suite, so the token goes through the
+        // client the way the app sets it — AuthProvider will not fetch
+        // /auth/me without one, and the permissions would stay empty.
+        setAuthToken('test-token');
+      };
+
+      afterEach(() => {
+        setAuthToken(null);
+      });
+
+      it('ignores the event', async () => {
+        withPermissions(['archives:read_all']);
+        render(<Layout />);
+
+        await waitFor(() => {
+          expect(document.querySelector('aside')).toBeInTheDocument();
+        });
+        window.dispatchEvent(
+          new CustomEvent('print-confirm-request', { detail: { archive_id: 42 } })
+        );
+
+        await waitFor(() => {
+          expect(screen.queryByTestId('confirm-outcome-dialog')).toBeNull();
+        });
+      });
+
+      it('consumes the deep link without opening anything', async () => {
+        withPermissions(['archives:read_all']);
+        window.history.replaceState({}, '', '/archives?confirm=42');
+
+        render(<Layout />);
+
+        // The parameter still goes: a link that can open nothing should not
+        // survive a reload either.
+        await waitFor(() => {
+          expect(window.location.search).toBe('');
+        });
+        expect(screen.queryByTestId('confirm-outcome-dialog')).toBeNull();
+      });
+
+      it('opens for a user who may update their own archives', async () => {
+        withPermissions(['archives:read_own', 'archives:update_own']);
+        render(<Layout />);
+
+        await waitFor(() => {
+          expect(document.querySelector('aside')).toBeInTheDocument();
+        });
+        window.dispatchEvent(
+          new CustomEvent('print-confirm-request', { detail: { archive_id: 42 } })
+        );
+
+        expect(await screen.findByTestId('confirm-outcome-dialog')).toBeInTheDocument();
+      });
+    });
+
+    it('still opens from the WebSocket event, which carries no URL', async () => {
+      render(<Layout />);
+
+      await waitFor(() => {
+        expect(document.querySelector('aside')).toBeInTheDocument();
+      });
+      window.dispatchEvent(
+        new CustomEvent('print-confirm-request', { detail: { archive_id: 42 } })
+      );
+
+      expect(await screen.findByTestId('confirm-outcome-dialog')).toBeInTheDocument();
+    });
+  });
 });

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

@@ -1611,6 +1611,93 @@ describe('PrintModal', () => {
       expect(capturedBody?.cleanup_library_after_dispatch).toBeUndefined();
     });
   });
+
+  describe('ask-for-outcome pill (#1898 follow-up)', () => {
+    // The pill outside the collapsed Print Options panel and the row inside it
+    // edit the same printOptions.confirm_outcome, so flipping either must be
+    // reflected by the other and land in the submitted payload.
+    // Both carry the label "Ask for Outcome"; the pill is the only button with
+    // that name, the panel row is found through its description text.
+    const pill = () => screen.getByRole('button', { name: 'Ask for Outcome' });
+    const panelRowDesc = 'Ask whether the print came out well after it completes';
+    const panelRow = () => screen.getByText(panelRowDesc).closest('div')!.parentElement!;
+
+    it('starts off and toggles on a click', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal mode="create" archiveId={1} archiveName="Benchy" initialSelectedPrinterIds={[1]} onClose={mockOnClose} />
+      );
+
+      expect(pill()).toHaveAttribute('aria-pressed', 'false');
+      await user.click(pill());
+      expect(pill()).toHaveAttribute('aria-pressed', 'true');
+    });
+
+    it('shares its state with the row inside Print Options', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal mode="create" archiveId={1} archiveName="Benchy" initialSelectedPrinterIds={[1]} onClose={mockOnClose} />
+      );
+
+      await user.click(pill());
+      // The panel opens expanded when a printer is preselected; expand otherwise.
+      if (!screen.queryByText(panelRowDesc)) await user.click(screen.getByText('Print Options'));
+      const row = panelRow();
+      expect(within(row).getByRole('button', { name: 'On' })).toHaveClass('bg-bambu-green');
+
+      await user.click(within(row).getByRole('button', { name: 'Off' }));
+      expect(pill()).toHaveAttribute('aria-pressed', 'false');
+    });
+
+    it('submits confirm_outcome=true with the queue item when turned on', async () => {
+      let capturedBody: Record<string, unknown> | null = null;
+      server.use(
+        http.post('/api/v1/queue/', async ({ request }) => {
+          capturedBody = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ id: 1, status: 'pending' });
+        }),
+      );
+
+      const user = userEvent.setup();
+      render(
+        <PrintModal mode="create" archiveId={1} archiveName="Benchy" initialSelectedPrinterIds={[1]} onClose={mockOnClose} />
+      );
+
+      await user.click(pill());
+      await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
+
+      await waitFor(() => expect(capturedBody).not.toBeNull());
+      expect(capturedBody!.confirm_outcome).toBe(true);
+    });
+
+    it('reflects the queue item value in edit mode and saves the change', async () => {
+      let capturedBody: Record<string, unknown> | null = null;
+      server.use(
+        http.patch('/api/v1/queue/:id', async ({ request }) => {
+          capturedBody = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ id: 1, status: 'pending' });
+        }),
+      );
+
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={1}
+          archiveName="Test Print"
+          queueItem={createMockQueueItem({ confirm_outcome: true })}
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(pill()).toHaveAttribute('aria-pressed', 'true');
+      await user.click(pill());
+      await user.click(screen.getByRole('button', { name: /save/i }));
+
+      await waitFor(() => expect(capturedBody).not.toBeNull());
+      expect(capturedBody!.confirm_outcome).toBe(false);
+    });
+  });
 });
 
 

+ 68 - 0
frontend/src/__tests__/pages/ArchivesPage.test.tsx

@@ -618,4 +618,72 @@ describe('ArchivesPage', () => {
       });
     });
   });
+
+  // #1898: the "outcome?" badge on a print still waiting for its verdict opens
+  // the dialog. The PATCH behind it checks archives:update against the owner,
+  // like the menu item beside it, so for anyone else the badge is a status only.
+  describe('awaiting-outcome badge', () => {
+    const awaiting = { ...mockArchives[0], confirm_requested: true, user_verdict: null, created_by_id: 7 };
+
+    const signInWith = (permissions: string[]) => {
+      setAuthToken('verdict-token', 'session');
+      server.use(
+        http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
+        http.get('/api/v1/auth/me', () => HttpResponse.json({
+          id: 7,
+          username: 'operator',
+          is_active: true,
+          is_admin: false,
+          groups: [],
+          permissions,
+          created_at: '2026-08-18T00:00:00Z',
+        })),
+        http.get('/api/v1/archives/', () => HttpResponse.json([awaiting])),
+      );
+    };
+
+    afterEach(() => {
+      window.localStorage.removeItem('archiveViewMode');
+    });
+
+    it('is a status only for a user who may not update the archive', async () => {
+      signInWith(['archives:read_all']);
+      render(<ArchivesPage />);
+
+      const badge = await screen.findByRole('button', { name: 'outcome?' });
+      await waitFor(() => expect(badge).toBeDisabled());
+      expect(badge).toHaveAttribute('title', 'You do not have permission to update archives');
+      fireEvent.click(badge);
+      expect(screen.queryByTestId('confirm-outcome-dialog')).toBeNull();
+    });
+
+    it('is a status only in the list view too', async () => {
+      window.localStorage.setItem('archiveViewMode', 'list');
+      signInWith(['archives:read_all']);
+      render(<ArchivesPage />);
+
+      const badge = await screen.findByRole('button', { name: 'outcome?' });
+      await waitFor(() => expect(badge).toBeDisabled());
+      expect(badge).toHaveAttribute('title', 'You do not have permission to update archives');
+    });
+
+    it('is disabled on somebody else\'s print for a user who may update only their own', async () => {
+      signInWith(['archives:read_all', 'archives:update_own']);
+      server.use(http.get('/api/v1/archives/', () => HttpResponse.json([{ ...awaiting, created_by_id: 8 }])));
+      render(<ArchivesPage />);
+
+      const badge = await screen.findByRole('button', { name: 'outcome?' });
+      await waitFor(() => expect(badge).toBeDisabled());
+    });
+
+    it('opens the dialog for the owner', async () => {
+      signInWith(['archives:read_all', 'archives:update_own']);
+      render(<ArchivesPage />);
+
+      const badge = await screen.findByRole('button', { name: 'outcome?' });
+      await waitFor(() => expect(badge).toBeEnabled());
+      fireEvent.click(badge);
+      expect(await screen.findByTestId('confirm-outcome-dialog')).toBeInTheDocument();
+    });
+  });
 });

+ 188 - 0
frontend/src/__tests__/pages/PrintersPageCardVerdict.test.tsx

@@ -0,0 +1,188 @@
+/**
+ * Good / Reject on the printer card (#1898).
+ *
+ * The card offers the verdict where the operator is standing: the plate-clear
+ * gate is up, the print that just finished asked for a verdict, and answering
+ * it should not cost a trip to the archive.
+ *
+ * What is pinned here is which button does what. Good is answered on the spot;
+ * Reject wants a reason and possibly a reprint, so it opens the dialog and
+ * records nothing by itself. The card's mutation therefore only ever sends
+ * "good", and its type says so — a verdict it cannot send must not be able to
+ * reach a success message that calls it good.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { setAuthToken } from '../../api/client';
+
+const printer = {
+  id: 1,
+  name: 'H2S-1',
+  ip_address: '192.168.20.10',
+  model: 'H2S',
+  serial_number: '00M09A350100001',
+  access_code: '12345678',
+  enabled: true,
+  is_active: true,
+  nozzle_diameter: 0.4,
+  nozzle_type: 'hardened_steel',
+  location: 'Workshop',
+  auto_archive: true,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+};
+
+const status = {
+  connected: true,
+  state: 'FINISH',
+  awaiting_plate_clear: true,
+  progress: 100,
+  layer_num: 0,
+  total_layers: 0,
+  temperatures: { nozzle: 25, bed: 25, chamber: 25 },
+  remaining_time: 0,
+  filename: 'bracket.gcode.3mf',
+  wifi_signal: -50,
+  vt_tray: [],
+};
+
+const uiPreferences = {
+  ams_humidity_good: 40,
+  ams_humidity_fair: 60,
+  ams_temp_good: 30,
+  ams_temp_fair: 35,
+  require_plate_clear: true,
+};
+
+const finishedPrint = {
+  id: 42,
+  printer_id: 1,
+  filename: 'bracket.gcode.3mf',
+  print_name: 'Bracket',
+  status: 'completed',
+  confirm_requested: true,
+  user_verdict: null,
+  user_verdict_source: null,
+  photos: null,
+  created_by_id: 7,
+};
+
+describe('PrintersPage — verdict on the card', () => {
+  let patched: Array<Record<string, unknown>>;
+
+  beforeEach(() => {
+    patched = [];
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([printer])),
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json(status)),
+      http.get('/api/v1/settings/ui-preferences', () => HttpResponse.json(uiPreferences)),
+      http.get('/api/v1/settings/', () => HttpResponse.json({ ...uiPreferences, auto_archive: true })),
+      http.get('/api/v1/archives/', () => HttpResponse.json([finishedPrint])),
+      http.patch('/api/v1/archives/:id', async ({ request }) => {
+        const body = (await request.json()) as Record<string, unknown>;
+        patched.push(body);
+        return HttpResponse.json({ ...finishedPrint, ...body });
+      }),
+    );
+  });
+
+  it('records good from the card and says so', async () => {
+    const user = userEvent.setup();
+    render(<PrintersPage />);
+
+    await user.click(await screen.findByTitle('Good'));
+
+    await waitFor(() => expect(patched).toHaveLength(1));
+    expect(patched[0].user_verdict).toBe('good');
+    expect(patched[0].user_verdict_source).toBe('printer_card');
+    expect(await screen.findByText('Marked as good part')).toBeInTheDocument();
+  });
+
+  it('sends the reject to the dialog and records nothing on its own', async () => {
+    const user = userEvent.setup();
+    render(<PrintersPage />);
+
+    await user.click(await screen.findByTitle('Reject'));
+
+    expect(await screen.findByTestId('confirm-outcome-dialog')).toBeInTheDocument();
+    expect(patched).toHaveLength(0);
+    expect(screen.queryByText('Marked as good part')).toBeNull();
+  });
+
+  // The PATCH checks archives:update against the archive's owner, so the pair
+  // is only offered to whoever it can succeed for. Mark cleared, which has its
+  // own permission, stays either way.
+  describe('with auth enabled', () => {
+    // Before /auth/me has answered, every permission check says yes, so an
+    // absence is only worth asserting once both it and the archive are in.
+    let meServed: boolean;
+    let archiveServed: boolean;
+    const settled = async () => {
+      await waitFor(() => expect(meServed && archiveServed).toBe(true));
+      expect(await screen.findByRole('button', { name: 'Mark plate as cleared' })).toBeInTheDocument();
+    };
+
+    const signInWith = (permissions: string[], archive = finishedPrint) => {
+      meServed = false;
+      archiveServed = false;
+      setAuthToken('card-verdict-token', 'session');
+      server.use(
+        http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
+        http.get('/api/v1/auth/me', () => {
+          meServed = true;
+          return HttpResponse.json({
+            id: 7,
+            username: 'operator',
+            is_active: true,
+            is_admin: false,
+            groups: [],
+            permissions,
+            created_at: '2026-08-18T00:00:00Z',
+          });
+        }),
+        http.get('/api/v1/archives/', () => {
+          archiveServed = true;
+          return HttpResponse.json([archive]);
+        }),
+      );
+    };
+
+    afterEach(() => {
+      setAuthToken(null);
+    });
+
+    it('offers no verdict to a user who may not update archives', async () => {
+      signInWith(['printers:read', 'printers:clear_plate', 'archives:read_all']);
+      render(<PrintersPage />);
+
+      await settled();
+      await waitFor(() => expect(screen.queryByTitle('Good')).toBeNull());
+      expect(screen.queryByTitle('Reject')).toBeNull();
+    });
+
+    it('offers no verdict on somebody else\'s print to a user who may update only their own', async () => {
+      signInWith(
+        ['printers:read', 'printers:clear_plate', 'archives:read_all', 'archives:update_own'],
+        { ...finishedPrint, created_by_id: 8 },
+      );
+      render(<PrintersPage />);
+
+      await settled();
+      await waitFor(() => expect(screen.queryByTitle('Good')).toBeNull());
+    });
+
+    it('offers it to the owner', async () => {
+      signInWith(['printers:read', 'printers:clear_plate', 'archives:read_all', 'archives:update_own']);
+      render(<PrintersPage />);
+
+      expect(await screen.findByTitle('Good')).toBeInTheDocument();
+      expect(screen.getByTitle('Reject')).toBeInTheDocument();
+    });
+  });
+});

+ 70 - 0
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -811,6 +811,29 @@ describe('QueuePage', () => {
     });
   });
 
+  describe('outcome prompt badge (#1898)', () => {
+    it('shows the badge only on items with confirm_outcome set', async () => {
+      const items = mockQueueItems.map((item, i) =>
+        i === 0 ? { ...item, confirm_outcome: true } : { ...item, confirm_outcome: false }
+      );
+      server.use(http.get('/api/v1/queue/', () => HttpResponse.json(items)));
+
+      render(<QueuePage />);
+
+      await waitFor(() => expect(screen.getByText('Test Print 1')).toBeInTheDocument());
+      const badges = screen.getAllByText('Outcome prompt');
+      expect(badges).toHaveLength(1);
+      expect(badges[0].closest('span')).toHaveAttribute('title', 'Asks how the print came out after it completes');
+    });
+
+    it('renders nothing when no item asks for an outcome', async () => {
+      render(<QueuePage />);
+
+      await waitFor(() => expect(screen.getByText('Test Print 1')).toBeInTheDocument());
+      expect(screen.queryByText('Outcome prompt')).not.toBeInTheDocument();
+    });
+  });
+
   describe('filament-short ▶ flow (#1496)', () => {
     /**
      * The dispatch pre-flight flags a queue item as filament_short. The user
@@ -1030,4 +1053,51 @@ describe('QueuePage', () => {
       expect('gcode_injection' in patchBody!).toBe(false);
     });
   });
+
+  describe('bulk edit ask-for-outcome (#1898)', () => {
+    const openBulkEdit = async () => {
+      render(<QueuePage />);
+      await waitFor(() => expect(screen.getByText('Test Print 1')).toBeInTheDocument());
+      await userEvent.click(screen.getByText('Select All'));
+      await userEvent.click(await screen.findByTitle('Edit Selected'));
+      return screen.getByText('Edit 1 Item').closest('div')!.parentElement!;
+    };
+
+    const captureBulkPatch = () => {
+      const captured: { body: Record<string, unknown> | null } = { body: null };
+      server.use(
+        http.patch('/api/v1/queue/bulk', async ({ request }) => {
+          captured.body = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ updated_count: 1, skipped_count: 0, message: 'Updated 1 items' });
+        }),
+      );
+      return captured;
+    };
+
+    it('sends confirm_outcome with the bulk PATCH once toggled', async () => {
+      const captured = captureBulkPatch();
+      const dialog = await openBulkEdit();
+
+      const row = within(dialog).getByText('Ask for outcome').closest('div')!;
+      await userEvent.click(within(row).getByText('On'));
+      await userEvent.click(within(dialog).getByText('Apply Changes'));
+
+      await waitFor(() => expect(captured.body).not.toBeNull());
+      expect(captured.body!.item_ids).toEqual([1]);
+      expect(captured.body!.confirm_outcome).toBe(true);
+    });
+
+    it('leaves confirm_outcome out of the PATCH while it stays on "no change"', async () => {
+      const captured = captureBulkPatch();
+      const dialog = await openBulkEdit();
+
+      const timelapseRow = within(dialog).getByText('Timelapse').closest('div')!;
+      await userEvent.click(within(timelapseRow).getByText('On'));
+      await userEvent.click(within(dialog).getByText('Apply Changes'));
+
+      await waitFor(() => expect(captured.body).not.toBeNull());
+      expect(captured.body!.timelapse).toBe(true);
+      expect('confirm_outcome' in captured.body!).toBe(false);
+    });
+  });
 });

+ 87 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -1617,6 +1617,93 @@ describe('SettingsPage', () => {
       });
     });
   });
+
+  // --------------------------------------------------------------------
+  // Ask for the outcome of prints Bambuddy did not start (#1898)
+  // --------------------------------------------------------------------
+  describe('outcome prompt for external prints (#1898)', () => {
+    const externalLabel = 'Also ask for prints started outside Bambuddy';
+
+    const openDefaultPrintOptions = async () => {
+      render(<SettingsPage />);
+      const user = userEvent.setup();
+      await waitFor(() => expect(screen.getByRole('button', { name: 'Workflow' })).toBeInTheDocument());
+      await user.click(screen.getByRole('button', { name: 'Workflow' }));
+      return user;
+    };
+
+    it('offers the toggle alongside the per-job outcome default', async () => {
+      await openDefaultPrintOptions();
+
+      expect(await screen.findByText(externalLabel)).toBeInTheDocument();
+      expect(screen.getByText('Ask for Outcome')).toBeInTheDocument();
+    });
+
+    it('shows it off when the backend has no value for it', async () => {
+      // Default false: an install that never touches it keeps today's
+      // behaviour, where only queued prints are asked about.
+      await openDefaultPrintOptions();
+
+      const label = await screen.findByText(externalLabel);
+      const row = label.closest('div')!.parentElement!;
+      expect(within(row).getByRole('checkbox')).not.toBeChecked();
+    });
+
+    it('sends the new value on save', async () => {
+      let saved: Record<string, unknown> | null = null;
+      server.use(
+        http.put('/api/v1/settings/', async ({ request }) => {
+          saved = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ ...mockSettings, ...saved });
+        })
+      );
+      const user = await openDefaultPrintOptions();
+
+      const label = await screen.findByText(externalLabel);
+      // The page suppresses auto-save for 100ms after the settings load.
+      await new Promise((resolve) => setTimeout(resolve, 200));
+      const row = label.closest('div')!.parentElement!;
+      await user.click(within(row).getByRole('checkbox'));
+
+      await waitFor(() => {
+        expect(saved).not.toBeNull();
+      }, { timeout: 3000 });
+      expect(saved!.confirm_outcome_external_prints).toBe(true);
+    });
+
+    it('reflects a value the backend already has', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () =>
+          HttpResponse.json({ ...mockSettings, confirm_outcome_external_prints: true })
+        )
+      );
+      await openDefaultPrintOptions();
+
+      const label = await screen.findByText(externalLabel);
+      const row = label.closest('div')!.parentElement!;
+      expect(within(row).getByRole('checkbox')).toBeChecked();
+    });
+  });
+
+  // --------------------------------------------------------------------
+  // The plate-clear default answers outcome prompts on its own (#1898), so
+  // its help text has to name that consequence — the farm case was a user
+  // who had it on and could not work out why Telegram said "already used".
+  // --------------------------------------------------------------------
+  describe('plate-clear outcome default help text (#1898)', () => {
+    it('warns that a later Telegram or link answer only shows the result', async () => {
+      render(<SettingsPage />);
+      const user = userEvent.setup();
+      await waitFor(() => expect(screen.getByRole('button', { name: 'Workflow' })).toBeInTheDocument());
+      await user.click(screen.getByRole('button', { name: 'Workflow' }));
+
+      const label = await screen.findByText('Count unanswered outcomes as good on plate release');
+      const row = label.closest('div')!;
+      expect(row).toHaveTextContent(
+        /a Telegram or link answer after that only shows the recorded result/i,
+      );
+    });
+  });
 });
 
 /**

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

@@ -790,6 +790,9 @@ export interface ArchivePrinterMedia {
   warnings: Array<'printer_missing' | 'timelapse_unavailable' | 'ipcam_unavailable' | 'printer_files_forbidden'>;
 }
 
+/** How a post-print outcome verdict reached the archive (#1898). */
+export type VerdictSource = 'dialog' | 'link' | 'plate_clear' | 'printer_card' | 'api' | 'reaction';
+
 export interface Archive {
   id: number;
   printer_id: number | null;
@@ -836,6 +839,13 @@ export interface Archive {
   cost: number | null;
   photos: string[] | null;
   failure_reason: string | null;
+  // Post-print outcome confirmation (#1898)
+  user_verdict: 'good' | 'reject' | null;
+  // How the verdict arrived; 'reaction' is written by the Telegram reaction
+  // handler (#3046) and is labelled here so it reads correctly once that lands.
+  user_verdict_source: VerdictSource | null;
+  user_verdict_at: string | null;
+  confirm_requested: boolean;
   quantity: number;
   energy_kwh: number | null;
   energy_cost: number | null;
@@ -930,6 +940,11 @@ export interface FailureAnalysis {
   total_prints: number;
   failed_prints: number;
   failure_rate: number;
+  // Quality dimension (#1898): completed prints the user rejected. Optional
+  // so a frontend build against an older backend degrades gracefully.
+  rejected_prints?: number;
+  yield_rate?: number;
+  rejects_by_reason?: Record<string, number>;
   failures_by_reason: Record<string, number>;
   failures_by_filament: Record<string, number>;
   failures_by_printer: Record<string, number>;
@@ -1457,6 +1472,12 @@ export interface AppSettings {
   default_layer_inspect: boolean;
   default_timelapse: boolean;
   default_nozzle_offset_cali: CalibrationMode;
+  // Default for the per-job "ask for outcome afterwards" toggle (#1898)
+  default_confirm_outcome: boolean;
+  // Also ask for prints Bambuddy archived but did not dispatch (#1898)
+  confirm_outcome_external_prints: boolean;
+  // Count unanswered outcome prompts as "good" when the plate is released (#1898)
+  confirm_default_good_on_plate_clear: boolean;
   // Staggered batch start defaults
   stagger_group_size: number;
   stagger_interval_minutes: number;
@@ -2596,6 +2617,8 @@ export interface PrintQueueItem {
   timelapse: boolean;
   use_ams: boolean;
   nozzle_offset_cali: CalibrationMode;
+  // Ask for a post-print outcome verdict when this job completes (#1898)
+  confirm_outcome: boolean;
   preheat_override: 'inherit' | 'on' | 'off';
   preheat_chamber_target_override: number | null;
   status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled';
@@ -2726,6 +2749,8 @@ export interface PrintQueueItemCreate {
   timelapse?: boolean;
   use_ams?: boolean;
   nozzle_offset_cali?: CalibrationMode;
+  // Ask for a post-print outcome verdict when this job completes (#1898)
+  confirm_outcome?: boolean;
   preheat_override?: 'inherit' | 'on' | 'off';
   preheat_chamber_target_override?: number | null;
   // Auto-print G-code injection
@@ -2814,6 +2839,8 @@ export interface PrintQueueItemUpdate {
   timelapse?: boolean;
   use_ams?: boolean;
   nozzle_offset_cali?: CalibrationMode;
+  // Ask for a post-print outcome verdict when this job completes (#1898)
+  confirm_outcome?: boolean;
   preheat_override?: 'inherit' | 'on' | 'off';
   preheat_chamber_target_override?: number | null;
   // Auto-print G-code injection
@@ -2841,6 +2868,8 @@ export interface PrintQueueBulkUpdate {
   timelapse?: boolean;
   use_ams?: boolean;
   nozzle_offset_cali?: CalibrationMode;
+  // Ask for a post-print outcome verdict when this job completes (#1898)
+  confirm_outcome?: boolean;
   preheat_override?: 'inherit' | 'on' | 'off';
   preheat_chamber_target_override?: number | null;
   // Auto-print G-code injection
@@ -2981,6 +3010,8 @@ export interface NotificationProvider {
   // Build plate detection
   on_plate_not_empty: boolean;
   on_plate_clear_required: boolean;
+  // Post-print outcome confirmation (#1898)
+  on_print_confirm_request: boolean;
   // Bed cooled
   on_bed_cooled: boolean;
   on_ha_sensor_alert: boolean;
@@ -3045,6 +3076,8 @@ export interface NotificationProviderCreate {
   // Build plate detection
   on_plate_not_empty?: boolean;
   on_plate_clear_required?: boolean;
+  // Post-print outcome confirmation (#1898)
+  on_print_confirm_request?: boolean;
   // Bed cooled
   on_bed_cooled?: boolean;
   on_ha_sensor_alert?: boolean;
@@ -3102,6 +3135,8 @@ export interface NotificationProviderUpdate {
   // Build plate detection
   on_plate_not_empty?: boolean;
   on_plate_clear_required?: boolean;
+  // Post-print outcome confirmation (#1898)
+  on_print_confirm_request?: boolean;
   // Bed cooled
   on_bed_cooled?: boolean;
   on_ha_sensor_alert?: boolean;
@@ -5105,6 +5140,11 @@ export const api = {
     quantity?: number;
     external_url?: string | null;
     filament_used_grams?: number | null;
+    // Post-print outcome verdict (#1898); null clears it
+    user_verdict?: 'good' | 'reject' | null;
+    // Which surface answered. The backend only accepts the sources a client
+    // can honestly claim and defaults to 'api' when none is given.
+    user_verdict_source?: 'dialog' | 'printer_card';
   }) =>
     request<Archive>(`/archives/${id}`, {
       method: 'PATCH',

+ 12 - 0
frontend/src/components/AddNotificationModal.tsx

@@ -45,6 +45,9 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
   const [onStockReorderAlert, setOnStockReorderAlert] = useState(provider?.on_stock_reorder_alert ?? false);
   const [onStockBreakAlert, setOnStockBreakAlert] = useState(provider?.on_stock_break_alert ?? false);
   const [onPlateClearRequired, setOnPlateClearRequired] = useState(provider?.on_plate_clear_required ?? false);
+  // Post-print outcome confirmation (#1898). Defaults ON — it only fires for
+  // prints that opted in per-job, so the toggle exists to mute a channel.
+  const [onPrintConfirmRequest, setOnPrintConfirmRequest] = useState(provider?.on_print_confirm_request ?? true);
   const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false);
   const [onHaSensorAlert, setOnHaSensorAlert] = useState(provider?.on_ha_sensor_alert ?? false);
   const [onLocationHaSensorAlert, setOnLocationHaSensorAlert] = useState(
@@ -205,6 +208,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       on_stock_reorder_alert: onStockReorderAlert,
       on_stock_break_alert: onStockBreakAlert,
       on_plate_clear_required: onPlateClearRequired,
+      on_print_confirm_request: onPrintConfirmRequest,
       on_bed_cooled: onBedCooled,
       on_ha_sensor_alert: onHaSensorAlert,
       on_location_ha_sensor_alert: onLocationHaSensorAlert,
@@ -625,6 +629,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                   </div>
                   <Toggle checked={onPlateClearRequired} onChange={setOnPlateClearRequired} />
                 </div>
+                <div className="flex items-center justify-between col-span-2">
+                  <div>
+                    <span className="text-sm text-white">{t('notifications.printConfirmRequest')}</span>
+                    <span className="text-xs text-bambu-gray ml-1">{t('notifications.printConfirmRequestDescription')}</span>
+                  </div>
+                  <Toggle checked={onPrintConfirmRequest} onChange={setOnPrintConfirmRequest} />
+                </div>
                 <div className="flex items-center justify-between col-span-2">
                   <div>
                     <span className="text-sm text-white">{t('notifications.bedCooled')}</span>
@@ -714,6 +725,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
               if (onPrintProgress) enabledEvents.push({ key: 'on_print_progress', label: t('notifications.progress') });
               if (onBillingChargeFailed) enabledEvents.push({ key: 'on_billing_charge_failed', label: t('notifications.billingChargeFailedLabel') });
               if (onPlateClearRequired) enabledEvents.push({ key: 'on_plate_clear_required', label: t('notifications.plateClearRequired') });
+              if (onPrintConfirmRequest) enabledEvents.push({ key: 'on_print_confirm_request', label: t('notifications.printConfirmRequest') });
               if (onBedCooled) enabledEvents.push({ key: 'on_bed_cooled', label: t('notifications.bedCooled') });
               if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
               if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') });

+ 208 - 0
frontend/src/components/ConfirmOutcomeDialog.tsx

@@ -0,0 +1,208 @@
+import { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { ThumbsUp, ThumbsDown, X, RotateCcw, ImageOff } from 'lucide-react';
+import { api } from '../api/client';
+import { FAILURE_REASON_KEYS } from './EditArchiveModal';
+import { verdictSourceKey } from '../utils/verdictSource';
+import { PrintModal } from './PrintModal';
+import { useToast } from '../contexts/ToastContext';
+
+interface ConfirmOutcomeDialogProps {
+  archiveId: number;
+  onClose: () => void;
+}
+
+/**
+ * Post-print outcome confirmation (#1898), Formlabs-style: finish photo plus
+ * Good / Reject. Opens from the print_confirm_request WebSocket event (via
+ * Layout), from the pending badge on an archive card, and from the
+ * ?confirm=<id> deep link a push notification carries. Rejecting offers an
+ * optional reason and a one-click reprint through the regular PrintModal.
+ */
+export function ConfirmOutcomeDialog({ archiveId, onClose }: ConfirmOutcomeDialogProps) {
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+  const [rejecting, setRejecting] = useState(false);
+  const [rejectReason, setRejectReason] = useState('');
+  const [showReprint, setShowReprint] = useState(false);
+
+  const { data: archive } = useQuery({
+    queryKey: ['archive', archiveId],
+    queryFn: () => api.getArchive(archiveId),
+  });
+
+  const verdictMutation = useMutation({
+    mutationFn: (data: { user_verdict: 'good' | 'reject'; failure_reason?: string }) =>
+      api.updateArchive(archiveId, { ...data, user_verdict_source: 'dialog' }),
+    onSuccess: (_updated, variables) => {
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      queryClient.invalidateQueries({ queryKey: ['archive', archiveId] });
+      showToast(
+        variables.user_verdict === 'good'
+          ? t('confirmOutcome.savedGood')
+          : t('confirmOutcome.savedReject'),
+        'success',
+      );
+      if (variables.user_verdict === 'good') {
+        onClose();
+      }
+    },
+    onError: () => showToast(t('confirmOutcome.saveFailed'), 'error'),
+  });
+
+  const name = archive?.print_name || archive?.filename || '';
+  // The finish photo is prepended to archive.photos at print end (#1397);
+  // fall back to the thumbnail when no camera shot exists.
+  const photoFilename = archive?.photos?.[0] ?? null;
+  const photoUrl = photoFilename ? api.getArchivePhotoUrl(archiveId, photoFilename) : null;
+  const alreadyDecided = archive?.user_verdict != null;
+  // Why this print already has a verdict — the plate-clear default answers
+  // prompts on its own, and without this the answer looks like it appeared
+  // from nowhere (#1898).
+  const sourceKey = verdictSourceKey(archive?.user_verdict_source);
+
+  const handleGood = () => verdictMutation.mutate({ user_verdict: 'good' });
+  const handleRejectSave = (reprint: boolean) => {
+    verdictMutation.mutate(
+      { user_verdict: 'reject', ...(rejectReason ? { failure_reason: rejectReason } : {}) },
+      {
+        onSuccess: () => {
+          if (reprint) {
+            setShowReprint(true);
+          } else {
+            onClose();
+          }
+        },
+      },
+    );
+  };
+
+  if (showReprint && archive) {
+    return (
+      <PrintModal
+        mode="create"
+        archiveId={archiveId}
+        archiveName={name}
+        onClose={onClose}
+      />
+    );
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4" data-testid="confirm-outcome-dialog">
+      <div className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md overflow-hidden">
+        {/* Header */}
+        <div className="flex items-center justify-between px-4 py-3 border-b border-bambu-dark-tertiary">
+          <h3 className="text-white font-semibold">{t('confirmOutcome.title')}</h3>
+          <button onClick={onClose} className="text-bambu-gray hover:text-white transition-colors" title={t('common.close')}>
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Photo */}
+        <div className="bg-bambu-dark aspect-video flex items-center justify-center overflow-hidden">
+          {photoUrl ? (
+            <img src={photoUrl} alt={name} className="w-full h-full object-contain" />
+          ) : (
+            <div className="flex flex-col items-center gap-2 text-bambu-gray/60">
+              <ImageOff className="w-10 h-10" />
+              <span className="text-xs">{t('confirmOutcome.noPhoto')}</span>
+            </div>
+          )}
+        </div>
+
+        <div className="p-4">
+          <p className="text-sm text-white text-center mb-1 truncate" title={name}>{name}</p>
+
+          {alreadyDecided ? (
+            <>
+              <p className="text-sm text-bambu-gray text-center">
+                {archive?.user_verdict === 'good'
+                  ? t('confirmOutcome.alreadyGood')
+                  : t('confirmOutcome.alreadyRejected')}
+              </p>
+              {sourceKey && (
+                <p className="text-xs text-bambu-gray/70 text-center mt-1" data-testid="verdict-source-hint">
+                  {t(sourceKey)}
+                </p>
+              )}
+            </>
+          ) : !rejecting ? (
+            <div className="flex items-center justify-center gap-6 mt-3">
+              <button
+                type="button"
+                onClick={() => setRejecting(true)}
+                disabled={verdictMutation.isPending}
+                className="flex flex-col items-center gap-1.5 px-6 py-3 rounded-xl border border-red-500/40 text-red-500 hover:bg-red-500/10 transition-colors"
+              >
+                <ThumbsDown className="w-7 h-7" />
+                <span className="text-xs">{t('confirmOutcome.reject')}</span>
+              </button>
+              <button
+                type="button"
+                onClick={handleGood}
+                disabled={verdictMutation.isPending}
+                className="flex flex-col items-center gap-1.5 px-6 py-3 rounded-xl border border-bambu-green/50 text-bambu-green hover:bg-bambu-green/10 transition-colors"
+              >
+                <ThumbsUp className="w-7 h-7" />
+                <span className="text-xs">{t('confirmOutcome.good')}</span>
+              </button>
+            </div>
+          ) : (
+            <div className="mt-3">
+              <label className="block text-xs text-bambu-gray mb-1">{t('confirmOutcome.rejectReason')}</label>
+              <select
+                value={rejectReason}
+                onChange={(e) => setRejectReason(e.target.value)}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+              >
+                <option value="">{t('confirmOutcome.noReason')}</option>
+                {FAILURE_REASON_KEYS.map((key) => (
+                  <option key={key} value={key}>{t(`editArchive.failureReasons.${key}`)}</option>
+                ))}
+              </select>
+              <div className="flex gap-2 mt-3">
+                <button
+                  type="button"
+                  onClick={() => handleRejectSave(false)}
+                  disabled={verdictMutation.isPending}
+                  className="flex-1 px-3 py-2 rounded-lg bg-red-500/80 hover:bg-red-500 text-white text-sm transition-colors"
+                >
+                  {t('confirmOutcome.saveReject')}
+                </button>
+                <button
+                  type="button"
+                  onClick={() => handleRejectSave(true)}
+                  disabled={verdictMutation.isPending}
+                  className="flex-1 px-3 py-2 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green/60 text-white text-sm transition-colors flex items-center justify-center gap-1.5"
+                >
+                  <RotateCcw className="w-4 h-4" />
+                  {t('confirmOutcome.rejectAndReprint')}
+                </button>
+              </div>
+              <button
+                type="button"
+                onClick={() => setRejecting(false)}
+                className="w-full mt-2 text-xs text-bambu-gray hover:text-white transition-colors"
+              >
+                {t('common.back')}
+              </button>
+            </div>
+          )}
+
+          {!alreadyDecided && !rejecting && (
+            <button
+              type="button"
+              onClick={onClose}
+              className="w-full mt-4 text-xs text-bambu-gray hover:text-white transition-colors"
+            >
+              {t('confirmOutcome.later')}
+            </button>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 52 - 5
frontend/src/components/EditArchiveModal.tsx

@@ -8,6 +8,7 @@ import { Button } from './Button';
 import { PrintLogTable } from './PrintLogTable';
 import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
 import { assignableProjects } from '../utils/projectTree';
+import { verdictSourceKey } from '../utils/verdictSource';
 
 // Keys for failure reasons - translated at render time.
 // Exported so the Print Log per-row classification editor (#1687 part 4)
@@ -89,6 +90,9 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
       ? failureReason
       : null;
   const [status, setStatus] = useState(archive.status);
+  // Post-print quality verdict (#1898): '' = none, 'good' / 'reject'
+  const [userVerdict, setUserVerdict] = useState<string>(archive.user_verdict ?? '');
+  const verdictSourceHintKey = verdictSourceKey(archive.user_verdict_source);
   const [quantity, setQuantity] = useState(archive.quantity ?? 1);
   // Kept as a string so the field can be genuinely empty: a print archived
   // without its 3MF has no figure at all, and "" has to stay distinguishable
@@ -246,6 +250,12 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
       updateData.status = status;
     }
 
+    // Verdict only when touched, same reasoning as status; '' clears it.
+    if (userVerdict !== (archive.user_verdict ?? '')) {
+      updateData.user_verdict = userVerdict === '' ? null : (userVerdict as 'good' | 'reject');
+      updateData.user_verdict_source = 'dialog';
+    }
+
     // Sent only when the user actually touched it, so an ordinary save of an
     // archive that has its 3MF cannot overwrite the sliced figure with a
     // rounded one from the input.
@@ -262,11 +272,19 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
       updateData.filament_used_grams = parsedGrams;
     }
 
-    // Handle failure_reason based on status
+    // Handle failure_reason based on status. A completed-but-rejected print
+    // (#1898) uses the same field as its reject reason.
     if (status === 'failed' || status === 'aborted') {
       updateData.failure_reason = failureReason || undefined;
-    } else if (archive.status === 'failed' || archive.status === 'aborted') {
-      // Clear failure_reason when changing from failed/aborted to another status
+    } else if (status === 'completed' && userVerdict === 'reject') {
+      updateData.failure_reason = failureReason || undefined;
+    } else if (archive.status === 'failed' || archive.status === 'aborted' || archive.user_verdict === 'reject') {
+      // Clear failure_reason when neither a failure nor a reject carries one.
+      // Gated on the archive having held a reject verdict rather than on the
+      // field merely being populated: a 'cancelled' or 'stopped' archive is
+      // neither failed nor aborted, its reason is written by the machine
+      // ("Stopped by user (printer was offline)"), and the status dropdown has
+      // no option that would let the user put it back.
       updateData.failure_reason = null;
     }
 
@@ -529,8 +547,37 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
             </select>
           </div>
 
-          {/* Failure Reason - only show for failed/aborted prints */}
-          {(status === 'failed' || status === 'aborted') && (
+          {/* Outcome verdict (#1898) - only meaningful on completed prints */}
+          {status === 'completed' && (
+            <div>
+              <label htmlFor="user-verdict-select" className="block text-sm text-bambu-gray mb-1">
+                {t('editArchive.userVerdict')}
+              </label>
+              <select
+                id="user-verdict-select"
+                value={userVerdict}
+                onChange={(e) => setUserVerdict(e.target.value)}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+              >
+                <option value="">{t('editArchive.verdicts.none')}</option>
+                <option value="good">{t('editArchive.verdicts.good')}</option>
+                <option value="reject">{t('editArchive.verdicts.reject')}</option>
+              </select>
+              {/* How the stored verdict got here (#1898) — hidden once the
+                  select no longer shows that verdict, since the hint would
+                  then describe an answer the user is replacing. */}
+              {verdictSourceHintKey && userVerdict === (archive.user_verdict ?? '') && (
+                <p className="text-xs text-bambu-gray/70 mt-1" data-testid="verdict-source-hint">
+                  {t(verdictSourceHintKey)}
+                </p>
+              )}
+            </div>
+          )}
+
+          {/* Failure Reason - shown for failed/aborted prints and for
+              completed-but-rejected ones (#1898: the reject reason reuses
+              this field and vocabulary) */}
+          {(status === 'failed' || status === 'aborted' || (status === 'completed' && userVerdict === 'reject')) && (
             <div>
               <label htmlFor="failure-reason-select" className="block text-sm text-bambu-gray mb-1">{t('editArchive.failureReason')}</label>
               <select

+ 56 - 1
frontend/src/components/Layout.tsx

@@ -14,6 +14,7 @@ import { useColorCatalogVersion } from '../hooks/useColorCatalogVersion';
 import { useSponsorPrompt } from '../hooks/useSponsorPrompt';
 import { useUnknownTagPrompt } from '../hooks/useUnknownTagPrompt';
 import { UnknownSpoolModal } from './UnknownSpoolModal';
+import { ConfirmOutcomeDialog } from './ConfirmOutcomeDialog';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 import { Card, CardHeader, CardContent } from './Card';
@@ -100,7 +101,7 @@ export function Layout() {
   // catalog fetched — and cached HSL-fallback color names during their first
   // render — refresh with the real catalog names. See #857.
   useColorCatalogVersion();
-  const { user, authEnabled, logout, hasPermission } = useAuth();
+  const { user, authEnabled, logout, hasPermission, hasAnyPermission, loading: authLoading } = useAuth();
   const { showToast } = useToast();
   const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
   const [changePasswordData, setChangePasswordData] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
@@ -124,6 +125,9 @@ export function Layout() {
     printer_name: string;
     message: string;
   } | null>(null);
+  // Post-print outcome confirmation (#1898): archive waiting for a verdict,
+  // set by the print_confirm_request WebSocket event.
+  const [confirmOutcomeArchiveId, setConfirmOutcomeArchiveId] = useState<number | null>(null);
 
   // Check for updates
   const { data: versionInfo } = useQuery({
@@ -460,6 +464,43 @@ export function Layout() {
     return () => window.removeEventListener('plate-not-empty', handlePlateNotEmpty);
   }, [hasPermission]);
 
+  // A completed print asked for its outcome verdict (#1898). Same CustomEvent
+  // relay as plate-not-empty, and gated the same way: the PATCH route decides
+  // who may record a verdict, so a user who cannot should not be handed a
+  // dialog whose only outcome is a 403.
+  // Held until the auth state has landed: while it is loading, `authEnabled`
+  // is still false and every permission check answers yes, which would open
+  // the dialog for exactly the user this gate exists to spare.
+  const canConfirmOutcome = !authLoading && hasAnyPermission('archives:update_all', 'archives:update_own');
+  useEffect(() => {
+    const handleConfirmRequest = (event: Event) => {
+      const detail = (event as CustomEvent).detail;
+      if (typeof detail?.archive_id === 'number') {
+        setConfirmOutcomeArchiveId(detail.archive_id);
+      }
+    };
+    window.addEventListener('print-confirm-request', handleConfirmRequest);
+    return () => window.removeEventListener('print-confirm-request', handleConfirmRequest);
+  }, []);
+
+  // The ?confirm=<id> deep link a push notification carries (#1898). It is read
+  // here and not on ArchivesPage because that page renders inside this Layout's
+  // <Outlet />: React flushes a child's effects before its parent's, so a page
+  // dispatching `print-confirm-request` on mount fired before the listener above
+  // existed — which is exactly the cold load a notification tap produces. The
+  // parameter is stripped afterwards so a reload does not re-open the dialog.
+  useEffect(() => {
+    const params = new URLSearchParams(location.search);
+    const confirmId = params.get('confirm');
+    if (!confirmId || !/^\d+$/.test(confirmId)) {
+      return;
+    }
+    setConfirmOutcomeArchiveId(Number(confirmId));
+    params.delete('confirm');
+    const rest = params.toString();
+    navigate({ pathname: location.pathname, search: rest ? `?${rest}` : '' }, { replace: true });
+  }, [location.pathname, location.search, navigate]);
+
   // Global keyboard shortcuts for navigation
   const handleKeyDown = useCallback((e: KeyboardEvent) => {
     const target = e.target as HTMLElement;
@@ -1021,6 +1062,20 @@ export function Layout() {
         </div>
       )}
 
+      {/* Post-print outcome confirmation (#1898) */}
+      {/* The dialog's only outcome for a user who may not record a verdict is
+          a 403 from the PATCH, so it is gated on the permissions that route
+          enforces — the names the June migration left in the default groups.
+          Gated on the render rather than on the two ways a request arrives:
+          one of them can land before /auth/me has answered, and a check made
+          then would drop it for everybody. */}
+      {canConfirmOutcome && confirmOutcomeArchiveId !== null && (
+        <ConfirmOutcomeDialog
+          archiveId={confirmOutcomeArchiveId}
+          onClose={() => setConfirmOutcomeArchiveId(null)}
+        />
+      )}
+
       {/* Change Password Modal */}
       {showChangePasswordModal && (
         <div

+ 14 - 0
frontend/src/components/NotificationProviderCard.tsx

@@ -126,6 +126,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {provider.on_plate_clear_required && (
               <span className="px-2 py-0.5 bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 text-xs rounded">{t('notifications.plateClear')}</span>
             )}
+            {provider.on_print_confirm_request && (
+              <span className="px-2 py-0.5 bg-teal-100 dark:bg-teal-500/20 text-teal-700 dark:text-teal-400 text-xs rounded">{t('notifications.printConfirmRequest')}</span>
+            )}
             {provider.on_print_failed && (
               <span className="px-2 py-0.5 bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400 text-xs rounded">{t('notifications.failed')}</span>
             )}
@@ -314,6 +317,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                   />
                 </div>
 
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">{t('notifications.printConfirmRequest')}</p>
+                    <p className="text-xs text-bambu-gray">{t('notifications.printConfirmRequestDescription')}</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_print_confirm_request ?? true}
+                    onChange={(checked) => updateMutation.mutate({ on_print_confirm_request: checked })}
+                  />
+                </div>
+
                 <div className="flex items-center justify-between">
                   <div>
                     <p className="text-sm text-white">{t('notifications.bedCooledLabel')}</p>

+ 2 - 0
frontend/src/components/PrintModal/PrintOptions.tsx

@@ -49,6 +49,8 @@ export function PrintOptionsPanel({
     { key: 'layer_inspect', label: t('settings.defaultLayerInspect'), desc: t('settings.defaultLayerInspectDesc') },
     { key: 'timelapse', label: t('settings.defaultTimelapse'), desc: t('settings.defaultTimelapseDesc') },
     { key: 'nozzle_offset_cali', label: t('settings.defaultNozzleOffsetCali'), desc: t('settings.defaultNozzleOffsetCaliDesc'), dualNozzleOnly: true, tristate: true },
+    // Post-print outcome confirmation (#1898) — same settings.default* reuse.
+    { key: 'confirm_outcome', label: t('settings.defaultConfirmOutcome'), desc: t('settings.defaultConfirmOutcomeDesc') },
   ];
 
   const visibleOptions = printOptionsConfig.filter(o => !o.dualNozzleOnly || showDualNozzleOptions);

+ 21 - 1
frontend/src/components/PrintModal/index.tsx

@@ -1,5 +1,5 @@
 import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
-import { AlertCircle, AlertTriangle, Loader2, Pencil, Printer, X } from 'lucide-react';
+import { AlertCircle, AlertTriangle, Loader2, Pencil, Printer, ThumbsUp, X } from 'lucide-react';
 import { useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import type { CostCenterSummary, PrintQueueItemCreate, PrintQueueItemUpdate, SlotMaterial } from '../../api/client';
@@ -175,6 +175,7 @@ export function PrintModal({
         layer_inspect: queueItem.layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,
         timelapse: queueItem.timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,
         nozzle_offset_cali: queueItem.nozzle_offset_cali ?? DEFAULT_PRINT_OPTIONS.nozzle_offset_cali,
+        confirm_outcome: queueItem.confirm_outcome ?? DEFAULT_PRINT_OPTIONS.confirm_outcome,
         preheat_override: queueItem.preheat_override ?? DEFAULT_PRINT_OPTIONS.preheat_override,
         preheat_chamber_target_override: queueItem.preheat_chamber_target_override ?? DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override,
       };
@@ -325,6 +326,7 @@ export function PrintModal({
       layer_inspect: settings.default_layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,
       timelapse: settings.default_timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,
       nozzle_offset_cali: settings.default_nozzle_offset_cali ?? DEFAULT_PRINT_OPTIONS.nozzle_offset_cali,
+      confirm_outcome: settings.default_confirm_outcome ?? DEFAULT_PRINT_OPTIONS.confirm_outcome,
       preheat_override: DEFAULT_PRINT_OPTIONS.preheat_override,
       preheat_chamber_target_override: DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override,
     });
@@ -1978,6 +1980,24 @@ export function PrintModal({
               hasGcodeSnippets={!!settings?.gcode_snippets}
             />
 
+            {/* Outcome prompt (#1898) sits outside the collapsed Print Options
+                panel so it is discoverable; it edits the same printOptions
+                field as the row inside the panel. */}
+            <button
+              type="button"
+              aria-pressed={printOptions.confirm_outcome}
+              title={t('printModal.askForOutcomeTitle')}
+              onClick={() => setPrintOptions((prev) => ({ ...prev, confirm_outcome: !prev.confirm_outcome }))}
+              className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm transition-colors ${
+                printOptions.confirm_outcome
+                  ? 'bg-bambu-green/20 border-bambu-green text-bambu-green'
+                  : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
+              }`}
+            >
+              <ThumbsUp className="w-4 h-4" />
+              {t('printModal.askForOutcome')}
+            </button>
+
             {/* Error message */}
             {updateQueueMutation.isError && (
               <div className="mb-4 p-3 bg-red-100 dark:bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-700 dark:text-red-400">

+ 3 - 0
frontend/src/components/PrintModal/types.ts

@@ -66,6 +66,8 @@ export interface PrintOptions {
   layer_inspect: boolean;
   timelapse: boolean;
   nozzle_offset_cali: CalibrationMode;
+  // Ask for a post-print outcome verdict when this job completes (#1898)
+  confirm_outcome: boolean;
   // Per-item preheat / heat-soak override (#1468). 'inherit' uses the global
   // Settings → Workflow toggle; 'on' / 'off' force the per-print decision.
   // chamber_target_override is non-null to bypass the per-filament-type
@@ -84,6 +86,7 @@ export const DEFAULT_PRINT_OPTIONS: PrintOptions = {
   layer_inspect: false,
   timelapse: false,
   nozzle_offset_cali: 'auto',
+  confirm_outcome: false,
   preheat_override: 'inherit',
   preheat_chamber_target_override: null,
 };

+ 11 - 0
frontend/src/hooks/useWebSocket.ts

@@ -353,6 +353,17 @@ export function useWebSocket() {
         debouncedInvalidate('archiveStats');
         break;
 
+      case 'print_confirm_request': {
+        // #1898: a completed print asked for its outcome verdict. Handed to
+        // the globally mounted ConfirmOutcomeDialog in Layout via the same
+        // CustomEvent relay plate_not_empty uses.
+        const detail = (message as unknown as { data?: { archive_id?: number; print_name?: string } }).data;
+        if (detail?.archive_id != null) {
+          window.dispatchEvent(new CustomEvent('print-confirm-request', { detail }));
+        }
+        break;
+      }
+
       case 'kill_switch_triggered': {
         const printer = message.printer_name || `Printer ${message.printer_id ?? '?'}`;
         const filename = message.filename || t('common.unknown');

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

@@ -950,6 +950,8 @@ export default {
     showFailedPrints: 'Fehlgeschlagene Drucke anzeigen',
     hideFailedPrints: 'Fehlgeschlagene Drucke ausblenden',
     hideDuplicates: 'Duplikate ausblenden',
+    filterUnconfirmed: 'Unbestätigt',
+    filterUnconfirmedTitle: 'Nur Drucke anzeigen, die noch auf ihre Ergebnis-Bewertung warten',
     viewOriginalPrint: 'Klicken, um den ursprünglichen Druck anzuzeigen (#{{id}})',
     printTime: 'Druckzeit',
     filamentUsed: 'Verbrauchtes Filament',
@@ -1004,6 +1006,7 @@ export default {
       slice: 'Slicen',
       externalLink: 'Externer Link',
       viewOnMakerWorld: 'Auf MakerWorld ansehen',
+      confirmOutcome: 'Ergebnis bestätigen',
       preview3d: '3D-Vorschau',
       viewTimelapse: 'Zeitraffer ansehen',
       scanForTimelapse: 'Nach Zeitraffer suchen',
@@ -1065,6 +1068,12 @@ export default {
       removeFromFavorites: 'Aus Favoriten entfernen',
       cancelled: 'abgebrochen',
       failed: 'fehlgeschlagen',
+      confirmPending: 'Ergebnis?',
+      confirmPendingTitle: 'Dieser Druck wartet auf eine Ergebnis-Bewertung — zum Antworten klicken',
+      rejected: 'Ausschuss',
+      rejectedTitle: 'Auf dem Drucker abgeschlossen, vom Benutzer als Ausschuss markiert',
+      confirmedGood: 'Gutteil',
+      confirmedGoodTitle: 'Vom Benutzer als Gutteil bestätigt',
       duplicate: 'Duplikat',
       duplicateTitle: 'Dieses Modell wurde bereits zuvor gedruckt',
       openSource3mf: 'Quell-3MF in Bambu Studio öffnen (Rechtsklick für mehr Optionen)',
@@ -1484,6 +1493,8 @@ export default {
       requiresPrevious: 'Erfordert vorherigen Erfolg',
       autoPowerOff: 'Automatisch ausschalten',
       gcodeInjection: 'G-Code',
+      confirmOutcome: 'Ergebnisabfrage',
+      confirmOutcomeTitle: 'Fragt nach Abschluss, wie der Druck geworden ist',
     },
     // Empty state
     empty: {
@@ -1525,6 +1536,7 @@ export default {
       layerInspection: 'Erste-Schicht-Prüfung',
       timelapse: 'Zeitraffer',
       useAms: 'AMS verwenden',
+      confirmOutcome: 'Nach Ergebnis fragen',
       nozzleOffsetCali: 'Düsenversatz-Kalibrierung',
       applyChanges: 'Änderungen übernehmen',
       selectAll: 'Alle auswählen',
@@ -1669,6 +1681,8 @@ export default {
     failureReasons: 'Fehlerursachen',
     topFailureReasons: 'Häufigste Fehlerursachen',
     failedPrintsCount: '{{failed}} / {{total}} Drucke fehlgeschlagen',
+    rejectedPrintsCount: '{{rejected}} nach Abschluss als Ausschuss markiert',
+    yieldRate: 'Ausbeute: {{rate}}%',
     lastWeekRate: 'Letzte Woche: {{rate}}%',
     // Actions
     resetLayout: 'Layout zurücksetzen',
@@ -2317,6 +2331,12 @@ export default {
     defaultLayerInspectDesc: 'KI-Inspektion der ersten Schicht',
     defaultTimelapse: 'Zeitraffer',
     defaultTimelapseDesc: 'Zeitraffervideo aufnehmen',
+    defaultConfirmOutcome: 'Nach Ergebnis fragen',
+    defaultConfirmOutcomeDesc: 'Nach Abschluss fragen, ob der Druck gut geworden ist',
+    confirmOutcomeExternalPrints: 'Auch bei Drucken fragen, die nicht über Bambuddy gestartet wurden',
+    confirmOutcomeExternalPrintsDesc: 'Drucke, die am Drucker, in Bambu Studio oder in der Handy-App gestartet wurden, landen ebenfalls im Archiv. Mit dieser Option bekommen sie dieselbe Ergebnisabfrage wie Aufträge aus der Warteschlange.',
+    confirmDefaultGoodOnPlateClear: 'Unbeantwortete Ergebnisse bei Druckplatten-Freigabe als gut zählen',
+    confirmDefaultGoodOnPlateClearDescription: 'Wenn die Druckplatte freigegeben wird (manuell oder durch den nächsten geplanten Druck) und die Ergebnisabfrage des Drucks noch unbeantwortet ist, wird er automatisch als Gutteil erfasst. Der Druck gilt dann als gut, sobald die Druckplatte freigegeben wird; eine Telegram- oder Link-Antwort danach zeigt nur noch das gespeicherte Ergebnis.',
     defaultNozzleOffsetCali: 'Düsenversatz-Kalibrierung',
     defaultNozzleOffsetCaliDesc: 'Düsenversatz zwischen Extrudern kalibrieren',
     tempFanPresetsTitle: 'Temperatur- und Lüfter-Vorgaben',
@@ -5127,6 +5147,8 @@ export default {
     insufficientBudget: 'Budget nicht ausreichend',
     unlimitedNoBudget: 'Unbegrenzt – es ist kein Budgetlimit festgelegt.',
     noPrintableCostCenters: 'Es ist keine aktive Kostenstelle zum Drucken verfügbar. Bitte einen Administrator, dir Druckzugriff zu gewähren.',
+    askForOutcome: 'Nach Ergebnis fragen',
+    askForOutcomeTitle: 'Nach Abschluss fragen, wie der Druck geworden ist',
   },
 
   // Backup
@@ -5445,6 +5467,32 @@ export default {
     uploadFailed: 'Hochladen fehlgeschlagen',
   },
 
+  confirmOutcome: {
+    title: 'Wie ist Ihr Druck geworden?',
+    good: 'Gut',
+    reject: 'Ausschuss',
+    later: 'Später fragen',
+    noPhoto: 'Kein Abschlussfoto verfügbar',
+    savedGood: 'Als Gutteil markiert',
+    savedReject: 'Als Ausschuss markiert',
+    saveFailed: 'Bewertung konnte nicht gespeichert werden',
+    rejectReason: 'Ausschussgrund (optional)',
+    noReason: 'Kein Grund',
+    saveReject: 'Speichern',
+    rejectAndReprint: 'Erneut drucken',
+    alreadyGood: 'Bereits als Gutteil bestätigt.',
+    alreadyRejected: 'Bereits als Ausschuss markiert.',
+    cardPrompt: 'Wie ist der Druck geworden?',
+    sources: {
+      dialog: 'In der App erfasst.',
+      link: 'Über einen Ein-Tipp-Link erfasst.',
+      plateClear: 'Bei der Freigabe der Druckplatte erfasst.',
+      printerCard: 'Über die Druckerkarte erfasst.',
+      api: 'Über die API erfasst.',
+      reaction: 'Über eine Reaktion im Chat erfasst.',
+    },
+  },
+
   // Edit archive modal
   editArchive: {
     title: 'Archiv bearbeiten',
@@ -5471,6 +5519,12 @@ export default {
     existingTags: 'Vorhandene Tags',
     clickToAdd: '(zum Hinzufügen klicken)',
     status: 'Status',
+    userVerdict: 'Ergebnis-Bewertung',
+    verdicts: {
+      none: 'Nicht festgelegt',
+      good: 'Gutteil',
+      reject: 'Ausschuss',
+    },
     failureReason: 'Fehlergrund',
     selectReason: 'Grund auswählen...',
     photos: 'Fotos des Druckergebnisses',
@@ -6186,6 +6240,8 @@ export default {
     plateNotEmptyDescription: 'Objekte vor dem Druck erkannt',
     plateClearRequired: 'Platte freigeben',
     plateClearRequiredDescription: 'Druck beendet, Warteschlange wartet auf Bestätigung',
+    printConfirmRequest: 'Ergebnis-Bestätigung',
+    printConfirmRequestDescription: 'Gut/Ausschuss-Bewertung mit Ein-Klick-Links anfordern, wenn ein dafür aktivierter Druck abgeschlossen wird',
     plateClear: 'Platte freigeben',
     printCompleted: 'Druck abgeschlossen',
     bedCooledLabel: 'Bett abgekühlt',

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

@@ -955,6 +955,8 @@ export default {
     showFailedPrints: 'Show failed prints',
     hideFailedPrints: 'Hide failed prints',
     hideDuplicates: 'Hide Duplicates',
+    filterUnconfirmed: 'Unconfirmed',
+    filterUnconfirmedTitle: 'Show only prints still waiting for their outcome verdict',
     viewOriginalPrint: 'Click to view original print (#{{id}})',
     printTime: 'Print Time',
     filamentUsed: 'Filament Used',
@@ -1009,6 +1011,7 @@ export default {
       slice: 'Slice',
       externalLink: 'External Link',
       viewOnMakerWorld: 'View on MakerWorld',
+      confirmOutcome: 'Confirm Outcome',
       preview3d: '3D Preview',
       viewTimelapse: 'View Timelapse',
       scanForTimelapse: 'Scan for Timelapse',
@@ -1070,6 +1073,12 @@ export default {
       removeFromFavorites: 'Remove from favorites',
       cancelled: 'cancelled',
       failed: 'failed',
+      confirmPending: 'outcome?',
+      confirmPendingTitle: 'This print asked for an outcome verdict — click to answer',
+      rejected: 'rejected',
+      rejectedTitle: 'Completed on the printer, marked as reject by the user',
+      confirmedGood: 'good',
+      confirmedGoodTitle: 'Confirmed as a good part by the user',
       duplicate: 'duplicate',
       duplicateTitle: 'This model has been printed before',
       openSource3mf: 'Open source 3MF in Bambu Studio (right-click for more options)',
@@ -1500,6 +1509,8 @@ export default {
       requiresPrevious: 'Requires previous success',
       autoPowerOff: 'Auto power off',
       gcodeInjection: 'G-code',
+      confirmOutcome: 'Outcome prompt',
+      confirmOutcomeTitle: 'Asks how the print came out after it completes',
     },
     // Empty state
     empty: {
@@ -1541,6 +1552,7 @@ export default {
       layerInspection: 'First layer inspection',
       timelapse: 'Timelapse',
       useAms: 'Use AMS',
+      confirmOutcome: 'Ask for outcome',
       nozzleOffsetCali: 'Nozzle offset calibration',
       applyChanges: 'Apply Changes',
       selectAll: 'Select All',
@@ -1686,6 +1698,8 @@ export default {
     failureReasons: 'Failure Reasons',
     topFailureReasons: 'Top Failure Reasons',
     failedPrintsCount: '{{failed}} / {{total}} prints failed',
+    rejectedPrintsCount: '{{rejected}} rejected after completion',
+    yieldRate: 'Yield: {{rate}}%',
     lastWeekRate: 'Last week: {{rate}}%',
     // Actions
     resetLayout: 'Reset Layout',
@@ -2337,6 +2351,12 @@ export default {
     defaultLayerInspectDesc: 'AI inspection of first layer',
     defaultTimelapse: 'Timelapse',
     defaultTimelapseDesc: 'Record timelapse video',
+    defaultConfirmOutcome: 'Ask for Outcome',
+    defaultConfirmOutcomeDesc: 'Ask whether the print came out well after it completes',
+    confirmOutcomeExternalPrints: 'Also ask for prints started outside Bambuddy',
+    confirmOutcomeExternalPrintsDesc: 'Prints started at the printer, in Bambu Studio or in the Handy app are archived by Bambuddy too. With this on, they get the same outcome prompt as queued prints.',
+    confirmDefaultGoodOnPlateClear: 'Count unanswered outcomes as good on plate release',
+    confirmDefaultGoodOnPlateClearDescription: 'When the plate is released (manually or by the next queued print) and the print\'s outcome prompt is still unanswered, record it as a good part automatically. The print then counts as good as soon as the plate is released; a Telegram or link answer after that only shows the recorded result.',
     defaultNozzleOffsetCali: 'Nozzle Offset Calibration',
     defaultNozzleOffsetCaliDesc: 'Calibrate nozzle offsets between extruders',
     tempFanPresetsTitle: 'Temperature & Fan Presets',
@@ -5172,6 +5192,8 @@ export default {
     insufficientBudget: 'Insufficient Budget',
     unlimitedNoBudget: 'Unlimited – no budget limit is set.',
     noPrintableCostCenters: 'No active cost center is available for printing. Ask an administrator to grant you print access.',
+    askForOutcome: 'Ask for Outcome',
+    askForOutcomeTitle: 'Ask how the print came out after it completes',
   },
 
   // Backup
@@ -5495,6 +5517,32 @@ export default {
     uploadFailed: 'Upload failed',
   },
 
+  // Post-print outcome confirmation dialog (#1898)
+  confirmOutcome: {
+    title: 'How did your print come out?',
+    good: 'Good',
+    reject: 'Reject',
+    later: 'Ask me later',
+    noPhoto: 'No finish photo available',
+    savedGood: 'Marked as good part',
+    savedReject: 'Marked as rejected',
+    saveFailed: 'Could not save the verdict',
+    rejectReason: 'Reject reason (optional)',
+    noReason: 'No reason',
+    saveReject: 'Save',
+    rejectAndReprint: 'Print again',
+    alreadyGood: 'Already confirmed as a good part.',
+    alreadyRejected: 'Already marked as rejected.',
+    cardPrompt: 'How did it come out?',
+    sources: {
+      dialog: 'Recorded in the app.',
+      link: 'Recorded with a one-tap link.',
+      plateClear: 'Recorded when the plate was cleared.',
+      printerCard: 'Recorded from the printer card.',
+      api: 'Recorded through the API.',
+      reaction: 'Recorded with a reaction in chat.',
+    },
+  },
   // Edit archive modal
   // Edit Archive Modal
   editArchive: {
@@ -5522,6 +5570,12 @@ export default {
     existingTags: 'Existing tags',
     clickToAdd: '(click to add)',
     status: 'Status',
+    userVerdict: 'Outcome Verdict',
+    verdicts: {
+      none: 'Not set',
+      good: 'Good part',
+      reject: 'Rejected',
+    },
     failureReason: 'Failure Reason',
     selectReason: 'Select reason...',
     photos: 'Photos of Printed Result',
@@ -6237,6 +6291,8 @@ export default {
     plateNotEmptyDescription: 'Objects detected before print',
     plateClearRequired: 'Plate Clear Required',
     plateClearRequiredDescription: 'Print finished, queue waits for plate confirmation',
+    printConfirmRequest: 'Outcome Confirmation',
+    printConfirmRequestDescription: 'Ask for a good/reject verdict with one-tap links when an opted-in print completes',
     plateClear: 'Plate Clear',
     printCompleted: 'Print Completed',
     bedCooledLabel: 'Bed Cooled',

+ 56 - 0
frontend/src/i18n/locales/es.ts

@@ -950,6 +950,8 @@ export default {
     showFailedPrints: 'Mostrar impresiones fallidas',
     hideFailedPrints: 'Ocultar impresiones fallidas',
     hideDuplicates: 'Ocultar duplicados',
+    filterUnconfirmed: 'Sin confirmar',
+    filterUnconfirmedTitle: 'Mostrar solo las impresiones que aún esperan su veredicto de resultado',
     viewOriginalPrint: 'Haga clic para ver la impresión original (n.º {{id}})',
     printTime: 'Tiempo de impresión',
     filamentUsed: 'Filamento usado',
@@ -1004,6 +1006,7 @@ export default {
       slice: 'Laminar',
       externalLink: 'Enlace externo',
       viewOnMakerWorld: 'Ver en MakerWorld',
+      confirmOutcome: 'Confirmar resultado',
       preview3d: 'Vista previa 3D',
       viewTimelapse: 'Ver time-lapse',
       scanForTimelapse: 'Buscar time-lapse',
@@ -1065,6 +1068,12 @@ export default {
       removeFromFavorites: 'Quitar de favoritos',
       cancelled: 'cancelada',
       failed: 'fallida',
+      confirmPending: '¿resultado?',
+      confirmPendingTitle: 'Esta impresión solicitó un veredicto de resultado — haga clic para responder',
+      rejected: 'rechazada',
+      rejectedTitle: 'Completada en la impresora, marcada como rechazo por el usuario',
+      confirmedGood: 'buena',
+      confirmedGoodTitle: 'Confirmada como pieza buena por el usuario',
       duplicate: 'duplicado',
       duplicateTitle: 'Este modelo ya se ha impreso anteriormente',
       openSource3mf: 'Abrir el 3MF de origen en Bambu Studio (clic derecho para más opciones)',
@@ -1484,6 +1493,8 @@ export default {
       requiresPrevious: 'Requiere éxito previo',
       autoPowerOff: 'Apagado automático',
       gcodeInjection: 'G-code',
+      confirmOutcome: 'Consulta de resultado',
+      confirmOutcomeTitle: 'Pregunta cómo salió la impresión al terminar',
     },
     // Empty state
     empty: {
@@ -1525,6 +1536,7 @@ export default {
       layerInspection: 'Inspección de la primera capa',
       timelapse: 'Time-lapse',
       useAms: 'Usar AMS',
+      confirmOutcome: 'Preguntar el resultado',
       nozzleOffsetCali: 'Calibración del offset de boquillas',
       applyChanges: 'Aplicar cambios',
       selectAll: 'Seleccionar todo',
@@ -1669,6 +1681,8 @@ export default {
     failureReasons: 'Motivos de fallo',
     topFailureReasons: 'Principales motivos de fallo',
     failedPrintsCount: '{{failed}} / {{total}} impresiones fallidas',
+    rejectedPrintsCount: '{{rejected}} rechazadas tras completarse',
+    yieldRate: 'Rendimiento: {{rate}}%',
     lastWeekRate: 'Semana pasada: {{rate}}%',
     // Actions
     resetLayout: 'Restablecer disposición',
@@ -2320,6 +2334,12 @@ export default {
     defaultLayerInspectDesc: 'Inspección de la primera capa por IA',
     defaultTimelapse: 'Time-lapse',
     defaultTimelapseDesc: 'Grabar vídeo time-lapse',
+    defaultConfirmOutcome: 'Preguntar el resultado',
+    defaultConfirmOutcomeDesc: 'Preguntar si la impresión ha salido bien después de completarse',
+    confirmOutcomeExternalPrints: 'Preguntar también por impresiones iniciadas fuera de Bambuddy',
+    confirmOutcomeExternalPrintsDesc: 'Las impresiones iniciadas en la impresora, en Bambu Studio o en la app Handy también se archivan en Bambuddy. Con esta opción reciben la misma pregunta de resultado que los trabajos de la cola.',
+    confirmDefaultGoodOnPlateClear: 'Contar resultados sin responder como buenos al despejar la cama',
+    confirmDefaultGoodOnPlateClearDescription: 'Cuando se despeja la cama (manualmente o por la siguiente impresión en cola) y la pregunta sobre el resultado de la impresión sigue sin responder, registrarla automáticamente como pieza buena. La impresión cuenta entonces como buena en cuanto se despeja la cama; una respuesta posterior por Telegram o por enlace solo muestra el resultado ya registrado.',
     defaultNozzleOffsetCali: 'Calibración del offset de boquillas',
     defaultNozzleOffsetCaliDesc: 'Calibrar los desplazamientos entre boquillas',
     tempFanPresetsTitle: 'Predefinidos de temperatura y ventilador',
@@ -5134,6 +5154,8 @@ export default {
     insufficientBudget: 'Presupuesto insuficiente',
     unlimitedNoBudget: 'Ilimitado: no se ha establecido ningún límite de presupuesto.',
     noPrintableCostCenters: 'No hay ningún centro de costes activo disponible para imprimir. Pide a un administrador que te conceda acceso de impresión.',
+    askForOutcome: 'Preguntar el resultado',
+    askForOutcomeTitle: 'Preguntar cómo salió la impresión al terminar',
   },
 
   // Backup
@@ -5452,6 +5474,32 @@ export default {
     uploadFailed: 'Error al subir',
   },
 
+  confirmOutcome: {
+    title: '¿Cómo ha salido su impresión?',
+    good: 'Buena',
+    reject: 'Rechazar',
+    later: 'Preguntar más tarde',
+    noPhoto: 'No hay foto del acabado disponible',
+    savedGood: 'Marcada como pieza buena',
+    savedReject: 'Marcada como rechazada',
+    saveFailed: 'No se pudo guardar el veredicto',
+    rejectReason: 'Motivo del rechazo (opcional)',
+    noReason: 'Sin motivo',
+    saveReject: 'Guardar',
+    rejectAndReprint: 'Imprimir de nuevo',
+    alreadyGood: 'Ya está confirmada como pieza buena.',
+    alreadyRejected: 'Ya está marcada como rechazada.',
+    cardPrompt: '¿Cómo ha salido?',
+    sources: {
+      dialog: 'Registrado en la aplicación.',
+      link: 'Registrado con un enlace de un toque.',
+      plateClear: 'Registrado al despejar la cama.',
+      printerCard: 'Registrado desde la tarjeta de la impresora.',
+      api: 'Registrado a través de la API.',
+      reaction: 'Registrado con una reacción en el chat.',
+    },
+  },
+
   // Edit archive modal
   // Edit Archive Modal
   editArchive: {
@@ -5479,6 +5527,12 @@ export default {
     existingTags: 'Etiquetas existentes',
     clickToAdd: '(haga clic para añadir)',
     status: 'Estado',
+    userVerdict: 'Veredicto del resultado',
+    verdicts: {
+      none: 'Sin establecer',
+      good: 'Pieza buena',
+      reject: 'Rechazada',
+    },
     failureReason: 'Motivo del fallo',
     selectReason: 'Seleccionar motivo...',
     photos: 'Fotos del resultado impreso',
@@ -6194,6 +6248,8 @@ export default {
     plateNotEmptyDescription: 'Objetos detectados antes de la impresión',
     plateClearRequired: 'Confirmar cama libre',
     plateClearRequiredDescription: 'Impresión terminada, la cola espera confirmación',
+    printConfirmRequest: 'Confirmación de resultado',
+    printConfirmRequestDescription: 'Solicitar un veredicto de buena/rechazo con enlaces de un solo toque cuando se complete una impresión que lo tenga activado',
     plateClear: 'Cama libre',
     printCompleted: 'Impresión completada',
     bedCooledLabel: 'Cama enfriada',

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

@@ -950,6 +950,8 @@ export default {
     showFailedPrints: 'Afficher les échecs',
     hideFailedPrints: 'Masquer les échecs',
     hideDuplicates: 'Masquer les doublons',
+    filterUnconfirmed: 'Non confirmées',
+    filterUnconfirmedTitle: 'Afficher uniquement les impressions en attente de leur verdict de résultat',
     viewOriginalPrint: 'Cliquez pour afficher l\'impression originale (#{{id}})',
     printTime: 'Temps d\'impression',
     filamentUsed: 'Filament utilisé',
@@ -1004,6 +1006,7 @@ export default {
       slice: 'Découper',
       externalLink: 'Lien externe',
       viewOnMakerWorld: 'Voir sur MakerWorld',
+      confirmOutcome: 'Confirmer le résultat',
       preview3d: 'Aperçu 3D',
       viewTimelapse: 'Voir le Timelapse',
       scanForTimelapse: 'Scanner pour Timelapse',
@@ -1065,6 +1068,12 @@ export default {
       removeFromFavorites: 'Retirer des favoris',
       cancelled: 'annulé',
       failed: 'échoué',
+      confirmPending: 'résultat ?',
+      confirmPendingTitle: 'Cette impression attend un verdict de résultat — cliquez pour répondre',
+      rejected: 'rebuté',
+      rejectedTitle: 'Terminée sur l\'imprimante, marquée comme rebut par l\'utilisateur',
+      confirmedGood: 'réussi',
+      confirmedGoodTitle: 'Confirmée comme pièce réussie par l\'utilisateur',
       duplicate: 'doublon',
       duplicateTitle: 'Ce modèle a déjà été imprimé',
       openSource3mf: 'Ouvrir 3MF dans Bambu Studio (clic droit pour plus)',
@@ -1484,6 +1493,8 @@ export default {
       requiresPrevious: 'Nécessite succès précédent',
       autoPowerOff: 'Extinction auto',
       gcodeInjection: 'G-code',
+      confirmOutcome: 'Demande de résultat',
+      confirmOutcomeTitle: 'Demande comment l’impression s’est déroulée une fois terminée',
     },
     // Empty state
     empty: {
@@ -1525,6 +1536,7 @@ export default {
       layerInspection: 'Inspection 1ère couche',
       timelapse: 'Time-lapse',
       useAms: 'Utiliser AMS',
+      confirmOutcome: 'Demander le résultat',
       nozzleOffsetCali: 'Calibration du décalage des buses',
       applyChanges: 'Appliquer',
       selectAll: 'Tout sélectionner',
@@ -1669,6 +1681,8 @@ export default {
     failureReasons: 'Raisons des échecs',
     topFailureReasons: 'Top raisons d\'échec',
     failedPrintsCount: '{{failed}} / {{total}} impressions ont échoué',
+    rejectedPrintsCount: '{{rejected}} rebutées après achèvement',
+    yieldRate: 'Rendement : {{rate}}%',
     lastWeekRate: 'Semaine dernière : {{rate}}%',
     // Actions
     resetLayout: 'Réinitialiser la mise en page',
@@ -2273,6 +2287,12 @@ export default {
     defaultLayerInspectDesc: 'Inspection IA de la première couche',
     defaultTimelapse: 'Time-lapse',
     defaultTimelapseDesc: 'Enregistrer une vidéo timelapse',
+    defaultConfirmOutcome: 'Demander le résultat',
+    defaultConfirmOutcomeDesc: 'Demander si l\'impression est bien sortie une fois terminée',
+    confirmOutcomeExternalPrints: 'Demander aussi pour les impressions lancées hors de Bambuddy',
+    confirmOutcomeExternalPrintsDesc: 'Les impressions lancées sur l\'imprimante, dans Bambu Studio ou dans l\'application Handy sont elles aussi archivées par Bambuddy. Avec cette option, elles reçoivent la même demande de résultat que les travaux de la file d\'attente.',
+    confirmDefaultGoodOnPlateClear: 'Compter les résultats sans réponse comme bons à la libération du plateau',
+    confirmDefaultGoodOnPlateClearDescription: 'Lorsque le plateau est libéré (manuellement ou par l\'impression suivante en file d\'attente) et que la demande de résultat de l\'impression reste sans réponse, l\'enregistrer automatiquement comme bonne pièce. Une fois le plateau libéré, la pièce compte comme bonne ; une réponse ultérieure par Telegram ou par lien affiche seulement le résultat enregistré.',
     defaultNozzleOffsetCali: 'Calibration du décalage des buses',
     defaultNozzleOffsetCaliDesc: 'Calibrer les décalages entre les buses',
     tempFanPresetsTitle: 'Préréglages de température et de ventilateur',
@@ -5116,6 +5136,8 @@ export default {
     insufficientBudget: 'Budget insuffisant',
     unlimitedNoBudget: 'Illimité – aucune limite de budget n’est définie.',
     noPrintableCostCenters: 'Aucun centre de coûts actif n’est disponible pour l’impression. Demandez à un administrateur de vous accorder l’accès à l’impression.',
+    askForOutcome: 'Demander le résultat',
+    askForOutcomeTitle: 'Demander comment l’impression s’est déroulée une fois terminée',
   },
 
   // Backup
@@ -5434,6 +5456,32 @@ export default {
     uploadFailed: 'Échec du téléversement',
   },
 
+  confirmOutcome: {
+    title: 'Comment votre impression est-elle sortie ?',
+    good: 'Bonne',
+    reject: 'Rebut',
+    later: 'Me redemander plus tard',
+    noPhoto: 'Aucune photo de fin disponible',
+    savedGood: 'Marquée comme bonne pièce',
+    savedReject: 'Marquée comme rebut',
+    saveFailed: 'Impossible d\'enregistrer le verdict',
+    rejectReason: 'Raison du rebut (facultatif)',
+    noReason: 'Aucune raison',
+    saveReject: 'Enregistrer',
+    rejectAndReprint: 'Réimprimer',
+    alreadyGood: 'Déjà confirmée comme bonne pièce.',
+    alreadyRejected: 'Déjà marquée comme rebut.',
+    cardPrompt: 'Comment est-elle sortie ?',
+    sources: {
+      dialog: 'Enregistré depuis Bambuddy.',
+      link: 'Enregistré via un lien rapide.',
+      plateClear: 'Enregistré lors de la libération du plateau.',
+      printerCard: 'Enregistré depuis la carte imprimante.',
+      api: 'Enregistré par API.',
+      reaction: 'Enregistré par une réaction dans le chat.',
+    },
+  },
+
   // Edit archive modal
   // Edit Archive Modal
   editArchive: {
@@ -5461,6 +5509,12 @@ export default {
     existingTags: 'Tags existants',
     clickToAdd: '(cliquer pour ajouter)',
     status: 'Statut',
+    userVerdict: 'Verdict de résultat',
+    verdicts: {
+      none: 'Non défini',
+      good: 'Bonne pièce',
+      reject: 'Rebutée',
+    },
     failureReason: 'Raison de l\'échec',
     selectReason: 'Choisir raison...',
     photos: 'Photos du résultat',
@@ -6176,6 +6230,8 @@ export default {
     plateNotEmptyDescription: 'Objets détectés avant l\'impression',
     plateClearRequired: 'Plateau à libérer',
     plateClearRequiredDescription: 'Impression terminée, la file attend la confirmation',
+    printConfirmRequest: 'Confirmation du résultat',
+    printConfirmRequestDescription: 'Demander un verdict bon/rebut avec des liens en un clic à la fin d\'une impression ayant activé l\'option',
     plateClear: 'Plateau libre',
     printCompleted: 'Impression terminée',
     bedCooledLabel: 'Plateau refroidi',

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

@@ -950,6 +950,8 @@ export default {
     showFailedPrints: 'Mostra stampe fallite',
     hideFailedPrints: 'Nascondi stampe fallite',
     hideDuplicates: 'Nascondi duplicati',
+    filterUnconfirmed: 'Non confermate',
+    filterUnconfirmedTitle: 'Mostra solo le stampe ancora in attesa del verdetto sull\'esito',
     viewOriginalPrint: 'Fai clic per visualizzare la stampa originale (#{{id}})',
     printTime: 'Tempo di stampa',
     filamentUsed: 'Filamento usato',
@@ -1004,6 +1006,7 @@ export default {
       slice: 'Slice',
       externalLink: 'Link esterno',
       viewOnMakerWorld: 'Vedi su MakerWorld',
+      confirmOutcome: 'Conferma esito',
       preview3d: 'Anteprima 3D',
       viewTimelapse: 'Vedi Timelapse',
       scanForTimelapse: 'Cerca Timelapse',
@@ -1065,6 +1068,12 @@ export default {
       removeFromFavorites: 'Rimuovi dai preferiti',
       cancelled: 'annullato',
       failed: 'fallito',
+      confirmPending: 'esito?',
+      confirmPendingTitle: 'Questa stampa ha richiesto un verdetto sull\'esito — clicca per rispondere',
+      rejected: 'scartato',
+      rejectedTitle: 'Completata sulla stampante, contrassegnata come scarto dall\'utente',
+      confirmedGood: 'riuscito',
+      confirmedGoodTitle: 'Confermato come pezzo riuscito dall\'utente',
       duplicate: 'duplicato',
       duplicateTitle: 'Questo modello è stato stampato prima',
       openSource3mf: 'Apri sorgente 3MF in Bambu Studio (clic destro per altre opzioni)',
@@ -1484,6 +1493,8 @@ export default {
       requiresPrevious: 'Richiede successo precedente',
       autoPowerOff: 'Spegnimento automatico',
       gcodeInjection: 'G-code',
+      confirmOutcome: 'Richiesta esito',
+      confirmOutcomeTitle: 'Chiede com’è venuta la stampa una volta completata',
     },
     // Empty state
     empty: {
@@ -1525,6 +1536,7 @@ export default {
       layerInspection: 'Controllo primo layer',
       timelapse: 'Timelapse',
       useAms: 'Usa AMS',
+      confirmOutcome: 'Chiedi l’esito',
       nozzleOffsetCali: 'Calibrazione offset ugelli',
       applyChanges: 'Applica modifiche',
       selectAll: 'Seleziona tutto',
@@ -1669,6 +1681,8 @@ export default {
     failureReasons: 'Cause guasto',
     topFailureReasons: 'Cause principali',
     failedPrintsCount: '{{failed}} / {{total}} stampe fallite',
+    rejectedPrintsCount: '{{rejected}} scartate dopo il completamento',
+    yieldRate: 'Resa: {{rate}}%',
     lastWeekRate: 'Settimana scorsa: {{rate}}%',
     // Actions
     resetLayout: 'Reimposta layout',
@@ -2273,6 +2287,12 @@ export default {
     defaultLayerInspectDesc: 'Ispezione IA del primo strato',
     defaultTimelapse: 'Timelapse',
     defaultTimelapseDesc: 'Registra un video timelapse',
+    defaultConfirmOutcome: 'Chiedi esito',
+    defaultConfirmOutcomeDesc: 'Chiedi se la stampa è venuta bene una volta completata',
+    confirmOutcomeExternalPrints: 'Chiedi anche per le stampe avviate fuori da Bambuddy',
+    confirmOutcomeExternalPrintsDesc: 'Le stampe avviate sulla stampante, in Bambu Studio o nell\'app Handy vengono archiviate anche da Bambuddy. Con questa opzione ricevono la stessa richiesta di esito dei lavori in coda.',
+    confirmDefaultGoodOnPlateClear: 'Conta gli esiti senza risposta come buoni alla liberazione del piatto',
+    confirmDefaultGoodOnPlateClearDescription: 'Quando il piatto viene liberato (manualmente o dalla stampa successiva in coda) e la richiesta di esito della stampa è ancora senza risposta, registra automaticamente il pezzo come buono. La stampa vale come buona non appena il piatto viene liberato; una risposta successiva via Telegram o tramite link mostra solo il risultato registrato.',
     defaultNozzleOffsetCali: 'Calibrazione offset ugelli',
     defaultNozzleOffsetCaliDesc: 'Calibra gli offset tra gli ugelli',
     tempFanPresetsTitle: 'Preset di temperatura e ventola',
@@ -5115,6 +5135,8 @@ export default {
     insufficientBudget: 'Budget insufficiente',
     unlimitedNoBudget: 'Illimitato – non è impostato alcun limite di budget.',
     noPrintableCostCenters: 'Non è disponibile alcun centro di costo attivo per la stampa. Chiedi a un amministratore di concederti l’accesso alla stampa.',
+    askForOutcome: 'Chiedi l’esito',
+    askForOutcomeTitle: 'Chiedi com’è venuta la stampa una volta completata',
   },
 
   // Backup
@@ -5433,6 +5455,32 @@ export default {
     uploadFailed: 'Caricamento fallito',
   },
 
+  confirmOutcome: {
+    title: 'Com\'è venuta la tua stampa?',
+    good: 'Buona',
+    reject: 'Scarto',
+    later: 'Chiedimelo più tardi',
+    noPhoto: 'Nessuna foto finale disponibile',
+    savedGood: 'Contrassegnata come pezzo buono',
+    savedReject: 'Contrassegnata come scartata',
+    saveFailed: 'Impossibile salvare il verdetto',
+    rejectReason: 'Motivo dello scarto (opzionale)',
+    noReason: 'Nessun motivo',
+    saveReject: 'Salva',
+    rejectAndReprint: 'Stampa di nuovo',
+    alreadyGood: 'Già confermata come pezzo buono.',
+    alreadyRejected: 'Già contrassegnata come scartata.',
+    cardPrompt: 'Com\'è venuta?',
+    sources: {
+      dialog: 'Registrato in Bambuddy.',
+      link: 'Registrato con un link rapido.',
+      plateClear: 'Registrato alla liberazione del piatto.',
+      printerCard: 'Registrato dalla scheda stampante.',
+      api: 'Registrato tramite API.',
+      reaction: 'Registrato con una reazione in chat.',
+    },
+  },
+
   // Edit archive modal
   // Edit Archive Modal
   editArchive: {
@@ -5460,6 +5508,12 @@ export default {
     existingTags: 'Tag esistenti',
     clickToAdd: '(clicca per aggiungere)',
     status: 'Stato',
+    userVerdict: 'Verdetto esito',
+    verdicts: {
+      none: 'Non impostato',
+      good: 'Pezzo buono',
+      reject: 'Scartato',
+    },
     failureReason: 'Motivo fallimento',
     selectReason: 'Seleziona motivo...',
     photos: 'Foto del risultato stampato',
@@ -6175,6 +6229,8 @@ export default {
     plateNotEmptyDescription: 'Oggetti rilevati prima della stampa',
     plateClearRequired: 'Conferma piatto libero',
     plateClearRequiredDescription: 'Stampa finita, la coda attende conferma',
+    printConfirmRequest: 'Conferma esito',
+    printConfirmRequestDescription: 'Chiedi un verdetto buona/scarto con link a un tocco quando si completa una stampa con l\'opzione attiva',
     plateClear: 'Piatto libero',
     printCompleted: 'Stampa completata',
     bedCooledLabel: 'Piatto raffreddato',

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

@@ -949,6 +949,8 @@ export default {
     showFailedPrints: '失敗した印刷を表示',
     hideFailedPrints: '失敗した印刷を非表示',
     hideDuplicates: '重複を非表示',
+    filterUnconfirmed: '未確認',
+    filterUnconfirmedTitle: '結果の判定待ちの印刷のみを表示',
     viewOriginalPrint: 'クリックして元の印刷を表示 (#{{id}})',
     printTime: '印刷時間',
     filamentUsed: 'フィラメント使用量',
@@ -1003,6 +1005,7 @@ export default {
       slice: 'スライス',
       externalLink: '外部リンク',
       viewOnMakerWorld: 'MakerWorldで表示',
+      confirmOutcome: '結果を確認',
       preview3d: '3Dプレビュー',
       viewTimelapse: 'タイムラプスを表示',
       scanForTimelapse: 'タイムラプスをスキャン',
@@ -1064,6 +1067,12 @@ export default {
       removeFromFavorites: 'お気に入りから削除',
       cancelled: 'キャンセル',
       failed: '失敗',
+      confirmPending: '結果は?',
+      confirmPendingTitle: 'この印刷は結果の判定を求めています — クリックして回答',
+      rejected: '不良',
+      rejectedTitle: 'プリンターでは完了しましたが、ユーザーにより不良と判定されました',
+      confirmedGood: '良品',
+      confirmedGoodTitle: 'ユーザーにより良品と確認されました',
       duplicate: '重複',
       duplicateTitle: 'このモデルは以前印刷されています',
       openSource3mf: 'ソース3MFをBambu Studioで開く(右クリックでオプション表示)',
@@ -1483,6 +1492,8 @@ export default {
       requiresPrevious: '前の成功が必要',
       autoPowerOff: '自動電源オフ',
       gcodeInjection: 'G-code',
+      confirmOutcome: '結果確認',
+      confirmOutcomeTitle: '完了後に印刷の仕上がりを確認します',
     },
     // Empty state
     empty: {
@@ -1524,6 +1535,7 @@ export default {
       layerInspection: '第一層検査',
       timelapse: 'タイムラプス',
       useAms: 'AMS使用',
+      confirmOutcome: '結果を確認する',
       nozzleOffsetCali: 'ノズルオフセットキャリブレーション',
       applyChanges: '変更を適用',
       selectAll: 'すべて選択',
@@ -1668,6 +1680,8 @@ export default {
     failureReasons: '失敗理由',
     topFailureReasons: '主な失敗理由',
     failedPrintsCount: '{{failed}} / {{total}} 件の印刷が失敗',
+    rejectedPrintsCount: '完了後に {{rejected}} 件が不良と判定',
+    yieldRate: '歩留まり: {{rate}}%',
     lastWeekRate: '先週: {{rate}}%',
     // Actions
     resetLayout: 'レイアウトをリセット',
@@ -2316,6 +2330,12 @@ export default {
     defaultLayerInspectDesc: 'AIによる第1層の検査',
     defaultTimelapse: 'タイムラプス',
     defaultTimelapseDesc: 'タイムラプス動画を記録',
+    defaultConfirmOutcome: '結果の確認',
+    defaultConfirmOutcomeDesc: '印刷完了後に仕上がりの良否を確認',
+    confirmOutcomeExternalPrints: 'Bambuddy 以外で開始した印刷にも確認する',
+    confirmOutcomeExternalPrintsDesc: 'プリンター本体、Bambu Studio、Handy アプリから開始した印刷も Bambuddy がアーカイブします。この設定をオンにすると、キューの印刷と同じ結果確認が届きます。',
+    confirmDefaultGoodOnPlateClear: 'プレートクリア時に未回答の結果を良品として記録',
+    confirmDefaultGoodOnPlateClearDescription: 'プレートがクリアされた際(手動またはキューの次の印刷による)、その印刷の結果確認が未回答の場合、自動的に良品として記録します。プレートがクリアされた時点でその印刷は良品として扱われ、その後に Telegram やリンクから回答しても、記録済みの結果が表示されるだけです。',
     defaultNozzleOffsetCali: 'ノズルオフセットキャリブレーション',
     defaultNozzleOffsetCaliDesc: 'エクストルーダー間のノズルオフセットを校正',
     tempFanPresetsTitle: '温度・ファンプリセット',
@@ -5127,6 +5147,8 @@ export default {
     insufficientBudget: '予算が不足しています',
     unlimitedNoBudget: '無制限 – 予算上限は設定されていません。',
     noPrintableCostCenters: '印刷に利用できる有効なコストセンターがありません。管理者に印刷アクセスの付与を依頼してください。',
+    askForOutcome: '結果を確認する',
+    askForOutcomeTitle: '完了後に印刷の仕上がりを尋ねます',
   },
 
   // Backup
@@ -5445,6 +5467,32 @@ export default {
     uploadFailed: 'アップロード失敗',
   },
 
+  confirmOutcome: {
+    title: '印刷の仕上がりはいかがでしたか?',
+    good: '良品',
+    reject: '不良',
+    later: '後で確認',
+    noPhoto: '完成写真がありません',
+    savedGood: '良品として記録しました',
+    savedReject: '不良として記録しました',
+    saveFailed: '判定を保存できませんでした',
+    rejectReason: '不良理由(任意)',
+    noReason: '理由なし',
+    saveReject: '保存',
+    rejectAndReprint: 'もう一度印刷',
+    alreadyGood: 'すでに良品として確認済みです。',
+    alreadyRejected: 'すでに不良として記録済みです。',
+    cardPrompt: '仕上がりはどうでしたか?',
+    sources: {
+      dialog: 'アプリで記録されました。',
+      link: 'ワンタップリンクで記録されました。',
+      plateClear: 'プレートのクリア時に記録されました。',
+      printerCard: 'プリンターカードから記録されました。',
+      api: 'API 経由で記録されました。',
+      reaction: 'チャットのリアクションで記録されました。',
+    },
+  },
+
   // Edit archive modal
   // Edit Archive Modal
   editArchive: {
@@ -5472,6 +5520,12 @@ export default {
     existingTags: '既存のタグ',
     clickToAdd: '(クリックして追加)',
     status: 'ステータス',
+    userVerdict: '結果判定',
+    verdicts: {
+      none: '未設定',
+      good: '良品',
+      reject: '不良',
+    },
     failureReason: '失敗理由',
     selectReason: '理由を選択...',
     photos: '印刷結果の写真',
@@ -6187,6 +6241,8 @@ export default {
     plateNotEmptyDescription: '印刷前にオブジェクトが検出されました',
     plateClearRequired: 'プレート確認が必要',
     plateClearRequiredDescription: '印刷完了、キューはプレート確認を待機中',
+    printConfirmRequest: '結果確認',
+    printConfirmRequestDescription: '確認を有効にした印刷の完了時に、ワンタップリンクで良品/不良の判定を求める',
     plateClear: 'プレート確認',
     printCompleted: '印刷完了',
     bedCooledLabel: 'ベッド冷却済み',

+ 56 - 1
frontend/src/i18n/locales/ko.ts

@@ -906,6 +906,8 @@ export default {
     showFailedPrints: '실패한 인쇄 표시',
     hideFailedPrints: '실패한 인쇄 숨기기',
     hideDuplicates: '중복 숨기기',
+    filterUnconfirmed: '미확인',
+    filterUnconfirmedTitle: '결과 판정을 기다리는 인쇄만 표시',
     viewOriginalPrint: '원본 인쇄 보기 클릭 (#{{id}})',
     printTime: '인쇄 시간',
     filamentUsed: '사용된 필라멘트',
@@ -960,6 +962,7 @@ export default {
       slice: '슬라이스',
       externalLink: '외부 링크',
       viewOnMakerWorld: 'MakerWorld에서 보기',
+      confirmOutcome: '결과 확인',
       preview3d: '3D 미리보기',
       viewTimelapse: '타임랩스 보기',
       scanForTimelapse: '타임랩스 검색',
@@ -1021,6 +1024,12 @@ export default {
       removeFromFavorites: '즐겨찾기에서 제거',
       cancelled: '취소됨',
       failed: '실패',
+      confirmPending: '결과는?',
+      confirmPendingTitle: '이 인쇄는 결과 판정을 요청했습니다 — 클릭하여 답변하세요',
+      rejected: '불량',
+      rejectedTitle: '프린터에서는 완료되었지만 사용자가 불량으로 표시함',
+      confirmedGood: '양품',
+      confirmedGoodTitle: '사용자가 양품으로 확인함',
       duplicate: '중복',
       duplicateTitle: '이 모델은 이전에 인쇄된 적이 있습니다',
       openSource3mf: 'Bambu Studio에서 소스 3MF 열기 (더 많은 옵션은 우클릭)',
@@ -1414,7 +1423,9 @@ export default {
       staged: '준비됨',
       requiresPrevious: '이전 성공 필요',
       autoPowerOff: '자동 전원 끄기',
-      gcodeInjection: 'G코드'
+      gcodeInjection: 'G코드',
+      confirmOutcome: '결과 확인',
+      confirmOutcomeTitle: '완료 후 출력 결과가 어땠는지 물어봅니다',
     },
     empty: {
       title: '예약된 인쇄 없음',
@@ -1452,6 +1463,7 @@ export default {
       layerInspection: '첫 번째 층 검사',
       timelapse: '타임랩스',
       useAms: 'AMS 사용',
+      confirmOutcome: '결과 묻기',
       nozzleOffsetCali: '노즐 오프셋 보정',
       applyChanges: '변경사항 적용',
       selectAll: '전체 선택',
@@ -1602,6 +1614,8 @@ export default {
     failureReasons: '실패 원인',
     topFailureReasons: '주요 실패 원인',
     failedPrintsCount: '{{total}}개 인쇄 중 {{failed}}개 실패',
+    rejectedPrintsCount: '완료 후 불량 처리 {{rejected}}개',
+    yieldRate: '수율: {{rate}}%',
     lastWeekRate: '지난 주: {{rate}}%',
     resetLayout: '레이아웃 초기화',
     recalculateCosts: '비용 재계산',
@@ -2197,6 +2211,12 @@ export default {
     defaultLayerInspectDesc: '첫 번째 층 AI 검사',
     defaultTimelapse: '타임랩스',
     defaultTimelapseDesc: '타임랩스 영상 녹화',
+    defaultConfirmOutcome: '결과 확인 요청',
+    defaultConfirmOutcomeDesc: '인쇄 완료 후 결과가 잘 나왔는지 물어봅니다',
+    confirmOutcomeExternalPrints: 'Bambuddy 외부에서 시작한 인쇄도 확인',
+    confirmOutcomeExternalPrintsDesc: '프린터, Bambu Studio 또는 Handy 앱에서 시작한 인쇄도 Bambuddy에 보관됩니다. 이 옵션을 켜면 대기열 인쇄와 동일한 결과 확인을 받습니다.',
+    confirmDefaultGoodOnPlateClear: '플레이트 비움 시 미응답 결과를 양품으로 기록',
+    confirmDefaultGoodOnPlateClearDescription: '플레이트 비움이 확인될 때(수동 또는 다음 대기열 인쇄에 의해) 해당 인쇄의 결과 확인이 아직 응답되지 않았다면 자동으로 양품으로 기록합니다. 플레이트를 비우는 순간 해당 인쇄는 양품으로 처리되며, 그 뒤에 텔레그램이나 링크로 답해도 이미 기록된 결과만 표시됩니다.',
     defaultNozzleOffsetCali: '노즐 오프셋 보정',
     defaultNozzleOffsetCaliDesc: '익스트루더 간 노즐 오프셋 보정',
     tempFanPresetsTitle: '온도 및 팬 프리셋',
@@ -4897,6 +4917,8 @@ export default {
     insufficientBudget: '예산 부족',
     unlimitedNoBudget: '무제한 – 예산 한도가 설정되지 않았습니다.',
     noPrintableCostCenters: '인쇄에 사용할 수 있는 활성 비용 센터가 없습니다. 관리자에게 인쇄 권한을 요청하세요.',
+    askForOutcome: '결과 묻기',
+    askForOutcomeTitle: '완료 후 출력 결과가 어땠는지 물어보기',
   },
   backup: {
     includesEncryptionKey: '로컬 백업에는 MFA 암호화 키 파일(DATA_DIR/.mfa_encryption_key)이 포함되어 백업 ZIP이 자체 완결됩니다. ZIP 파일을 민감하게 취급하세요 — 파일을 가진 누구나 내부에 저장된 OIDC 클라이언트 비밀과 TOTP 비밀을 복호화할 수 있습니다.',
@@ -5196,6 +5218,31 @@ export default {
     upload: '업로드',
     uploadFailed: '업로드 실패'
   },
+  confirmOutcome: {
+    title: '인쇄 결과가 어땠나요?',
+    good: '양품',
+    reject: '불량',
+    later: '나중에 묻기',
+    noPhoto: '완료 사진 없음',
+    savedGood: '양품으로 표시됨',
+    savedReject: '불량으로 표시됨',
+    saveFailed: '판정을 저장할 수 없습니다',
+    rejectReason: '불량 사유 (선택 사항)',
+    noReason: '사유 없음',
+    saveReject: '저장',
+    rejectAndReprint: '다시 인쇄',
+    alreadyGood: '이미 양품으로 확인되었습니다.',
+    alreadyRejected: '이미 불량으로 표시되었습니다.',
+    cardPrompt: '결과가 어땠나요?',
+    sources: {
+      dialog: '앱에서 기록되었습니다.',
+      link: '원탭 링크로 기록되었습니다.',
+      plateClear: '플레이트를 비울 때 기록되었습니다.',
+      printerCard: '프린터 카드에서 기록되었습니다.',
+      api: 'API를 통해 기록되었습니다.',
+      reaction: '채팅 반응으로 기록되었습니다.',
+    },
+  },
   editArchive: {
     title: '아카이브 편집',
     name: '이름',
@@ -5221,6 +5268,12 @@ export default {
     existingTags: '기존 태그',
     clickToAdd: '(클릭하여 추가)',
     status: '상태',
+    userVerdict: '결과 판정',
+    verdicts: {
+      none: '설정 안 됨',
+      good: '양품',
+      reject: '불량',
+    },
     failureReason: '실패 원인',
     selectReason: '원인 선택...',
     photos: '인쇄 결과 사진',
@@ -5905,6 +5958,8 @@ export default {
     plateNotEmptyDescription: '인쇄 전 개체 감지됨',
     plateClearRequired: '플레이트 비움 확인 필요',
     plateClearRequiredDescription: '인쇄 완료, 대기열이 플레이트 확인을 기다림',
+    printConfirmRequest: '결과 확인',
+    printConfirmRequestDescription: '참여 설정된 인쇄가 완료되면 원탭 링크로 양품/불량 판정을 요청합니다',
     plateClear: '플레이트 확인',
     printCompleted: '인쇄 완료됨',
     bedCooledLabel: '베드 냉각됨',

+ 56 - 0
frontend/src/i18n/locales/nl.ts

@@ -955,6 +955,8 @@ export default {
     showFailedPrints: 'Mislukte afdrukken tonen',
     hideFailedPrints: 'Mislukte afdrukken verbergen',
     hideDuplicates: 'Duplicaten verbergen',
+    filterUnconfirmed: 'Onbevestigd',
+    filterUnconfirmedTitle: 'Alleen afdrukken tonen die nog op hun resultaatoordeel wachten',
     viewOriginalPrint: 'Klik om originele afdruk te bekijken (#{{id}})',
     printTime: 'Afdruktijd',
     filamentUsed: 'Filament gebruikt',
@@ -1009,6 +1011,7 @@ export default {
       slice: 'Slicen',
       externalLink: 'Externe link',
       viewOnMakerWorld: 'Bekijken op MakerWorld',
+      confirmOutcome: 'Resultaat bevestigen',
       preview3d: '3D-voorbeeld',
       viewTimelapse: 'Timelapse bekijken',
       scanForTimelapse: 'Zoeken naar timelapse',
@@ -1070,6 +1073,12 @@ export default {
       removeFromFavorites: 'Uit favorieten verwijderen',
       cancelled: 'geannuleerd',
       failed: 'mislukt',
+      confirmPending: 'resultaat?',
+      confirmPendingTitle: 'Deze afdruk vroeg om een resultaatoordeel — klik om te antwoorden',
+      rejected: 'afgekeurd',
+      rejectedTitle: 'Voltooid op de printer, door de gebruiker als afkeur gemarkeerd',
+      confirmedGood: 'goedgekeurd',
+      confirmedGoodTitle: 'Door de gebruiker bevestigd als een goed onderdeel',
       duplicate: 'duplicaat',
       duplicateTitle: 'Dit model is eerder afgedrukt',
       openSource3mf: 'Bron-3MF openen in Bambu Studio (klik met rechts voor meer opties)',
@@ -1500,6 +1509,8 @@ export default {
       requiresPrevious: 'Vereist dat vorige taak slaagt',
       autoPowerOff: 'Automatisch uitschakelen',
       gcodeInjection: 'G-code',
+      confirmOutcome: 'Resultaatvraag',
+      confirmOutcomeTitle: 'Vraagt na afloop hoe de print is geworden',
     },
     // Empty state
     empty: {
@@ -1541,6 +1552,7 @@ export default {
       layerInspection: 'Inspectie eerste laag',
       timelapse: 'Timelapse',
       useAms: 'AMS gebruiken',
+      confirmOutcome: 'Vragen naar resultaat',
       nozzleOffsetCali: 'Nozzle-offsetkalibratie',
       applyChanges: 'Wijzigingen toepassen',
       selectAll: 'Alles selecteren',
@@ -1686,6 +1698,8 @@ export default {
     failureReasons: 'Oorzaken van fouten',
     topFailureReasons: 'Meest voorkomende foutoorzaken',
     failedPrintsCount: '{{failed}} / {{total}} afdrukken mislukt',
+    rejectedPrintsCount: '{{rejected}} afgekeurd na voltooiing',
+    yieldRate: 'Opbrengst: {{rate}}%',
     lastWeekRate: 'Vorige week: {{rate}}%',
     // Actions
     resetLayout: 'Indeling resetten',
@@ -2337,6 +2351,12 @@ export default {
     defaultLayerInspectDesc: 'AI-inspectie van de eerste laag',
     defaultTimelapse: 'Timelapse',
     defaultTimelapseDesc: 'Timelapsevideo opnemen',
+    defaultConfirmOutcome: 'Vragen om resultaat',
+    defaultConfirmOutcomeDesc: 'Vragen of de afdruk goed is gelukt nadat deze is voltooid',
+    confirmOutcomeExternalPrints: 'Ook vragen bij prints die buiten Bambuddy zijn gestart',
+    confirmOutcomeExternalPrintsDesc: 'Prints die op de printer, in Bambu Studio of in de Handy-app zijn gestart, worden ook door Bambuddy gearchiveerd. Met deze optie krijgen ze dezelfde resultaatvraag als prints uit de wachtrij.',
+    confirmDefaultGoodOnPlateClear: 'Onbeantwoorde resultaten als goed tellen bij plaatvrijgave',
+    confirmDefaultGoodOnPlateClearDescription: 'Wanneer de plaat wordt vrijgegeven (handmatig of door de volgende afdruk in de wachtrij) en de resultaatvraag van de afdruk nog onbeantwoord is, wordt deze automatisch als goed onderdeel geregistreerd. De afdruk geldt dan als goed zodra de plaat wordt vrijgegeven; een antwoord via Telegram of link daarna toont alleen nog het opgeslagen resultaat.',
     defaultNozzleOffsetCali: 'Nozzle-offsetkalibratie',
     defaultNozzleOffsetCaliDesc: 'Nozzle-offsets tussen extruders kalibreren',
     tempFanPresetsTitle: 'Voorinstellingen temperatuur en ventilator',
@@ -5172,6 +5192,8 @@ export default {
     insufficientBudget: 'Onvoldoende budget',
     unlimitedNoBudget: 'Onbeperkt – er is geen budgetlimiet ingesteld.',
     noPrintableCostCenters: 'Er is geen actieve kostenplaats beschikbaar voor afdrukken. Vraag een beheerder om je afdruktoegang te geven.',
+    askForOutcome: 'Vragen naar resultaat',
+    askForOutcomeTitle: 'Na afloop vragen hoe de print is geworden',
   },
 
   // Backup
@@ -5495,6 +5517,32 @@ export default {
     uploadFailed: 'Upload mislukt',
   },
 
+  confirmOutcome: {
+    title: 'Hoe is je afdruk gelukt?',
+    good: 'Goed',
+    reject: 'Afkeuren',
+    later: 'Vraag het me later',
+    noPhoto: 'Geen eindfoto beschikbaar',
+    savedGood: 'Gemarkeerd als goed onderdeel',
+    savedReject: 'Gemarkeerd als afgekeurd',
+    saveFailed: 'Kon het oordeel niet opslaan',
+    rejectReason: 'Reden van afkeuring (optioneel)',
+    noReason: 'Geen reden',
+    saveReject: 'Opslaan',
+    rejectAndReprint: 'Opnieuw afdrukken',
+    alreadyGood: 'Al bevestigd als goed onderdeel.',
+    alreadyRejected: 'Al gemarkeerd als afgekeurd.',
+    cardPrompt: 'Hoe is het gelukt?',
+    sources: {
+      dialog: 'Vastgelegd in de app.',
+      link: 'Vastgelegd via een link met één tik.',
+      plateClear: 'Vastgelegd bij het vrijgeven van de plaat.',
+      printerCard: 'Vastgelegd via de printerkaart.',
+      api: 'Vastgelegd via de API.',
+      reaction: 'Vastgelegd via een reactie in de chat.',
+    },
+  },
+
   // Edit archive modal
   // Edit Archive Modal
   editArchive: {
@@ -5522,6 +5570,12 @@ export default {
     existingTags: 'Bestaande tags',
     clickToAdd: '(klik om toe te voegen)',
     status: 'Status',
+    userVerdict: 'Resultaatoordeel',
+    verdicts: {
+      none: 'Niet ingesteld',
+      good: 'Goed onderdeel',
+      reject: 'Afgekeurd',
+    },
     failureReason: 'Reden van mislukking',
     selectReason: 'Selecteer reden...',
     photos: 'Foto\'s van het afdrukresultaat',
@@ -6237,6 +6291,8 @@ export default {
     plateNotEmptyDescription: 'Objecten gedetecteerd vóór afdrukken',
     plateClearRequired: 'Bevestiging lege plaat vereist',
     plateClearRequiredDescription: 'Afdruk voltooid, wachtrij wacht op bevestiging van de plaat',
+    printConfirmRequest: 'Resultaatbevestiging',
+    printConfirmRequestDescription: 'Vragen om een goed/afkeur-oordeel via links met één tik wanneer een aangemelde afdruk is voltooid',
     plateClear: 'Plaat leeg',
     printCompleted: 'Afdruk voltooid',
     bedCooledLabel: 'Bed afgekoeld',

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

@@ -950,6 +950,8 @@ export default {
     showFailedPrints: 'Mostrar impressões falhas',
     hideFailedPrints: 'Ocultar impressões falhas',
     hideDuplicates: 'Ocultar duplicados',
+    filterUnconfirmed: 'Não confirmadas',
+    filterUnconfirmedTitle: 'Mostrar apenas impressões que ainda aguardam o veredito de resultado',
     viewOriginalPrint: 'Clique para visualizar a impressão original (#{{id}})',
     printTime: 'Tempo de impressão',
     filamentUsed: 'Filamento usado',
@@ -1004,6 +1006,7 @@ export default {
       slice: 'Fatiar',
       externalLink: 'Link externo',
       viewOnMakerWorld: 'Ver no MakerWorld',
+      confirmOutcome: 'Confirmar Resultado',
       preview3d: 'Pré-visualização 3D',
       viewTimelapse: 'Ver Timelapse',
       scanForTimelapse: 'Escanear Timelapse',
@@ -1065,6 +1068,12 @@ export default {
       removeFromFavorites: 'Remover dos favoritos',
       cancelled: 'cancelado',
       failed: 'falha',
+      confirmPending: 'resultado?',
+      confirmPendingTitle: 'Esta impressão pediu um veredito de resultado — clique para responder',
+      rejected: 'rejeitado',
+      rejectedTitle: 'Concluída na impressora, marcada como rejeitada pelo usuário',
+      confirmedGood: 'boa',
+      confirmedGoodTitle: 'Confirmada como uma peça boa pelo usuário',
       duplicate: 'duplicado',
       duplicateTitle: 'Este modelo já foi impresso antes',
       openSource3mf: 'Abrir source 3MF no Bambu Studio (clique com o botão direito para mais opções)',
@@ -1484,6 +1493,8 @@ export default {
       requiresPrevious: 'Requer sucesso anterior',
       autoPowerOff: 'Desligamento automático',
       gcodeInjection: 'G-code',
+      confirmOutcome: 'Consulta de resultado',
+      confirmOutcomeTitle: 'Pergunta como a impressão ficou depois de concluída',
     },
     // Empty state
     empty: {
@@ -1525,6 +1536,7 @@ export default {
       layerInspection: 'Inspeção da Primeira Camada',
       timelapse: 'Timelapse',
       useAms: 'Usar AMS',
+      confirmOutcome: 'Perguntar o resultado',
       nozzleOffsetCali: 'Calibração de offset dos bicos',
       applyChanges: 'Aplicar Alterações',
       selectAll: 'Selecionar Todos',
@@ -1669,6 +1681,8 @@ export default {
     failureReasons: 'Razões de Falha',
     topFailureReasons: 'Principais Razões de Falha',
     failedPrintsCount: '{{failed}} / {{total}} impressões falharam',
+    rejectedPrintsCount: '{{rejected}} rejeitadas após a conclusão',
+    yieldRate: 'Rendimento: {{rate}}%',
     lastWeekRate: 'Última semana: {{rate}}%',
     // Actions
     resetLayout: 'Redefinir Layout',
@@ -2273,6 +2287,12 @@ export default {
     defaultLayerInspectDesc: 'Inspeção IA da primeira camada',
     defaultTimelapse: 'Timelapse',
     defaultTimelapseDesc: 'Gravar vídeo timelapse',
+    defaultConfirmOutcome: 'Perguntar o Resultado',
+    defaultConfirmOutcomeDesc: 'Perguntar se a impressão saiu bem após a conclusão',
+    confirmOutcomeExternalPrints: 'Perguntar também em impressões iniciadas fora do Bambuddy',
+    confirmOutcomeExternalPrintsDesc: 'Impressões iniciadas na impressora, no Bambu Studio ou no app Handy também são arquivadas pelo Bambuddy. Com esta opção, elas recebem a mesma pergunta de resultado das impressões da fila.',
+    confirmDefaultGoodOnPlateClear: 'Contar resultados sem resposta como bons ao liberar a placa',
+    confirmDefaultGoodOnPlateClearDescription: 'Quando a placa é liberada (manualmente ou pela próxima impressão na fila) e a pergunta sobre o resultado da impressão ainda não foi respondida, registrá-la automaticamente como peça boa. A impressão passa a contar como boa assim que a placa é liberada; uma resposta posterior por Telegram ou por link apenas mostra o resultado registrado.',
     defaultNozzleOffsetCali: 'Calibração de offset dos bicos',
     defaultNozzleOffsetCaliDesc: 'Calibrar offsets entre extrusores',
     tempFanPresetsTitle: 'Predefinições de temperatura e ventilador',
@@ -5115,6 +5135,8 @@ export default {
     insufficientBudget: 'Orçamento insuficiente',
     unlimitedNoBudget: 'Ilimitado – nenhum limite de orçamento foi definido.',
     noPrintableCostCenters: 'Não há nenhum centro de custo ativo disponível para impressão. Peça a um administrador para conceder acesso de impressão.',
+    askForOutcome: 'Perguntar o resultado',
+    askForOutcomeTitle: 'Perguntar como a impressão ficou depois de concluída',
   },
 
   // Backup
@@ -5433,6 +5455,32 @@ export default {
     uploadFailed: 'Falha no envio',
   },
 
+  confirmOutcome: {
+    title: 'Como ficou sua impressão?',
+    good: 'Boa',
+    reject: 'Rejeitar',
+    later: 'Perguntar depois',
+    noPhoto: 'Nenhuma foto do resultado disponível',
+    savedGood: 'Marcada como peça boa',
+    savedReject: 'Marcada como rejeitada',
+    saveFailed: 'Não foi possível salvar o veredito',
+    rejectReason: 'Motivo da rejeição (opcional)',
+    noReason: 'Sem motivo',
+    saveReject: 'Salvar',
+    rejectAndReprint: 'Imprimir novamente',
+    alreadyGood: 'Já confirmada como peça boa.',
+    alreadyRejected: 'Já marcada como rejeitada.',
+    cardPrompt: 'Como ficou a impressão?',
+    sources: {
+      dialog: 'Registrado no aplicativo.',
+      link: 'Registrado com um link de um toque.',
+      plateClear: 'Registrado ao liberar a placa.',
+      printerCard: 'Registrado no cartão da impressora.',
+      api: 'Registrado pela API.',
+      reaction: 'Registrado com uma reação no chat.',
+    },
+  },
+
   // Edit archive modal
   // Edit Archive Modal
   editArchive: {
@@ -5460,6 +5508,12 @@ export default {
     existingTags: 'Tags existentes',
     clickToAdd: '(clique para adicionar)',
     status: 'Status',
+    userVerdict: 'Veredito de Resultado',
+    verdicts: {
+      none: 'Não definido',
+      good: 'Peça boa',
+      reject: 'Rejeitada',
+    },
     failureReason: 'Motivo da Falha',
     selectReason: 'Selecione o motivo...',
     photos: 'Fotos do Resultado da Impressão',
@@ -6175,6 +6229,8 @@ export default {
     plateNotEmptyDescription: 'Objetos detectados antes da impressão',
     plateClearRequired: 'Confirmar mesa livre',
     plateClearRequiredDescription: 'Impressão concluída, a fila aguarda confirmação',
+    printConfirmRequest: 'Confirmação de Resultado',
+    printConfirmRequestDescription: 'Pedir um veredito de boa/rejeitada com links de um toque quando uma impressão com a opção ativada for concluída',
     plateClear: 'Mesa livre',
     printCompleted: 'Impressão Concluída',
     bedCooledLabel: 'Mesa Resfriada',

+ 55 - 0
frontend/src/i18n/locales/ru.ts

@@ -905,6 +905,8 @@ export default {
     showFailedPrints: "Показывать неудачные печати",
     hideFailedPrints: "Скрывать неудачные печати",
     hideDuplicates: "Скрывать дубликаты",
+    filterUnconfirmed: "Неподтверждённые",
+    filterUnconfirmedTitle: "Показывать только печати, ожидающие вердикта о результате",
     viewOriginalPrint: "Открыть исходную печать №{{id}}",
     printTime: "Время печати",
     filamentUsed: "Израсходовано филамента",
@@ -959,6 +961,7 @@ export default {
       slice: "Нарезать",
       externalLink: "Внешняя ссылка",
       viewOnMakerWorld: "Открыть на MakerWorld",
+      confirmOutcome: "Подтвердить результат",
       preview3d: "3D-просмотр",
       viewTimelapse: "Посмотреть таймлапс",
       scanForTimelapse: "Найти таймлапс",
@@ -1020,6 +1023,12 @@ export default {
       removeFromFavorites: "Удалить из избранного",
       cancelled: "отменено",
       failed: "ошибка",
+      confirmPending: "результат?",
+      confirmPendingTitle: "Для этой печати запрошен вердикт о результате — нажмите, чтобы ответить",
+      rejected: "брак",
+      rejectedTitle: "Печать завершена на принтере, но помечена пользователем как брак",
+      confirmedGood: "годная",
+      confirmedGoodTitle: "Подтверждено пользователем как годная деталь",
       duplicate: "дубликат",
       duplicateTitle: "Эта модель уже печаталась",
       openSource3mf: "Открыть исходный 3MF в Bambu Studio (щёлкните правой кнопкой для дополнительных действий)",
@@ -1425,6 +1434,8 @@ export default {
       requiresPrevious: "Требуется успешное предыдущее задание",
       autoPowerOff: "Автоотключение питания",
       gcodeInjection: "G-code",
+      confirmOutcome: 'Запрос результата',
+      confirmOutcomeTitle: 'После завершения спросит, как получилась печать',
     },
     empty: {
       title: "Нет запланированных заданий",
@@ -1462,6 +1473,7 @@ export default {
       layerInspection: "Проверка первого слоя",
       timelapse: "Таймлапс",
       useAms: "Использовать AMS",
+      confirmOutcome: 'Спросить о результате',
       nozzleOffsetCali: "Калибровка смещения сопел",
       applyChanges: "Применить изменения",
       selectAll: "Выбрать все",
@@ -1599,6 +1611,8 @@ export default {
     failureReasons: "Причины неудач",
     topFailureReasons: "Основные причины неудач",
     failedPrintsCount: "Неудачных печатей: {{failed}} из {{total}}",
+    rejectedPrintsCount: "Забраковано после завершения: {{rejected}}",
+    yieldRate: "Выход годных: {{rate}}%",
     lastWeekRate: "За прошлую неделю: {{rate}}%",
     resetLayout: "Сбросить расположение",
     recalculateCosts: "Пересчитать стоимость",
@@ -2197,6 +2211,12 @@ export default {
     defaultLayerInspectDesc: "Проверка первого слоя системой компьютерного зрения",
     defaultTimelapse: "Таймлапс",
     defaultTimelapseDesc: "Записывать таймлапс",
+    defaultConfirmOutcome: "Запрашивать результат",
+    defaultConfirmOutcomeDesc: "Спрашивать после завершения печати, удалась ли она",
+    confirmOutcomeExternalPrints: "Спрашивать и о печати, запущенной вне Bambuddy",
+    confirmOutcomeExternalPrintsDesc: "Печать, запущенная на принтере, в Bambu Studio или в приложении Handy, тоже попадает в архив Bambuddy. С этой опцией по ней задаётся тот же вопрос о результате, что и по заданиям из очереди.",
+    confirmDefaultGoodOnPlateClear: "Считать неотвеченные результаты годными при очистке пластины",
+    confirmDefaultGoodOnPlateClearDescription: "Если пластина очищена (вручную или следующей печатью из очереди), а запрос результата печати остался без ответа, автоматически записывать её как годную деталь. Печать считается годной, как только пластина очищена; ответ через Telegram или по ссылке после этого лишь показывает сохранённый результат.",
     defaultNozzleOffsetCali: "Калибровка смещения сопел",
     defaultNozzleOffsetCaliDesc: "Калибровать взаимное смещение сопел экструдеров",
     tempFanPresetsTitle: "Пресеты температуры и вентиляторов",
@@ -4886,6 +4906,8 @@ export default {
     insufficientBudget: "Недостаточно бюджета",
     unlimitedNoBudget: "Без ограничений — лимит бюджета не задан.",
     noPrintableCostCenters: "Нет активного центра затрат, доступного для печати. Попросите администратора предоставить вам доступ к печати.",
+    askForOutcome: 'Спросить о результате',
+    askForOutcomeTitle: 'После завершения спросить, как получилась печать',
   },
   backup: {
     includesEncryptionKey: "Локальные резервные копии включают файл ключа шифрования MFA (DATA_DIR/.mfa_encryption_key), поэтому ZIP-архив является самодостаточным. Считайте этот ZIP конфиденциальным: любой, у кого есть файл, сможет расшифровать сохранённые в нём секреты клиента OIDC и TOTP.",
@@ -5184,6 +5206,31 @@ export default {
     upload: "Загрузить",
     uploadFailed: "Не удалось загрузить",
   },
+  confirmOutcome: {
+    title: "Как получилась печать?",
+    good: "Хорошо",
+    reject: "Брак",
+    later: "Спросить позже",
+    noPhoto: "Фото готовой печати недоступно",
+    savedGood: "Отмечено как годная деталь",
+    savedReject: "Отмечено как брак",
+    saveFailed: "Не удалось сохранить вердикт",
+    rejectReason: "Причина брака (необязательно)",
+    noReason: "Без причины",
+    saveReject: "Сохранить",
+    rejectAndReprint: "Напечатать снова",
+    alreadyGood: "Уже подтверждено как годная деталь.",
+    alreadyRejected: "Уже отмечено как брак.",
+    cardPrompt: "Как получилась печать?",
+    sources: {
+      dialog: "Записано в приложении.",
+      link: "Записано по ссылке в одно нажатие.",
+      plateClear: "Записано при очистке пластины.",
+      printerCard: "Записано с карточки принтера.",
+      api: "Записано через API.",
+      reaction: "Записано реакцией в чате.",
+    },
+  },
   editArchive: {
     title: "Редактирование архива",
     name: "Название",
@@ -5209,6 +5256,12 @@ export default {
     existingTags: "Существующие метки",
     clickToAdd: "(нажмите, чтобы добавить)",
     status: "Статус",
+    userVerdict: "Вердикт о результате",
+    verdicts: {
+      none: "Не задан",
+      good: "Годная деталь",
+      reject: "Брак",
+    },
     failureReason: "Причина сбоя",
     selectReason: "Выберите причину...",
     photos: "Фотографии результата печати",
@@ -5892,6 +5945,8 @@ export default {
     plateNotEmptyDescription: "Перед печатью на пластине обнаружены объекты",
     plateClearRequired: "Требуется подтверждение стола",
     plateClearRequiredDescription: "Печать завершена, очередь ждет подтверждения",
+    printConfirmRequest: "Подтверждение результата",
+    printConfirmRequestDescription: "Запрашивать вердикт «годная/брак» со ссылками в одно нажатие, когда завершается печать с включённым запросом результата",
     plateClear: "Стол свободен",
     printCompleted: "Печать завершена",
     bedCooledLabel: "Стол остыл",

+ 56 - 0
frontend/src/i18n/locales/sv.ts

@@ -955,6 +955,8 @@ export default {
     showFailedPrints: 'Visa misslyckade utskrifter',
     hideFailedPrints: 'Dölj misslyckade utskrifter',
     hideDuplicates: 'Dölj dubbletter',
+    filterUnconfirmed: 'Obekräftade',
+    filterUnconfirmedTitle: 'Visa endast utskrifter som fortfarande väntar på sin resultatbedömning',
     viewOriginalPrint: 'Klicka för att visa originalutskrift (#{{id}})',
     printTime: 'Utskriftstid',
     filamentUsed: 'Filament använt',
@@ -1009,6 +1011,7 @@ export default {
       slice: 'Slica',
       externalLink: 'Extern länk',
       viewOnMakerWorld: 'Visa på MakerWorld',
+      confirmOutcome: 'Bekräfta resultat',
       preview3d: '3D-förhandsgranskning',
       viewTimelapse: 'Visa timelapse',
       scanForTimelapse: 'Sök efter timelapse',
@@ -1070,6 +1073,12 @@ export default {
       removeFromFavorites: 'Ta bort från favoriter',
       cancelled: 'avbruten',
       failed: 'misslyckad',
+      confirmPending: 'resultat?',
+      confirmPendingTitle: 'Denna utskrift bad om en resultatbedömning — klicka för att svara',
+      rejected: 'kasserad',
+      rejectedTitle: 'Slutförd på skrivaren, markerad som kasserad av användaren',
+      confirmedGood: 'godkänd',
+      confirmedGoodTitle: 'Bekräftad som godkänd del av användaren',
       duplicate: 'kopia',
       duplicateTitle: 'Denna modell har skrivits ut tidigare',
       openSource3mf: 'Öppna 3MF-källfil i Bambu Studio (högerklicka för fler alternativ)',
@@ -1500,6 +1509,8 @@ export default {
       requiresPrevious: 'Kräver tidigare framgång',
       autoPowerOff: 'Auto-avstängning',
       gcodeInjection: 'G-kod',
+      confirmOutcome: 'Resultatfråga',
+      confirmOutcomeTitle: 'Frågar hur utskriften blev när den är klar',
     },
     // Empty state
     empty: {
@@ -1541,6 +1552,7 @@ export default {
       layerInspection: 'Förstaskiktsinspektion',
       timelapse: 'Timelapse',
       useAms: 'Använd AMS',
+      confirmOutcome: 'Fråga efter resultat',
       nozzleOffsetCali: 'Munstyckesoffsetskalibrering',
       applyChanges: 'Tillämpa ändringar',
       selectAll: 'Markera alla',
@@ -1686,6 +1698,8 @@ export default {
     failureReasons: 'Orsaker till fel',
     topFailureReasons: 'Vanligaste orsaker till fel',
     failedPrintsCount: '{{failed}} / {{total}} utskrifter misslyckades',
+    rejectedPrintsCount: '{{rejected}} kasserade efter slutförande',
+    yieldRate: 'Utbyte: {{rate}}%',
     lastWeekRate: 'Förra veckan: {{rate}}%',
     // Actions
     resetLayout: 'Återställ layout',
@@ -2337,6 +2351,12 @@ export default {
     defaultLayerInspectDesc: 'AI-inspektion av första skiktet',
     defaultTimelapse: 'Timelapse',
     defaultTimelapseDesc: 'Spela in timelapse-video',
+    defaultConfirmOutcome: 'Fråga efter resultat',
+    defaultConfirmOutcomeDesc: 'Fråga om utskriften blev bra när den är klar',
+    confirmOutcomeExternalPrints: 'Fråga även för utskrifter som startats utanför Bambuddy',
+    confirmOutcomeExternalPrintsDesc: 'Utskrifter som startats på skrivaren, i Bambu Studio eller i Handy-appen arkiveras också av Bambuddy. Med detta påslaget får de samma resultatfråga som utskrifter från kön.',
+    confirmDefaultGoodOnPlateClear: 'Räkna obesvarade resultat som godkända när plattan rensas',
+    confirmDefaultGoodOnPlateClearDescription: 'När plattan rensas (manuellt eller av nästa utskrift i kön) och utskriftens resultatfråga fortfarande är obesvarad, registreras utskriften automatiskt som en godkänd del. Utskriften räknas då som godkänd så snart plattan rensas; ett svar via Telegram eller länk därefter visar bara det registrerade resultatet.',
     defaultNozzleOffsetCali: 'Munstyckesoffsetkalibrering',
     defaultNozzleOffsetCaliDesc: 'Kalibrera munstyckesoffsetar mellan extruderare',
     tempFanPresetsTitle: 'Temperatur- och fläktförinställningar',
@@ -5172,6 +5192,8 @@ errors: {
     insufficientBudget: 'Otillräcklig budget',
     unlimitedNoBudget: 'Obgränsad – ingen budgetgräns är inställd.',
     noPrintableCostCenters: 'Inget aktivt kostnadsställe är tillgängligt för utskrift. Be en administratör att ge dig utskriftsåtkomst.',
+    askForOutcome: 'Fråga efter resultat',
+    askForOutcomeTitle: 'Fråga hur utskriften blev när den är klar',
   },
 
   // Backup
@@ -5495,6 +5517,32 @@ errors: {
     uploadFailed: 'Kunde inte ladda upp',
   },
 
+  // Post-print outcome confirmation dialog (#1898)
+  confirmOutcome: {
+    title: 'Hur blev din utskrift?',
+    good: 'Godkänd',
+    reject: 'Kassera',
+    later: 'Fråga mig senare',
+    noPhoto: 'Inget slutfoto tillgängligt',
+    savedGood: 'Markerad som godkänd del',
+    savedReject: 'Markerad som kasserad',
+    saveFailed: 'Kunde inte spara bedömningen',
+    rejectReason: 'Orsak till kassering (valfritt)',
+    noReason: 'Ingen orsak',
+    saveReject: 'Spara',
+    rejectAndReprint: 'Skriv ut igen',
+    alreadyGood: 'Redan bekräftad som godkänd del.',
+    alreadyRejected: 'Redan markerad som kasserad.',
+    cardPrompt: 'Hur blev den?',
+    sources: {
+      dialog: 'Registrerad i appen.',
+      link: 'Registrerad via en snabblänk.',
+      plateClear: 'Registrerad när plattan rensades.',
+      printerCard: 'Registrerad från skrivarkortet.',
+      api: 'Registrerad via API:et.',
+      reaction: 'Registrerad med en reaktion i chatten.',
+    },
+  },
   // Edit archive modal
   // Edit Archive Modal
   editArchive: {
@@ -5522,6 +5570,12 @@ errors: {
     existingTags: 'Befintliga taggar',
     clickToAdd: '(klicka för att lägga till)',
     status: 'Status',
+    userVerdict: 'Resultatbedömning',
+    verdicts: {
+      none: 'Inte angiven',
+      good: 'Godkänd del',
+      reject: 'Kasserad',
+    },
     failureReason: 'Orsak till fel',
     selectReason: 'Välj orsak...',
     photos: 'Bilder av utskriftsresultat',
@@ -6237,6 +6291,8 @@ errors: {
     plateNotEmptyDescription: 'Objekt upptäckta före utskrift',
     plateClearRequired: 'Platt-rensning krävs',
     plateClearRequiredDescription: 'Utskrift slutförd, kön väntar på plattbekräftelse',
+    printConfirmRequest: 'Resultatbekräftelse',
+    printConfirmRequestDescription: 'Begär en godkänd/kasserad-bedömning med snabblänkar när en utskrift med resultatfråga blir klar',
     plateClear: 'Platta rensad',
     printCompleted: 'Utskrift slutförd',
     bedCooledLabel: 'Byggplatta kyld',

+ 56 - 0
frontend/src/i18n/locales/tr.ts

@@ -950,6 +950,8 @@ export default {
     showFailedPrints: 'Başarısız baskıları göster',
     hideFailedPrints: 'Başarısız baskıları gizle',
     hideDuplicates: 'Tekrarları Gizle',
+    filterUnconfirmed: 'Onaylanmamış',
+    filterUnconfirmedTitle: 'Yalnızca sonuç kararı bekleyen baskıları göster',
     viewOriginalPrint: 'Orijinal baskıyı görüntülemek için tıklayın (#{{id}})',
     printTime: 'Baskı Süresi',
     filamentUsed: 'Kullanılan Filament',
@@ -1004,6 +1006,7 @@ export default {
       slice: 'Dilimle',
       externalLink: 'Harici Bağlantı',
       viewOnMakerWorld: "MakerWorld'de Görüntüle",
+      confirmOutcome: 'Sonucu Onayla',
       preview3d: '3B Önizleme',
       viewTimelapse: 'Zaman Atlamalı Videoyu Görüntüle',
       scanForTimelapse: 'Zaman Atlamalı Video Tara',
@@ -1065,6 +1068,12 @@ export default {
       removeFromFavorites: 'Favorilerden kaldır',
       cancelled: 'iptal edildi',
       failed: 'başarısız',
+      confirmPending: 'sonuç?',
+      confirmPendingTitle: 'Bu baskı için sonuç kararı istendi — yanıtlamak için tıklayın',
+      rejected: 'reddedildi',
+      rejectedTitle: 'Yazıcıda tamamlandı, kullanıcı tarafından ret olarak işaretlendi',
+      confirmedGood: 'sağlam',
+      confirmedGoodTitle: 'Kullanıcı tarafından sağlam parça olarak onaylandı',
       duplicate: 'tekrar',
       duplicateTitle: 'Bu model daha önce yazdırıldı',
       openSource3mf: "Kaynak 3MF'yi Bambu Studio'da aç (daha fazla seçenek için sağ tıklayın)",
@@ -1484,6 +1493,8 @@ export default {
       requiresPrevious: 'Önceki başarı gerekli',
       autoPowerOff: 'Otomatik kapanma',
       gcodeInjection: 'G-kod',
+      confirmOutcome: 'Sonuç sorgusu',
+      confirmOutcomeTitle: 'Tamamlandığında baskının nasıl çıktığını sorar',
     },
     // Boş durum
     empty: {
@@ -1525,6 +1536,7 @@ export default {
       layerInspection: 'İlk katman denetimi',
       timelapse: 'Zaman atlamalı video',
       useAms: "AMS Kullan",
+      confirmOutcome: 'Sonucu sor',
       nozzleOffsetCali: 'Nozül ofset kalibrasyonu',
       applyChanges: 'Değişiklikleri Uygula',
       selectAll: 'Tümünü Seç',
@@ -1670,6 +1682,8 @@ export default {
     failureReasons: 'Başarısızlık Nedenleri',
     topFailureReasons: 'En Sık Başarısızlık Nedenleri',
     failedPrintsCount: '{{total}} baskının {{failed}} tanesi başarısız',
+    rejectedPrintsCount: '{{rejected}} tamamlandıktan sonra reddedildi',
+    yieldRate: 'Verim: %{{rate}}',
     lastWeekRate: 'Geçen hafta: %{{rate}}',
     // İşlemler
     resetLayout: 'Düzeni Sıfırla',
@@ -2321,6 +2335,12 @@ export default {
     defaultLayerInspectDesc: 'İlk katmanın AI denetimi',
     defaultTimelapse: 'Zaman Atlamalı Video',
     defaultTimelapseDesc: 'Zaman atlamalı video kaydet',
+    defaultConfirmOutcome: 'Sonucu Sor',
+    defaultConfirmOutcomeDesc: 'Baskı tamamlandıktan sonra iyi çıkıp çıkmadığını sor',
+    confirmOutcomeExternalPrints: 'Bambuddy dışında başlatılan baskılar için de sor',
+    confirmOutcomeExternalPrintsDesc: 'Yazıcıda, Bambu Studio\'da veya Handy uygulamasında başlatılan baskılar da Bambuddy tarafından arşivlenir. Bu seçenek açıkken, kuyruktaki baskılarla aynı sonuç sorusunu alırlar.',
+    confirmDefaultGoodOnPlateClear: 'Plaka temizlendiğinde yanıtsız sonuçları iyi olarak say',
+    confirmDefaultGoodOnPlateClearDescription: 'Plaka temizlendi olarak onaylandığında (elle veya kuyruktaki bir sonraki baskıyla) ve baskının sonuç sorusu hâlâ yanıtsızsa, otomatik olarak iyi parça olarak kaydet. Plaka temizlendiği anda baskı iyi sayılır; sonrasında Telegram veya bağlantı üzerinden verilen yanıt yalnızca kayıtlı sonucu gösterir.',
     defaultNozzleOffsetCali: 'Nozül Ofset Kalibrasyonu',
     defaultNozzleOffsetCaliDesc: 'Ekstrüderler arasındaki nozül ofsetlerini kalibre et',
     tempFanPresetsTitle: 'Sıcaklık ve Fan Ön Ayarları',
@@ -5104,6 +5124,8 @@ export default {
     insufficientBudget: 'Yetersiz bütçe',
     unlimitedNoBudget: 'Sınırsız – bütçe limiti belirlenmemiş.',
     noPrintableCostCenters: 'Yazdırma için kullanılabilir etkin bir masraf merkezi yok. Bir yöneticiden yazdırma erişimi vermesini isteyin.',
+    askForOutcome: 'Sonucu sor',
+    askForOutcomeTitle: 'Tamamlandığında baskının nasıl çıktığını sor',
   },
 
   // Yedekleme
@@ -5419,6 +5441,32 @@ export default {
     uploadFailed: 'Yükleme başarısız',
   },
 
+  confirmOutcome: {
+    title: 'Baskınız nasıl çıktı?',
+    good: 'İyi',
+    reject: 'Ret',
+    later: 'Daha sonra sor',
+    noPhoto: 'Bitiş fotoğrafı mevcut değil',
+    savedGood: 'İyi parça olarak işaretlendi',
+    savedReject: 'Reddedildi olarak işaretlendi',
+    saveFailed: 'Karar kaydedilemedi',
+    rejectReason: 'Ret nedeni (isteğe bağlı)',
+    noReason: 'Neden yok',
+    saveReject: 'Kaydet',
+    rejectAndReprint: 'Tekrar yazdır',
+    alreadyGood: 'Zaten iyi parça olarak onaylandı.',
+    alreadyRejected: 'Zaten reddedildi olarak işaretlendi.',
+    cardPrompt: 'Nasıl çıktı?',
+    sources: {
+      dialog: 'Uygulamada kaydedildi.',
+      link: 'Tek dokunuşluk bağlantıyla kaydedildi.',
+      plateClear: 'Plaka temizlendiğinde kaydedildi.',
+      printerCard: 'Yazıcı kartından kaydedildi.',
+      api: 'API üzerinden kaydedildi.',
+      reaction: 'Sohbetteki bir tepkiyle kaydedildi.',
+    },
+  },
+
   // Arşiv düzenleme modali
   editArchive: {
     title: 'Arşivi Düzenle',
@@ -5445,6 +5493,12 @@ export default {
     existingTags: 'Mevcut etiketler',
     clickToAdd: '(eklemek için tıklayın)',
     status: 'Durum',
+    userVerdict: 'Sonuç Kararı',
+    verdicts: {
+      none: 'Ayarlanmadı',
+      good: 'İyi parça',
+      reject: 'Reddedildi',
+    },
     failureReason: 'Başarısızlık Nedeni',
     selectReason: 'Neden seç...',
     photos: 'Yazdırılan Sonuç Fotoğrafları',
@@ -6142,6 +6196,8 @@ export default {
     plateNotEmptyDescription: 'Baskıdan önce nesneler algılandı',
     plateClearRequired: 'Tabla onayı gerekli',
     plateClearRequiredDescription: 'Baskı bitti, kuyruk tabla onayını bekliyor',
+    printConfirmRequest: 'Sonuç Onayı',
+    printConfirmRequestDescription: 'Onay istenen bir baskı tamamlandığında tek dokunuş bağlantılarıyla iyi/ret kararı iste',
     plateClear: 'Tabla onayı',
     printCompleted: 'Baskı Tamamlandı',
     bedCooledLabel: 'Tabla Soğudu',

+ 56 - 0
frontend/src/i18n/locales/uk.ts

@@ -954,6 +954,8 @@ export default {
     showFailedPrints: "Показати невдалі друки",
     hideFailedPrints: "Приховати невдалі друки",
     hideDuplicates: "Приховати дублікати",
+    filterUnconfirmed: "Непідтверджені",
+    filterUnconfirmedTitle: "Показати лише друки, що досі очікують вердикту щодо результату",
     viewOriginalPrint: "Клацніть, щоб переглянути оригінальний друк (#{{id}})",
     printTime: "Час друку",
     filamentUsed: "Використано філаменту",
@@ -1008,6 +1010,7 @@ export default {
       slice: "Нарізати",
       externalLink: "Зовнішнє посилання",
       viewOnMakerWorld: "Переглянути на MakerWorld",
+      confirmOutcome: "Підтвердити результат",
       preview3d: "3D попередній перегляд",
       viewTimelapse: "Переглянути таймлапс",
       scanForTimelapse: "Знайти таймлапс",
@@ -1069,6 +1072,12 @@ export default {
       removeFromFavorites: "Видалити з вибраного",
       cancelled: "скасовано",
       failed: "не вдалося",
+      confirmPending: "результат?",
+      confirmPendingTitle: "Цей друк очікує вердикту щодо результату — натисніть, щоб відповісти",
+      rejected: "відбраковано",
+      rejectedTitle: "Завершено на принтері, позначено користувачем як брак",
+      confirmedGood: "вдалий",
+      confirmedGoodTitle: "Підтверджено користувачем як вдала деталь",
       duplicate: "дублікат",
       duplicateTitle: "Ця модель була надрукована раніше",
       openSource3mf: "Відкрити вихідний файл 3MF у Bambu Studio (клацніть правою кнопкою, щоб переглянути додаткові дії)",
@@ -1499,6 +1508,8 @@ export default {
       requiresPrevious: "Потрібне успішне завершення попереднього завдання",
       autoPowerOff: "Автоматичне вимкнення",
       gcodeInjection: "G-код",
+      confirmOutcome: 'Запит результату',
+      confirmOutcomeTitle: 'Після завершення запитає, як вийшов друк',
     },
     // Empty state
     empty: {
@@ -1540,6 +1551,7 @@ export default {
       layerInspection: "Перевірка першого шару",
       timelapse: "Таймлапс",
       useAms: "Використовувати AMS",
+      confirmOutcome: 'Запитати про результат',
       nozzleOffsetCali: "Калібрування зсуву сопла",
       applyChanges: "Застосувати зміни",
       selectAll: "Вибрати усі",
@@ -1685,6 +1697,8 @@ export default {
     failureReasons: "Причини невдач",
     topFailureReasons: "Основні причини невдач",
     failedPrintsCount: "Невдалих друків: {{failed}} із {{total}}",
+    rejectedPrintsCount: "Відбраковано після завершення: {{rejected}}",
+    yieldRate: "Вихід придатних: {{rate}}%",
     lastWeekRate: "Минулого тижня: {{rate}}%",
     // Actions
     resetLayout: "Скинути макет",
@@ -2336,6 +2350,12 @@ export default {
     defaultLayerInspectDesc: "Перевірка першого шару за допомогою ШІ",
     defaultTimelapse: "Таймлапс",
     defaultTimelapseDesc: "Записувати таймлапс",
+    defaultConfirmOutcome: "Запитувати про результат",
+    defaultConfirmOutcomeDesc: "Запитувати після завершення друку, чи вдалим він вийшов",
+    confirmOutcomeExternalPrints: "Запитувати й про друк, запущений поза Bambuddy",
+    confirmOutcomeExternalPrintsDesc: "Друк, запущений на принтері, у Bambu Studio чи в застосунку Handy, також потрапляє до архіву Bambuddy. З цією опцією щодо нього ставиться те саме запитання про результат, що й для завдань із черги.",
+    confirmDefaultGoodOnPlateClear: "Вважати непідтверджені результати вдалими після очищення пластини",
+    confirmDefaultGoodOnPlateClearDescription: "Якщо пластину очищено (вручну або наступним друком із черги), а запит щодо результату друку залишився без відповіді, автоматично записувати його як придатну деталь. Друк вважається придатним, щойно пластину очищено; відповідь через Telegram або за посиланням після цього лише показує збережений результат.",
     defaultNozzleOffsetCali: "Калібрування зсуву сопла",
     defaultNozzleOffsetCaliDesc: "Калібрувати зміщення сопел між екструдерами",
     tempFanPresetsTitle: "Профілі температури та вентилятора",
@@ -5169,6 +5189,8 @@ export default {
     insufficientBudget: "Недостатньо бюджету",
     unlimitedNoBudget: "Без обмежень — ліміт бюджету не встановлено.",
     noPrintableCostCenters: "Немає активного центру витрат, доступного для друку. Попросіть адміністратора надати вам доступ до друку.",
+    askForOutcome: 'Запитати про результат',
+    askForOutcomeTitle: 'Після завершення запитати, як вийшов друк',
   },
 
   // Backup
@@ -5487,6 +5509,32 @@ export default {
     uploadFailed: "Не вдалося вивантажити",
   },
 
+  confirmOutcome: {
+    title: "Як вийшов ваш друк?",
+    good: "Добре",
+    reject: "Брак",
+    later: "Запитати пізніше",
+    noPhoto: "Фото завершеного друку недоступне",
+    savedGood: "Позначено як придатну деталь",
+    savedReject: "Позначено як брак",
+    saveFailed: "Не вдалося зберегти вердикт",
+    rejectReason: "Причина браку (необов’язково)",
+    noReason: "Без причини",
+    saveReject: "Зберегти",
+    rejectAndReprint: "Друкувати знову",
+    alreadyGood: "Уже підтверджено як придатну деталь.",
+    alreadyRejected: "Уже позначено як брак.",
+    cardPrompt: "Як вийшов друк?",
+    sources: {
+      dialog: "Записано в застосунку.",
+      link: "Записано за посиланням в один дотик.",
+      plateClear: "Записано під час очищення пластини.",
+      printerCard: "Записано з картки принтера.",
+      api: "Записано через API.",
+      reaction: "Записано реакцією в чаті.",
+    },
+  },
+
   // Edit archive modal
   // Edit Archive Modal
   editArchive: {
@@ -5514,6 +5562,12 @@ export default {
     existingTags: "Існуючі теги",
     clickToAdd: "(натисніть, щоб додати)",
     status: "Статус",
+    userVerdict: "Вердикт щодо результату",
+    verdicts: {
+      none: "Не задано",
+      good: "Придатна деталь",
+      reject: "Відбраковано",
+    },
     failureReason: "Причина невдачі",
     selectReason: "Виберіть причину...",
     photos: "Фотографії надрукованих результатів",
@@ -6229,6 +6283,8 @@ export default {
     plateNotEmptyDescription: "Перед друком виявлено об’єкти",
     plateClearRequired: "Потрібно очистити пластину",
     plateClearRequiredDescription: "Друк завершено, черга очікує підтвердження очищення пластини",
+    printConfirmRequest: "Підтвердження результату",
+    printConfirmRequestDescription: "Запитувати вердикт «придатний/брак» із посиланнями в один дотик, коли завершується друк з увімкненим запитом",
     plateClear: "Очищення пластини",
     printCompleted: "Друк завершено",
     bedCooledLabel: "Стіл охолов",

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

@@ -950,6 +950,8 @@ export default {
     showFailedPrints: '显示失败的打印',
     hideFailedPrints: '隐藏失败的打印',
     hideDuplicates: '隐藏重复项',
+    filterUnconfirmed: '待确认',
+    filterUnconfirmedTitle: '仅显示仍在等待成品判定的打印',
     viewOriginalPrint: '点击查看原始打印 (#{{id}})',
     printTime: '打印时间',
     filamentUsed: '耗材用量',
@@ -1004,6 +1006,7 @@ export default {
       slice: '切片',
       externalLink: '外部链接',
       viewOnMakerWorld: '在 MakerWorld 上查看',
+      confirmOutcome: '确认成品',
       preview3d: '3D 预览',
       viewTimelapse: '查看延时摄影',
       scanForTimelapse: '扫描延时摄影',
@@ -1065,6 +1068,12 @@ export default {
       removeFromFavorites: '从收藏中移除',
       cancelled: '已取消',
       failed: '失败',
+      confirmPending: '结果?',
+      confirmPendingTitle: '此打印请求了成品判定 — 点击回答',
+      rejected: '已报废',
+      rejectedTitle: '在打印机上已完成,但被用户标记为报废',
+      confirmedGood: '良品',
+      confirmedGoodTitle: '被用户确认为良品',
       duplicate: '重复',
       duplicateTitle: '此模型之前已打印过',
       openSource3mf: '在 Bambu Studio 中打开源 3MF(右键查看更多选项)',
@@ -1484,6 +1493,8 @@ export default {
       requiresPrevious: '需要前一个成功',
       autoPowerOff: '自动关机',
       gcodeInjection: 'G-code',
+      confirmOutcome: '结果确认',
+      confirmOutcomeTitle: '打印完成后询问打印效果如何',
     },
     // Empty state
     empty: {
@@ -1525,6 +1536,7 @@ export default {
       layerInspection: '首层检查',
       timelapse: '延时摄影',
       useAms: '使用 AMS',
+      confirmOutcome: '询问打印结果',
       nozzleOffsetCali: '喷嘴偏移校准',
       applyChanges: '应用更改',
       selectAll: '全选',
@@ -1669,6 +1681,8 @@ export default {
     failureReasons: '失败原因',
     topFailureReasons: '主要失败原因',
     failedPrintsCount: '{{failed}} / {{total}} 次打印失败',
+    rejectedPrintsCount: '{{rejected}} 个完成后被报废',
+    yieldRate: '良品率:{{rate}}%',
     lastWeekRate: '上周:{{rate}}%',
     // Actions
     resetLayout: '重置布局',
@@ -2318,6 +2332,12 @@ export default {
     defaultLayerInspectDesc: 'AI首层检测',
     defaultTimelapse: '延时摄影',
     defaultTimelapseDesc: '录制延时摄影视频',
+    defaultConfirmOutcome: '询问成品结果',
+    defaultConfirmOutcomeDesc: '打印完成后询问成品是否良好',
+    confirmOutcomeExternalPrints: '也询问非 Bambuddy 启动的打印',
+    confirmOutcomeExternalPrintsDesc: '在打印机、Bambu Studio 或 Handy 应用中启动的打印同样会被 Bambuddy 归档。开启后,它们会收到与队列打印相同的成品确认。',
+    confirmDefaultGoodOnPlateClear: '打印板清空时将未回答的结果计为良品',
+    confirmDefaultGoodOnPlateClearDescription: '当打印板被清空(手动或由下一个排队打印触发)而该打印的结果询问仍未回答时,自动将其记录为良品。打印板被清空后,该打印即算作良品;此后通过 Telegram 或链接作答只会显示已记录的结果。',
     defaultNozzleOffsetCali: '喷嘴偏移校准',
     defaultNozzleOffsetCaliDesc: '校准两个挤出机之间的喷嘴偏移',
     tempFanPresetsTitle: '温度与风扇预设',
@@ -5115,6 +5135,8 @@ export default {
     insufficientBudget: '预算不足',
     unlimitedNoBudget: '无限制 – 未设置预算上限。',
     noPrintableCostCenters: '没有可用于打印的有效成本中心。请联系管理员授予你打印权限。',
+    askForOutcome: '询问打印结果',
+    askForOutcomeTitle: '打印完成后询问打印效果如何',
   },
 
   // Backup
@@ -5433,6 +5455,32 @@ export default {
     uploadFailed: '上传失败',
   },
 
+  confirmOutcome: {
+    title: '这次打印的成品如何?',
+    good: '良品',
+    reject: '报废',
+    later: '稍后询问',
+    noPhoto: '暂无完成照片',
+    savedGood: '已标记为良品',
+    savedReject: '已标记为报废',
+    saveFailed: '无法保存判定结果',
+    rejectReason: '报废原因(可选)',
+    noReason: '无原因',
+    saveReject: '保存',
+    rejectAndReprint: '重新打印',
+    alreadyGood: '已确认为良品。',
+    alreadyRejected: '已标记为报废。',
+    cardPrompt: '打印效果如何?',
+    sources: {
+      dialog: '在应用中记录。',
+      link: '通过一键链接记录。',
+      plateClear: '在清空打印板时记录。',
+      printerCard: '从打印机卡片记录。',
+      api: '通过 API 记录。',
+      reaction: '通过聊天中的表情回应记录。',
+    },
+  },
+
   // Edit archive modal
   // Edit Archive Modal
   editArchive: {
@@ -5460,6 +5508,12 @@ export default {
     existingTags: '现有标签',
     clickToAdd: '(点击添加)',
     status: '状态',
+    userVerdict: '成品判定',
+    verdicts: {
+      none: '未设置',
+      good: '良品',
+      reject: '已报废',
+    },
     failureReason: '失败原因',
     selectReason: '选择原因...',
     photos: '打印成品照片',
@@ -6174,6 +6228,8 @@ export default {
     plateNotEmptyDescription: '打印前检测到物体',
     plateClearRequired: '需要确认热床已清空',
     plateClearRequiredDescription: '打印完成,队列等待热床确认',
+    printConfirmRequest: '成品确认',
+    printConfirmRequestDescription: '当已启用询问的打印完成时,通过一键链接请求良品/报废判定',
     plateClear: '热床确认',
     printCompleted: '打印已完成',
     bedCooledLabel: '热床已冷却',

+ 56 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -950,6 +950,8 @@ export default {
     showFailedPrints: '顯示失敗的列印',
     hideFailedPrints: '隱藏失敗的列印',
     hideDuplicates: '隱藏重複項',
+    filterUnconfirmed: '未確認',
+    filterUnconfirmedTitle: '僅顯示仍在等待成品判定的列印',
     viewOriginalPrint: '點選檢視原始列印 (#{{id}})',
     printTime: '列印時間',
     filamentUsed: '耗材用量',
@@ -1004,6 +1006,7 @@ export default {
       slice: '切片',
       externalLink: '外部連結',
       viewOnMakerWorld: '在 MakerWorld 上檢視',
+      confirmOutcome: '確認成品結果',
       preview3d: '3D 預覽',
       viewTimelapse: '檢視縮時攝影',
       scanForTimelapse: '掃描縮時攝影',
@@ -1065,6 +1068,12 @@ export default {
       removeFromFavorites: '從收藏中移除',
       cancelled: '已取消',
       failed: '失敗',
+      confirmPending: '成品結果?',
+      confirmPendingTitle: '此列印需要成品判定——點選以回答',
+      rejected: '不良',
+      rejectedTitle: '已在印表機上完成,但被使用者標記為不良',
+      confirmedGood: '良品',
+      confirmedGoodTitle: '已由使用者確認為良品',
       duplicate: '重複',
       duplicateTitle: '此模型之前已列印過',
       openSource3mf: '在 Bambu Studio 中開啟源 3MF(右鍵檢視更多選項)',
@@ -1484,6 +1493,8 @@ export default {
       requiresPrevious: '需要前一個成功',
       autoPowerOff: '自動關機',
       gcodeInjection: 'G-code',
+      confirmOutcome: '結果確認',
+      confirmOutcomeTitle: '列印完成後詢問列印效果如何',
     },
     // Empty state
     empty: {
@@ -1525,6 +1536,7 @@ export default {
       layerInspection: '首層檢查',
       timelapse: '縮時攝影',
       useAms: '使用 AMS',
+      confirmOutcome: '詢問列印結果',
       nozzleOffsetCali: '噴嘴偏移校準',
       applyChanges: '套用更改',
       selectAll: '全選',
@@ -1669,6 +1681,8 @@ export default {
     failureReasons: '失敗原因',
     topFailureReasons: '主要失敗原因',
     failedPrintsCount: '{{failed}} / {{total}} 次列印失敗',
+    rejectedPrintsCount: '{{rejected}} 次完成後被判為不良',
+    yieldRate: '良率:{{rate}}%',
     lastWeekRate: '上週:{{rate}}%',
     // Actions
     resetLayout: '重設佈局',
@@ -2318,6 +2332,12 @@ export default {
     defaultLayerInspectDesc: 'AI首層檢測',
     defaultTimelapse: '縮時攝影',
     defaultTimelapseDesc: '錄製縮時攝影影片',
+    defaultConfirmOutcome: '詢問成品結果',
+    defaultConfirmOutcomeDesc: '列印完成後詢問成品是否良好',
+    confirmOutcomeExternalPrints: '也詢問非 Bambuddy 啟動的列印',
+    confirmOutcomeExternalPrintsDesc: '在印表機、Bambu Studio 或 Handy 應用程式中啟動的列印同樣會被 Bambuddy 封存。開啟後,它們會收到與佇列列印相同的成品確認。',
+    confirmDefaultGoodOnPlateClear: '熱床清空時將未回答的結果計為良品',
+    confirmDefaultGoodOnPlateClearDescription: '當熱床被清空(手動或由下一個佇列列印觸發)而該列印的成品結果詢問仍未回答時,自動將其記錄為良品。熱床被清空後,該列印即算作良品;之後透過 Telegram 或連結作答只會顯示已記錄的結果。',
     defaultNozzleOffsetCali: '噴嘴偏移校準',
     defaultNozzleOffsetCaliDesc: '校準兩個擠出機之間的噴嘴偏移',
     tempFanPresetsTitle: '溫度與風扇預設',
@@ -5115,6 +5135,8 @@ export default {
     insufficientBudget: '預算不足',
     unlimitedNoBudget: '無限制 – 未設定預算上限。',
     noPrintableCostCenters: '沒有可用於列印的有效成本中心。請聯絡管理員授予你列印權限。',
+    askForOutcome: '詢問列印結果',
+    askForOutcomeTitle: '列印完成後詢問列印效果如何',
   },
 
   // Backup
@@ -5433,6 +5455,32 @@ export default {
     uploadFailed: '上傳失敗',
   },
 
+  confirmOutcome: {
+    title: '這次列印的成品如何?',
+    good: '良品',
+    reject: '不良',
+    later: '稍後再問',
+    noPhoto: '沒有可用的完成照片',
+    savedGood: '已標記為良品',
+    savedReject: '已標記為不良',
+    saveFailed: '無法儲存判定結果',
+    rejectReason: '不良原因(選填)',
+    noReason: '無原因',
+    saveReject: '儲存',
+    rejectAndReprint: '再次列印',
+    alreadyGood: '已確認為良品。',
+    alreadyRejected: '已標記為不良。',
+    cardPrompt: '成品如何?',
+    sources: {
+      dialog: '在應用程式中記錄。',
+      link: '透過一鍵連結記錄。',
+      plateClear: '在清空熱床時記錄。',
+      printerCard: '從印表機卡片記錄。',
+      api: '透過 API 記錄。',
+      reaction: '透過聊天中的表情回應記錄。',
+    },
+  },
+
   // Edit archive modal
   // Edit Archive Modal
   editArchive: {
@@ -5460,6 +5508,12 @@ export default {
     existingTags: '現有標籤',
     clickToAdd: '(點選新增)',
     status: '狀態',
+    userVerdict: '成品判定',
+    verdicts: {
+      none: '未設定',
+      good: '良品',
+      reject: '不良',
+    },
     failureReason: '失敗原因',
     selectReason: '選擇原因...',
     photos: '列印成品照片',
@@ -6174,6 +6228,8 @@ export default {
     plateNotEmptyDescription: '列印前偵測到物體',
     plateClearRequired: '需要確認熱床已清空',
     plateClearRequiredDescription: '列印完成,佇列等待熱床確認',
+    printConfirmRequest: '成品結果確認',
+    printConfirmRequestDescription: '已選擇加入的列印完成後,透過一鍵連結詢問良品/不良判定',
     plateClear: '熱床確認',
     printCompleted: '列印已完成',
     bedCooledLabel: '熱床已冷卻',

+ 153 - 3
frontend/src/pages/ArchivesPage.tsx

@@ -61,6 +61,8 @@ import {
   Columns,
   ChevronUp,
   ChevronDown,
+  ThumbsUp,
+  ThumbsDown,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { SliceModal } from '../components/SliceModal';
@@ -71,11 +73,13 @@ import { getCurrencySymbol } from '../utils/currency';
 import { getBedTypeInfo } from '../utils/bedType';
 import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
 import { assignableProjects } from '../utils/projectTree';
+import { verdictSourceKey } from '../utils/verdictSource';
 import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import type { Archive, PrintLogEntry, ProjectListItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { PrintModal } from '../components/PrintModal';
+import { ConfirmOutcomeDialog } from '../components/ConfirmOutcomeDialog';
 import { UploadModal } from '../components/UploadModal';
 import { PurgeArchivesModal } from '../components/PurgeArchivesModal';
 import { ConfirmModal } from '../components/ConfirmModal';
@@ -326,6 +330,8 @@ function ArchiveCard({
     return printerMap.get(saved.printer_id);
   }, [archive.extra_data, printerMap]);
   const [showReprint, setShowReprint] = useState(false);
+  // Post-print outcome confirmation dialog (#1898)
+  const [showConfirmOutcome, setShowConfirmOutcome] = useState(false);
   const [showSliceModal, setShowSliceModal] = useState(false);
   const [showRunPipeline, setShowRunPipeline] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -371,6 +377,9 @@ function ArchiveCard({
 
   // Use pre-computed duplicate sequence and original archive ID from list response
   const duplicateSequence = archive.duplicate_sequence ?? 0;
+  // Appended to the verdict badge's tooltip (#1898) so a verdict the
+  // plate-clear default recorded can be explained where it is shown.
+  const verdictSourceHintKey = verdictSourceKey(archive.user_verdict_source);
   const originalArchiveId = archive.original_archive_id ?? null;
 
   const plates = platesData?.plates ?? [];
@@ -608,6 +617,21 @@ function ArchiveCard({
       },
       disabled: !archive.external_url && !archive.makerworld_url,
     },
+    // Post-print outcome confirmation (#1898): completed prints only — the
+    // machine statuses already cover everything else.
+    ...(archive.status === 'completed'
+      ? [{
+          label: t('archives.menu.confirmOutcome'),
+          icon: archive.user_verdict === 'reject'
+            ? <ThumbsDown className="w-4 h-4" />
+            : <ThumbsUp className="w-4 h-4" />,
+          onClick: () => setShowConfirmOutcome(true),
+          disabled: !canModify('archives', 'update', archive.created_by_id),
+          title: !canModify('archives', 'update', archive.created_by_id)
+            ? t('archives.permission.noUpdateArchives')
+            : undefined,
+        }]
+      : []),
     { label: '', divider: true, onClick: () => {} },
     {
       label: t('archives.menu.preview3d'),
@@ -983,6 +1007,42 @@ function ArchiveCard({
             {archive.status === 'aborted' ? t('archives.card.cancelled') : t('archives.card.failed')}
           </div>
         )}
+        {/* Outcome-confirmation badges (#1898): a completed print either
+            waiting for its verdict (click to answer, for whoever may update
+            the archive; a status only for anyone else) or marked as reject.
+            Same spot as the failed badge — the three are mutually exclusive. */}
+        {archive.status === 'completed' && archive.confirm_requested && archive.user_verdict == null && (
+          <button
+            onClick={(e) => {
+              e.stopPropagation();
+              setShowConfirmOutcome(true);
+            }}
+            disabled={!canModify('archives', 'update', archive.created_by_id)}
+            className="absolute top-2 left-12 px-2 py-1 rounded text-xs bg-amber-500/90 enabled:hover:bg-amber-500 text-black flex items-center gap-1 transition-colors enabled:cursor-pointer"
+            title={!canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : t('archives.card.confirmPendingTitle')}
+          >
+            <ThumbsUp className="w-3 h-3" />
+            {t('archives.card.confirmPending')}
+          </button>
+        )}
+        {archive.status === 'completed' && archive.user_verdict === 'reject' && (
+          <div
+            className="absolute top-2 left-12 px-2 py-1 rounded text-xs bg-status-error/80 text-white flex items-center gap-1"
+            title={`${t('archives.card.rejectedTitle')}${verdictSourceHintKey ? ` — ${t(verdictSourceHintKey)}` : ''}`}
+          >
+            <ThumbsDown className="w-3 h-3" />
+            {t('archives.card.rejected')}
+          </div>
+        )}
+        {archive.status === 'completed' && archive.user_verdict === 'good' && (
+          <div
+            className="absolute top-2 left-12 px-2 py-1 rounded text-xs bg-bambu-green/80 text-white flex items-center gap-1"
+            title={`${t('archives.card.confirmedGoodTitle')}${verdictSourceHintKey ? ` — ${t(verdictSourceHintKey)}` : ''}`}
+          >
+            <ThumbsUp className="w-3 h-3" />
+            {t('archives.card.confirmedGood')}
+          </div>
+        )}
         {/* Duplicate badge */}
         {archive.duplicate_count > 0 && duplicateSequence > 0 && originalArchiveId && (
           <button
@@ -1460,6 +1520,14 @@ function ArchiveCard({
         />
       )}
 
+      {/* Post-print outcome confirmation (#1898) */}
+      {showConfirmOutcome && (
+        <ConfirmOutcomeDialog
+          archiveId={archive.id}
+          onClose={() => setShowConfirmOutcome(false)}
+        />
+      )}
+
       {/* Slice Modal */}
       {showSliceModal && (
         <SliceModal
@@ -1791,6 +1859,8 @@ function ArchiveListRow({
   });
   const navigate = useNavigate();
   const [showReprint, setShowReprint] = useState(false);
+  // Post-print outcome confirmation dialog (#1898)
+  const [showConfirmOutcome, setShowConfirmOutcome] = useState(false);
   const [showSliceModal, setShowSliceModal] = useState(false);
   const [showRunPipeline, setShowRunPipeline] = useState(false);
   const [showTimelapse, setShowTimelapse] = useState(false);
@@ -1811,6 +1881,9 @@ function ArchiveListRow({
 
   // Use pre-computed duplicate sequence and original archive ID from list response
   const duplicateSequence = archive.duplicate_sequence ?? 0;
+  // Appended to the verdict badge's tooltip (#1898) so a verdict the
+  // plate-clear default recorded can be explained where it is shown.
+  const verdictSourceHintKey = verdictSourceKey(archive.user_verdict_source);
   const originalArchiveId = archive.original_archive_id ?? null;
 
   // 3D Preview click handler. Multi-plate archives show the plate picker
@@ -2033,6 +2106,21 @@ function ArchiveListRow({
       },
       disabled: !archive.external_url && !archive.makerworld_url,
     },
+    // Post-print outcome confirmation (#1898): completed prints only — the
+    // machine statuses already cover everything else.
+    ...(archive.status === 'completed'
+      ? [{
+          label: t('archives.menu.confirmOutcome'),
+          icon: archive.user_verdict === 'reject'
+            ? <ThumbsDown className="w-4 h-4" />
+            : <ThumbsUp className="w-4 h-4" />,
+          onClick: () => setShowConfirmOutcome(true),
+          disabled: !canModify('archives', 'update', archive.created_by_id),
+          title: !canModify('archives', 'update', archive.created_by_id)
+            ? t('archives.permission.noUpdateArchives')
+            : undefined,
+        }]
+      : []),
     { label: '', divider: true, onClick: () => {} },
     {
       label: t('archives.menu.preview3d'),
@@ -2298,6 +2386,36 @@ function ArchiveListRow({
                 {archive.status === 'aborted' ? t('archives.card.cancelled') : t('archives.card.failed')}
               </span>
             )}
+            {/* Outcome-confirmation badges (#1898), compact list variants */}
+            {archive.status === 'completed' && archive.confirm_requested && archive.user_verdict == null && (
+              <button
+                onClick={(e) => {
+                  e.stopPropagation();
+                  setShowConfirmOutcome(true);
+                }}
+                disabled={!canModify('archives', 'update', archive.created_by_id)}
+                className="px-1.5 py-0.5 rounded text-[10px] leading-tight bg-amber-500/90 enabled:hover:bg-amber-500 text-black flex-shrink-0 transition-colors"
+                title={!canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : t('archives.card.confirmPendingTitle')}
+              >
+                {t('archives.card.confirmPending')}
+              </button>
+            )}
+            {archive.status === 'completed' && archive.user_verdict === 'reject' && (
+              <span
+                className="px-1.5 py-0.5 rounded text-[10px] leading-tight bg-status-error/80 text-white flex-shrink-0"
+                title={`${t('archives.card.rejectedTitle')}${verdictSourceHintKey ? ` — ${t(verdictSourceHintKey)}` : ''}`}
+              >
+                {t('archives.card.rejected')}
+              </span>
+            )}
+            {archive.status === 'completed' && archive.user_verdict === 'good' && (
+              <span
+                className="px-1.5 py-0.5 rounded text-[10px] leading-tight bg-bambu-green/80 text-white flex-shrink-0"
+                title={`${t('archives.card.confirmedGoodTitle')}${verdictSourceHintKey ? ` — ${t(verdictSourceHintKey)}` : ''}`}
+              >
+                {t('archives.card.confirmedGood')}
+              </span>
+            )}
             {archive.duplicate_count > 0 && duplicateSequence > 0 && originalArchiveId && (
               <button
                 onClick={(e) => {
@@ -2516,6 +2634,14 @@ function ArchiveListRow({
         />
       )}
 
+      {/* Post-print outcome confirmation (#1898) */}
+      {showConfirmOutcome && (
+        <ConfirmOutcomeDialog
+          archiveId={archive.id}
+          onClose={() => setShowConfirmOutcome(false)}
+        />
+      )}
+
       {/* Slice Modal */}
       {showSliceModal && (
         <SliceModal
@@ -2837,6 +2963,10 @@ export function ArchivesPage() {
   const [hideFailed, setHideFailed] = useState(() =>
     localStorage.getItem('archiveHideFailed') === 'true'
   );
+  // Only archives still waiting for their outcome verdict (#1898)
+  const [filterUnconfirmed, setFilterUnconfirmed] = useState(() =>
+    localStorage.getItem('archiveFilterUnconfirmed') === 'true'
+  );
   const [hideDuplicates, setHideDuplicates] = useState(() =>
     localStorage.getItem('archiveHideDuplicates') === 'true'
   );
@@ -3314,6 +3444,10 @@ export function ArchivesPage() {
     localStorage.setItem('archiveHideFailed', hideFailed.toString());
   }, [hideFailed]);
 
+  useEffect(() => {
+    localStorage.setItem('archiveFilterUnconfirmed', filterUnconfirmed.toString());
+  }, [filterUnconfirmed]);
+
   useEffect(() => {
     localStorage.setItem('archiveHideDuplicates', hideDuplicates.toString());
   }, [hideDuplicates]);
@@ -3333,7 +3467,7 @@ export function ArchivesPage() {
   // Reset page when filters/search/sort/collection change
   useEffect(() => {
     setPageIndex(0);
-  }, [search, filterPrinter, filterMaterial, filterColors, colorFilterMode, filterFavorites, hideFailed, hideDuplicates, filterTag, filterFileType, sortBy, collection]);
+  }, [search, filterPrinter, filterMaterial, filterColors, colorFilterMode, filterFavorites, hideFailed, filterUnconfirmed, hideDuplicates, filterTag, filterFileType, sortBy, collection]);
 
   useEffect(() => {
     try { localStorage.setItem('archivePageSize', String(pageSize)); } catch { /* ignore */ }
@@ -3466,6 +3600,10 @@ export function ArchivesPage() {
       // Hide failed filter (don't apply when viewing failed collection)
       const matchesHideFailed = collection === 'failed' || !hideFailed || (a.status !== 'failed' && a.status !== 'aborted');
 
+      // Unconfirmed-outcome filter (#1898): archives that asked for a verdict
+      // and are still waiting for one.
+      const matchesUnconfirmed = !filterUnconfirmed || (a.confirm_requested && a.user_verdict == null);
+
       // Hide duplicates filter (don't apply when viewing duplicates collection)
       const matchesHideDuplicates =
         collection === 'duplicates' || !hideDuplicates || a.duplicate_count === 0 || a.duplicate_sequence === 0;
@@ -3480,7 +3618,7 @@ export function ArchivesPage() {
         (filterFileType === 'gcode' && isGcodeFile) ||
         (filterFileType === 'source' && !isGcodeFile);
 
-      return matchesCollection && matchesSearch && matchesMaterial && matchesColor && matchesFavorites && matchesHideFailed && matchesHideDuplicates && matchesTag && matchesFileType;
+      return matchesCollection && matchesSearch && matchesMaterial && matchesColor && matchesFavorites && matchesHideFailed && matchesUnconfirmed && matchesHideDuplicates && matchesTag && matchesFileType;
     })
     .sort((a, b) => {
       switch (sortBy) {
@@ -3574,7 +3712,7 @@ export function ArchivesPage() {
     setFilterFileType('all');
   };
 
-  const hasTopFilters = search || filterPrinter || filterMaterial || filterFavorites || hideFailed || hideDuplicates || filterTag || filterFileType !== 'all';
+  const hasTopFilters = search || filterPrinter || filterMaterial || filterFavorites || hideFailed || filterUnconfirmed || hideDuplicates || filterTag || filterFileType !== 'all';
 
   // Page-wide drag-and-drop upload (#1510). The hook covers the three cancel
   // paths the previous inline implementation missed (drag-out-of-window, Escape,
@@ -4015,6 +4153,18 @@ export function ArchivesPage() {
               <AlertCircle className={`w-4 h-4 ${hideFailed ? '' : ''}`} />
               <span className="text-sm hidden md:inline">Hide Failed</span>
             </button>
+            <button
+              onClick={() => setFilterUnconfirmed(!filterUnconfirmed)}
+              className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors flex-shrink-0 ${
+                filterUnconfirmed
+                  ? 'bg-amber-100 dark:bg-amber-500/20 border-amber-500 text-amber-700 dark:text-amber-400'
+                  : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
+              }`}
+              title={t('archives.filterUnconfirmedTitle')}
+            >
+              <ThumbsUp className="w-4 h-4" />
+              <span className="text-sm hidden md:inline">{t('archives.filterUnconfirmed')}</span>
+            </button>
             <button
               onClick={() => setHideDuplicates(!hideDuplicates)}
               className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors flex-shrink-0 ${

+ 88 - 14
frontend/src/pages/PrintersPage.tsx

@@ -140,7 +140,10 @@ import {
   MonitorPlay,
   ExternalLink,
   PictureInPicture2,
+  ThumbsUp,
+  ThumbsDown,
 } from 'lucide-react';
+import { ConfirmOutcomeDialog } from '../components/ConfirmOutcomeDialog';
 
 // Aliased: lucide-react already exports a `Link` icon into this module.
 import { Link as RouterLink, useNavigate } from 'react-router-dom';
@@ -2164,7 +2167,7 @@ function PrinterCard({
   const queryClient = useQueryClient();
   const navigate = useNavigate();
   const { showToast } = useToast();
-  const { hasPermission } = useAuth();
+  const { hasPermission, canModify } = useAuth();
   const [showMenu, setShowMenu] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [deleteArchives, setDeleteArchives] = useState(true);
@@ -2655,6 +2658,21 @@ function PrinterCard({
   const lastPrint = lastPrints?.[0];
   const isPrintingOrPaused = status?.state === 'RUNNING' || status?.state === 'PAUSE';
   const needsPlateClear = requirePlateClear && status?.awaiting_plate_clear === true;
+  // Post-print outcome confirmation on the card (#1898): while the plate-clear
+  // gate is up and the just-finished print asked for a verdict, offer
+  // Good/Reject right where the operator releases the plate. Releasing the
+  // plate without answering stays possible — the two are orthogonal (though
+  // the confirm_default_good_on_plate_clear setting can couple them).
+  // Only offered to whoever may record it: the PATCH checks archives:update
+  // against the archive's owner, so for anyone else the pair could only fail.
+  const pendingConfirmArchive =
+    lastPrint &&
+    lastPrint.status === 'completed' &&
+    lastPrint.confirm_requested &&
+    lastPrint.user_verdict == null &&
+    canModify('archives', 'update', lastPrint.created_by_id)
+      ? lastPrint
+      : null;
   // Not gated on `connected`: the plate-clear gate is Bambuddy-side state, and with
   // Auto Power Off the printer is powered down exactly when the operator clears the
   // plate. Hiding the control there left no way to release the gate (#2864).
@@ -2871,24 +2889,80 @@ function PrinterCard({
     onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
   });
 
+  // Post-print outcome confirmation (#1898): one-tap "good" from the card,
+  // reject goes through the dialog for the optional reason + reprint.
+  const [showConfirmOutcome, setShowConfirmOutcome] = useState(false);
+  // Only "good" is answered on the card. A reject wants a reason and possibly
+  // a reprint, so that button opens the dialog instead — and the parameter
+  // says as much, rather than accepting a verdict this path cannot send and
+  // then reporting it as good.
+  const cardVerdictMutation = useMutation({
+    mutationFn: (verdict: 'good') =>
+      api.updateArchive(pendingConfirmArchive!.id, {
+        user_verdict: verdict,
+        user_verdict_source: 'printer_card',
+      }),
+    onSuccess: () => {
+      showToast(t('confirmOutcome.savedGood'), 'success');
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+    },
+    onError: (error: Error) => showToast(error.message || t('confirmOutcome.saveFailed'), 'error'),
+  });
+
   // Rendered from two places: inside the live-status block for a connected printer,
   // and standalone below it for a powered-down one, whose status block isn't rendered
   // at all (#2864). Shared so the two can't drift apart.
   const expandedClearPlateButton = (
-    <button
-      type="button"
-      onClick={() => clearPlateMutation.mutate()}
-      disabled={clearPlateMutation.isPending || !hasPermission('printers:clear_plate')}
-      className="mt-2 w-full inline-flex items-center justify-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-300 dark:border-yellow-400/40 text-yellow-700 dark:text-yellow-400 hover:bg-yellow-500/30 transition-colors text-xs font-medium disabled:opacity-50"
-      title={!hasPermission('printers:clear_plate') ? t('printers.permission.noControl') : t('printers.plateStatus.markCleared')}
-    >
-      {clearPlateMutation.isPending ? (
-        <Loader2 className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] animate-spin" />
-      ) : (
-        <PlateClearedIcon className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
+    <>
+      {pendingConfirmArchive && (
+        <div className="mt-2 flex items-center gap-1.5">
+          <span
+            className="flex-1 min-w-0 truncate text-xs text-bambu-gray"
+            title={pendingConfirmArchive.print_name || pendingConfirmArchive.filename}
+          >
+            {t('confirmOutcome.cardPrompt')}
+          </span>
+          <button
+            type="button"
+            onClick={() => setShowConfirmOutcome(true)}
+            disabled={cardVerdictMutation.isPending}
+            className="p-1.5 rounded-lg border border-red-500/40 text-red-500 hover:bg-red-500/10 transition-colors"
+            title={t('confirmOutcome.reject')}
+          >
+            <ThumbsDown className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
+          </button>
+          <button
+            type="button"
+            onClick={() => cardVerdictMutation.mutate('good')}
+            disabled={cardVerdictMutation.isPending}
+            className="p-1.5 rounded-lg border border-bambu-green/50 text-bambu-green hover:bg-bambu-green/10 transition-colors"
+            title={t('confirmOutcome.good')}
+          >
+            <ThumbsUp className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
+          </button>
+        </div>
       )}
-      {t('printers.plateStatus.markCleared')}
-    </button>
+      <button
+        type="button"
+        onClick={() => clearPlateMutation.mutate()}
+        disabled={clearPlateMutation.isPending || !hasPermission('printers:clear_plate')}
+        className="mt-2 w-full inline-flex items-center justify-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-300 dark:border-yellow-400/40 text-yellow-700 dark:text-yellow-400 hover:bg-yellow-500/30 transition-colors text-xs font-medium disabled:opacity-50"
+        title={!hasPermission('printers:clear_plate') ? t('printers.permission.noControl') : t('printers.plateStatus.markCleared')}
+      >
+        {clearPlateMutation.isPending ? (
+          <Loader2 className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] animate-spin" />
+        ) : (
+          <PlateClearedIcon className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
+        )}
+        {t('printers.plateStatus.markCleared')}
+      </button>
+      {showConfirmOutcome && pendingConfirmArchive && (
+        <ConfirmOutcomeDialog
+          archiveId={pendingConfirmArchive.id}
+          onClose={() => setShowConfirmOutcome(false)}
+        />
+      )}
+    </>
   );
 
   const nozzleTemperatureMutation = useMutation({

+ 14 - 1
frontend/src/pages/QueuePage.tsx

@@ -62,6 +62,7 @@ import {
   Ban,
   PlayCircle,
   Workflow,
+  ThumbsUp,
 } from 'lucide-react';
 import { api, ApiError } from '../api/client';
 import { PipelineRunsView } from './PipelineRunsPage';
@@ -156,6 +157,7 @@ function BulkEditModal({
   const [layerInspect, setLayerInspect] = useState<boolean | 'unchanged'>('unchanged');
   const [timelapse, setTimelapse] = useState<boolean | 'unchanged'>('unchanged');
   const [useAms, setUseAms] = useState<boolean | 'unchanged'>('unchanged');
+  const [confirmOutcome, setConfirmOutcome] = useState<boolean | 'unchanged'>('unchanged');
   const [nozzleOffsetCali, setNozzleOffsetCali] = useState<CalibrationMode | 'unchanged'>('unchanged');
 
   // Show the dual-nozzle-only toggle when the user has at least one
@@ -176,6 +178,7 @@ function BulkEditModal({
     if (layerInspect !== 'unchanged') data.layer_inspect = layerInspect;
     if (timelapse !== 'unchanged') data.timelapse = timelapse;
     if (useAms !== 'unchanged') data.use_ams = useAms;
+    if (confirmOutcome !== 'unchanged') data.confirm_outcome = confirmOutcome;
     if (nozzleOffsetCali !== 'unchanged') data.nozzle_offset_cali = nozzleOffsetCali;
     onSave(data);
   };
@@ -183,7 +186,7 @@ function BulkEditModal({
   const hasChanges = printerId !== 'unchanged' || manualStart !== 'unchanged' || autoOffAfter !== 'unchanged' ||
     requirePreviousSuccess !== 'unchanged' || bedLevelling !== 'unchanged' || flowCali !== 'unchanged' ||
     vibrationCali !== 'unchanged' || layerInspect !== 'unchanged' || timelapse !== 'unchanged' || useAms !== 'unchanged' ||
-    nozzleOffsetCali !== 'unchanged' || gcodeInjection !== 'unchanged';
+    confirmOutcome !== 'unchanged' || nozzleOffsetCali !== 'unchanged' || gcodeInjection !== 'unchanged';
 
   return (
     <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
@@ -249,6 +252,7 @@ function BulkEditModal({
               <TriStateToggle label={t('queue.bulkEdit.layerInspection')} value={layerInspect} onChange={setLayerInspect} t={t} />
               <TriStateToggle label={t('queue.bulkEdit.timelapse')} value={timelapse} onChange={setTimelapse} t={t} />
               <TriStateToggle label={t('queue.bulkEdit.useAms')} value={useAms} onChange={setUseAms} t={t} />
+              <TriStateToggle label={t('queue.bulkEdit.confirmOutcome')} value={confirmOutcome} onChange={setConfirmOutcome} t={t} />
               {hasDualNozzlePrinter && (
                 <CalibrationModeToggle label={t('queue.bulkEdit.nozzleOffsetCali')} value={nozzleOffsetCali} onChange={setNozzleOffsetCali} t={t} />
               )}
@@ -716,6 +720,15 @@ function SortableQueueItem({
                 {t('queue.badges.gcodeInjection')}
               </span>
             )}
+            {item.confirm_outcome && (
+              <span
+                title={t('queue.badges.confirmOutcomeTitle')}
+                className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-green-50 dark:bg-green-500/10 text-green-700 dark:text-green-400 rounded-full border border-green-200 dark:border-green-500/20 flex items-center gap-1"
+              >
+                <ThumbsUp className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
+                {t('queue.badges.confirmOutcome')}
+              </span>
+            )}
           </div>
 
           {/* Progress bar for printing items - TODO: integrate with WebSocket */}

+ 49 - 0
frontend/src/pages/SettingsPage.tsx

@@ -1200,6 +1200,9 @@ export function SettingsPage() {
       (baseline.default_layer_inspect ?? false) !== (localSettings.default_layer_inspect ?? false) ||
       (baseline.default_timelapse ?? false) !== (localSettings.default_timelapse ?? false) ||
       (baseline.default_nozzle_offset_cali ?? 'auto') !== (localSettings.default_nozzle_offset_cali ?? 'auto') ||
+      (baseline.default_confirm_outcome ?? false) !== (localSettings.default_confirm_outcome ?? false) ||
+      (baseline.confirm_outcome_external_prints ?? false) !== (localSettings.confirm_outcome_external_prints ?? false) ||
+      (baseline.confirm_default_good_on_plate_clear ?? false) !== (localSettings.confirm_default_good_on_plate_clear ?? false) ||
       (baseline.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||
       (baseline.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) ||
       (baseline.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false) ||
@@ -1312,6 +1315,9 @@ export function SettingsPage() {
         default_layer_inspect: localSettings.default_layer_inspect,
         default_timelapse: localSettings.default_timelapse,
         default_nozzle_offset_cali: localSettings.default_nozzle_offset_cali,
+        default_confirm_outcome: localSettings.default_confirm_outcome,
+        confirm_outcome_external_prints: localSettings.confirm_outcome_external_prints,
+        confirm_default_good_on_plate_clear: localSettings.confirm_default_good_on_plate_clear,
         stagger_group_size: localSettings.stagger_group_size,
         stagger_interval_minutes: localSettings.stagger_interval_minutes,
         require_plate_clear: localSettings.require_plate_clear,
@@ -4939,6 +4945,7 @@ export function SettingsPage() {
                 { key: 'default_layer_inspect' as const, label: t('settings.defaultLayerInspect', 'First Layer Inspection'), desc: t('settings.defaultLayerInspectDesc', 'AI inspection of first layer'), fallback: false, dualNozzleOnly: false, tristate: false },
                 { key: 'default_timelapse' as const, label: t('settings.defaultTimelapse', 'Timelapse'), desc: t('settings.defaultTimelapseDesc', 'Record timelapse video'), fallback: false, dualNozzleOnly: false, tristate: false },
                 { key: 'default_nozzle_offset_cali' as const, label: t('settings.defaultNozzleOffsetCali', 'Nozzle Offset Calibration'), desc: t('settings.defaultNozzleOffsetCaliDesc', 'Calibrate nozzle offsets between extruders'), fallback: true, dualNozzleOnly: true, tristate: true },
+                { key: 'default_confirm_outcome' as const, label: t('settings.defaultConfirmOutcome', 'Ask for Outcome'), desc: t('settings.defaultConfirmOutcomeDesc', 'Ask whether the print came out well after it completes'), fallback: false, dualNozzleOnly: false, tristate: false },
               ]
               .filter(({ dualNozzleOnly }) => !dualNozzleOnly || (printers || []).some(p => p.nozzle_count === 2))
               .map(({ key, label, desc, fallback, tristate }) => (
@@ -4990,6 +4997,27 @@ export function SettingsPage() {
                   )}
                 </div>
               ))}
+              {/* Prints Bambuddy only archived, never dispatched, carry no
+                  queue item to read the ask-for-outcome flag from (#1898). */}
+              <div className="flex items-center justify-between">
+                <div className="flex-1 mr-4">
+                  <p className="text-sm text-white">
+                    {t('settings.confirmOutcomeExternalPrints', 'Also ask for prints started outside Bambuddy')}
+                  </p>
+                  <p className="text-xs text-bambu-gray mt-0.5">
+                    {t('settings.confirmOutcomeExternalPrintsDesc', 'Prints started at the printer, in Bambu Studio or in the Handy app are archived by Bambuddy too. With this on, they get the same outcome prompt as queued prints.')}
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer shrink-0">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.confirm_outcome_external_prints ?? false}
+                    onChange={(e) => updateSetting('confirm_outcome_external_prints', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
             </CardContent>
           </Card>
 
@@ -5021,6 +5049,27 @@ export function SettingsPage() {
                   <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
                 </label>
               </div>
+              {/* Post-print outcome confirmation (#1898): default unanswered
+                  prompts to "good" the moment the plate is released. */}
+              <div className="flex items-center justify-between">
+                <div className="flex-1 mr-4">
+                  <p className="text-sm text-white">
+                    {t('settings.confirmDefaultGoodOnPlateClear', 'Count unanswered outcomes as good on plate release')}
+                  </p>
+                  <p className="text-xs text-bambu-gray mt-1">
+                    {t('settings.confirmDefaultGoodOnPlateClearDescription', 'When the plate is released (manually or by the next queued print) and the print\'s outcome prompt is still unanswered, record it as a good part automatically. The print then counts as good as soon as the plate is released; a Telegram or link answer after that only shows the recorded result.')}
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.confirm_default_good_on_plate_clear ?? false}
+                    onChange={(e) => updateSetting('confirm_default_good_on_plate_clear', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
             </CardContent>
           </Card>
 

+ 13 - 0
frontend/src/pages/StatsPage.tsx

@@ -787,6 +787,19 @@ function FailureAnalysisWidget({ size = 1, dateFrom, dateTo, createdById }: {
         <div className="text-sm text-bambu-gray mt-1">
           {t('stats.failedPrintsCount', { failed: analysis.failed_prints, total: analysis.total_prints })}
         </div>
+        {/* Quality dimension (#1898): completed prints the user rejected.
+            Only rendered once at least one verdict exists — installs without
+            the confirmation workflow see the widget unchanged. */}
+        {(analysis.rejected_prints ?? 0) > 0 && (
+          <div className="text-sm mt-1">
+            <span className="text-status-warning">
+              {t('stats.rejectedPrintsCount', { rejected: analysis.rejected_prints })}
+            </span>
+            <span className="text-bambu-gray ml-2">
+              {t('stats.yieldRate', { rate: (analysis.yield_rate ?? 0).toFixed(1) })}
+            </span>
+          </div>
+        )}
         {/* Trend indicator */}
         {analysis.trend && analysis.trend.length >= 2 && (
           <div className={`${size >= 2 ? 'mt-4' : 'mt-2 pt-2 border-t border-bambu-dark-tertiary'}`}>

+ 27 - 0
frontend/src/utils/verdictSource.ts

@@ -0,0 +1,27 @@
+// How a post-print outcome verdict reached an archive (#1898). The backend
+// stamps it wherever a verdict is written, so a "good" badge nobody remembers
+// clicking can say where it came from — the live case being the plate-clear
+// default answering a prompt before the operator reached the phone.
+//
+// 'reaction' is written by the Telegram reaction handler (#3046), which lands
+// separately; it is mapped here so it reads correctly the day it does.
+
+import type { VerdictSource } from '../api/client';
+
+const SOURCE_KEYS: Record<VerdictSource, string> = {
+  dialog: 'confirmOutcome.sources.dialog',
+  link: 'confirmOutcome.sources.link',
+  plate_clear: 'confirmOutcome.sources.plateClear',
+  printer_card: 'confirmOutcome.sources.printerCard',
+  api: 'confirmOutcome.sources.api',
+  reaction: 'confirmOutcome.sources.reaction',
+};
+
+/**
+ * i18n key for a verdict source, or null when the source is unknown — archives
+ * answered before this shipped carry no source and must show no hint at all.
+ */
+export function verdictSourceKey(source: string | null | undefined): string | null {
+  if (!source) return null;
+  return SOURCE_KEYS[source as VerdictSource] ?? null;
+}