Przeglądaj źródła

fix(stats): #1593 multi-plate parser + per-run project rollup + carry-over system totals + accuracy band

  Two stacked causes under-reported multi-plate prints in the project
  rollup and the archive card.

  Root cause 1 - parser only read plate 1.

  ThreeMFParser._parse_slice_info used root.find(".//plate") and pulled
  prediction / weight from that one element. Any multi-plate file's
  archive-level print_time_seconds / filament_used_grams reflected
  plate 1 alone. The /plates endpoint already looped findall and was
  correct, which is why the plate carousel showed the right numbers
  while the archive card was wrong.

  Fix: loop every <plate> and sum prediction + weight. Per-plate
  concepts (plate_number, _plate_index, printable_objects) only set
  when there's exactly one plate - for multi-plate exports the
  archive represents all plates and a single index doesn't apply at
  the file level. bed_type keeps the first plate's value as a
  best-effort default. Malformed prediction / weight on individual
  plates skip cleanly rather than poison the sum.

  Root cause 2 - project rollup aggregated PrintArchive, not the
  per-run log.

  compute_project_stats and list_projects quick-stats summed
  PrintArchive.print_time_seconds / filament_used_grams / cost /
  energy_* WHERE project_id. A reprint reuses the source archive row
  and writes a new PrintLogEntry, so 3 sequential runs collapsed to 1
  archive - and that archive's numbers were already plate-1-only from
  cause 1. The Archive Print Log path was already correct because it
  drove off print_log_entries (archives.py:420 comment).

  Fix: both compute_project_stats and the list_projects quick-stats
  block inner-join print_log_entries -> print_archives WHERE
  archives.project_id. total_archives becomes COUNT(PrintLogEntry.id),
  failed_prints counts runs in failed/aborted/cancelled/stopped,
  completed_items is SUM(PrintArchive.quantity) for runs where
  status='completed', time/filament/cost/energy from PrintLogEntry.
  Orphan log rows (archive_id IS NULL post archive deletion) are
  excluded by the inner join.

  Same-shape fixes carried forward (no follow-ups per project rule):

  system.py system-info totals: total_print_time / total_filament
  had the same bug shape - summed PrintArchive directly so reprints
  collapsed to one row. Now sums PrintLogEntry.duration_seconds /
  filament_used_grams. The semantic shift is also a correctness
  improvement: the field now reflects time the printer actually spent
  printing, not slicer-estimated time.

  archives.py time-accuracy metric: estimate / actual per run where
  estimate = PrintArchive.print_time_seconds. Post-parser-fix
  multi-plate archives have file-level estimate but per-run actual =
  one plate, so ratio = N x 100% for an N-plate file. The calc now
  clamps each row to the [50%, 200%] plausibility band before
  contributing to the printer-level average; single-plate accuracy
  (the case the metric is designed for) stays fully included.

  Backfill: users with AMS spool tracking - the reporter's case - have
  per-run filament_used_grams from the tracked spool delta, so stats
  become correct immediately. Users without tracking fall back to the
  archive estimate and undercount until they reprint. Archive card
  still reads PrintArchive.filament_used_grams directly so old
  multi-plate archives keep plate-1-only numbers until reslice -
  forward-only as the reporter accepted.
maziggy 1 dzień temu
rodzic
commit
1e08c25a9f

Plik diff jest za duży
+ 0 - 0
CHANGELOG.md


+ 19 - 0
backend/app/api/routes/archives.py

@@ -948,6 +948,23 @@ async def get_archive_stats(
             *base_conditions,
             *base_conditions,
         )
         )
     )
     )
+    # Accuracy is meaningful only when the estimate roughly describes the
+    # work the run actually performed. Two shapes produce wildly-off ratios
+    # that are pure noise:
+    #   - multi-plate ``.gcode.3mf`` printed plate-by-plate: each run's
+    #     actual is one plate, the archive's estimate is the sum across
+    #     plates (post-#1593 parser fix), so the ratio is roughly N×100%
+    #     for an N-plate file. Pre-fix this shape was also broken, just
+    #     less dramatically — the estimate was plate-1-only so the ratio
+    #     was meaningless rather than N×.
+    #   - manual interventions / purge waste blowing the actual far past
+    #     the estimate.
+    # Clamp to the [50%, 200%] band so the printer-level average reflects
+    # real slicer-vs-reality drift, not multi-plate accounting or one-off
+    # outliers. Single-plate archives — the case the metric is actually
+    # designed for — stay fully included.
+    _ACCURACY_BAND_LO = 50.0
+    _ACCURACY_BAND_HI = 200.0
     average_accuracy = None
     average_accuracy = None
     accuracy_by_printer: dict[str, float] = {}
     accuracy_by_printer: dict[str, float] = {}
     accuracies: list[float] = []
     accuracies: list[float] = []
@@ -960,6 +977,8 @@ async def get_archive_stats(
         if not actual_seconds or not estimate_seconds:
         if not actual_seconds or not estimate_seconds:
             continue
             continue
         accuracy = (estimate_seconds / actual_seconds) * 100
         accuracy = (estimate_seconds / actual_seconds) * 100
+        if accuracy < _ACCURACY_BAND_LO or accuracy > _ACCURACY_BAND_HI:
+            continue
         accuracies.append(accuracy)
         accuracies.append(accuracy)
         printer_key = str(run_printer_id) if run_printer_id else "unknown"
         printer_key = str(run_printer_id) if run_printer_id else "unknown"
         printer_accuracies.setdefault(printer_key, []).append(accuracy)
         printer_accuracies.setdefault(printer_key, []).append(accuracy)

+ 83 - 69
backend/app/api/routes/projects.py

@@ -20,6 +20,7 @@ from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.project import Project
 from backend.app.models.project import Project
 from backend.app.models.project_bom import ProjectBOMItem
 from backend.app.models.project_bom import ProjectBOMItem
@@ -48,40 +49,66 @@ logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/projects", tags=["projects"])
 router = APIRouter(prefix="/projects", tags=["projects"])
 
 
 
 
+_FAILURE_STATUSES = ("failed", "aborted", "cancelled", "stopped")
+
+
 async def compute_project_stats(
 async def compute_project_stats(
     db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None
     db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None
 ) -> ProjectStats:
 ) -> ProjectStats:
-    """Compute statistics for a project."""
-    # Count total archives (distinct print jobs)
-    total_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id))
-    total_archives = total_result.scalar() or 0
-
-    # Sum total items (using quantity field)
-    total_items_result = await db.execute(
-        select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project_id)
-    )
-    total_items = total_items_result.scalar() or 0
-
-    # Count failed archives (number of print jobs) - includes all failure states
-    failed_result = await db.execute(
-        select(func.count(PrintArchive.id)).where(
-            PrintArchive.project_id == project_id,
-            PrintArchive.status.in_(["failed", "aborted", "cancelled", "stopped"]),
+    """Compute statistics for a project.
+
+    Aggregates from ``print_log_entries`` joined to ``print_archives`` so
+    every actual run contributes — pre-fix this counted ``print_archives``
+    (one row per file), which under-reported every reprint by collapsing
+    runs back into the source file (#1593). The Archive Print Log view
+    already drives off the same source (``archives.py::list_archives_slim``),
+    so project stats now stay aligned with the per-archive numbers.
+
+    Orphan log entries (``archive_id IS NULL`` after archive deletion via
+    ``ON DELETE SET NULL``) are excluded by the inner join — they can't
+    be attributed to a project.
+    """
+    # Per-run aggregates from print_log_entries joined on archive_id so
+    # the WHERE filters by archives.project_id. Each run's duration,
+    # filament, cost, and energy come from the log row, not the source
+    # archive — so multi-plate 3MFs and reprints both count correctly.
+    log_stats_result = await db.execute(
+        select(
+            func.count(PrintLogEntry.id).label("total_runs"),
+            func.coalesce(func.sum(PrintLogEntry.duration_seconds), 0).label("total_time"),
+            func.coalesce(func.sum(PrintLogEntry.filament_used_grams), 0).label("total_filament"),
+            func.coalesce(func.sum(PrintLogEntry.cost), 0).label("total_filament_cost"),
+            func.coalesce(func.sum(PrintLogEntry.energy_kwh), 0).label("total_energy"),
+            func.coalesce(func.sum(PrintLogEntry.energy_cost), 0).label("total_energy_cost"),
         )
         )
+        .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
+        .where(PrintArchive.project_id == project_id)
     )
     )
-    failed_prints = failed_result.scalar() or 0
+    log_stats = log_stats_result.first()
+    total_archives = int(log_stats.total_runs or 0)
 
 
-    # Sum print time, filament, and energy
-    sums_result = await db.execute(
+    # Total items the project has produced or attempted: sum of quantity
+    # per run (each run contributes its archive's quantity). The total/
+    # completed/failed splits are all per-run, not per-file.
+    items_split_result = await db.execute(
         select(
         select(
-            func.coalesce(func.sum(PrintArchive.print_time_seconds), 0).label("total_time"),
-            func.coalesce(func.sum(PrintArchive.filament_used_grams), 0).label("total_filament"),
-            func.coalesce(func.sum(PrintArchive.cost), 0).label("total_filament_cost"),
-            func.coalesce(func.sum(PrintArchive.energy_kwh), 0).label("total_energy"),
-            func.coalesce(func.sum(PrintArchive.energy_cost), 0).label("total_energy_cost"),
-        ).where(PrintArchive.project_id == project_id)
+            func.coalesce(func.sum(PrintArchive.quantity), 0).label("total_items"),
+            func.coalesce(
+                func.sum(case((PrintLogEntry.status == "completed", PrintArchive.quantity), else_=0)),
+                0,
+            ).label("completed_items"),
+            func.coalesce(
+                func.sum(case((PrintLogEntry.status.in_(_FAILURE_STATUSES), 1), else_=0)),
+                0,
+            ).label("failed_runs"),
+        )
+        .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
+        .where(PrintArchive.project_id == project_id)
     )
     )
-    sums = sums_result.first()
+    items_split = items_split_result.first()
+    total_items = int(items_split.total_items or 0)
+    completed_items = int(items_split.completed_items or 0)
+    failed_prints = int(items_split.failed_runs or 0)
 
 
     # Count queued items
     # Count queued items
     queued_result = await db.execute(
     queued_result = await db.execute(
@@ -99,15 +126,6 @@ async def compute_project_stats(
     )
     )
     in_progress_prints = in_progress_result.scalar() or 0
     in_progress_prints = in_progress_result.scalar() or 0
 
 
-    # Sum completed items (parts) - sum of quantities for actually printed jobs
-    completed_items_result = await db.execute(
-        select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
-            PrintArchive.project_id == project_id,
-            PrintArchive.status == "completed",
-        )
-    )
-    completed_items = int(completed_items_result.scalar() or 0)
-
     # Calculate progress for plates (target_count vs total_archives)
     # Calculate progress for plates (target_count vs total_archives)
     progress_percent = None
     progress_percent = None
     remaining_prints = None
     remaining_prints = None
@@ -141,13 +159,13 @@ async def compute_project_stats(
         failed_prints=int(failed_prints),
         failed_prints=int(failed_prints),
         queued_prints=queued_prints,
         queued_prints=queued_prints,
         in_progress_prints=in_progress_prints,
         in_progress_prints=in_progress_prints,
-        total_print_time_hours=round((sums.total_time or 0) / 3600, 2),
-        total_filament_grams=round(sums.total_filament or 0, 2),
+        total_print_time_hours=round((log_stats.total_time or 0) / 3600, 2),
+        total_filament_grams=round(log_stats.total_filament or 0, 2),
         progress_percent=progress_percent,
         progress_percent=progress_percent,
         parts_progress_percent=parts_progress_percent,
         parts_progress_percent=parts_progress_percent,
-        estimated_cost=round((sums.total_filament_cost or 0), 2),
-        total_energy_kwh=round((sums.total_energy or 0), 3),
-        total_energy_cost=round((sums.total_energy_cost or 0), 3),
+        estimated_cost=round((log_stats.total_filament_cost or 0), 2),
+        total_energy_kwh=round((log_stats.total_energy or 0), 3),
+        total_energy_cost=round((log_stats.total_energy_cost or 0), 3),
         remaining_prints=remaining_prints,
         remaining_prints=remaining_prints,
         remaining_parts=remaining_parts,
         remaining_parts=remaining_parts,
         bom_total_items=bom_stats.total or 0,
         bom_total_items=bom_stats.total or 0,
@@ -172,20 +190,34 @@ async def list_projects(
     result = await db.execute(query)
     result = await db.execute(query)
     projects = result.scalars().all()
     projects = result.scalars().all()
 
 
-    # Compute quick stats for each project
+    # Compute quick stats for each project. Same per-run aggregation as
+    # ``compute_project_stats`` — counts and quantities come from
+    # ``print_log_entries`` joined to ``print_archives`` so reprints and
+    # multi-plate prints contribute every run, not just the source file
+    # (#1593). Quick stats and the full stats endpoint must agree.
     response = []
     response = []
     for project in projects:
     for project in projects:
-        # Get archive count (number of print jobs)
-        archive_count_result = await db.execute(
-            select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id)
-        )
-        archive_count = archive_count_result.scalar() or 0
-
-        # Get total items (sum of quantities)
-        total_items_result = await db.execute(
-            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project.id)
+        log_quick_result = await db.execute(
+            select(
+                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)),
+                    0,
+                ).label("completed_count"),
+                func.coalesce(
+                    func.sum(case((PrintLogEntry.status.in_(_FAILURE_STATUSES), 1), else_=0)),
+                    0,
+                ).label("failed_count"),
+            )
+            .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
+            .where(PrintArchive.project_id == project.id)
         )
         )
-        total_items = int(total_items_result.scalar() or 0)
+        log_quick = log_quick_result.first()
+        archive_count = int(log_quick.archive_count or 0)
+        total_items = int(log_quick.total_items or 0)
+        completed_count = int(log_quick.completed_count or 0)
+        failed_count = int(log_quick.failed_count or 0)
 
 
         # Get queue count
         # Get queue count
         queue_count_result = await db.execute(
         queue_count_result = await db.execute(
@@ -196,24 +228,6 @@ async def list_projects(
         )
         )
         queue_count = queue_count_result.scalar() or 0
         queue_count = queue_count_result.scalar() or 0
 
 
-        # Sum completed parts (quantities) - only actually printed jobs
-        completed_result = await db.execute(
-            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
-                PrintArchive.project_id == project.id,
-                PrintArchive.status == "completed",
-            )
-        )
-        completed_count = int(completed_result.scalar() or 0)
-
-        # Sum failed parts (quantities) - includes all failure states
-        failed_result = await db.execute(
-            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
-                PrintArchive.project_id == project.id,
-                PrintArchive.status.in_(["failed", "aborted", "cancelled", "stopped"]),
-            )
-        )
-        failed_count = int(failed_result.scalar() or 0)
-
         # Plates progress: archive_count / target_count
         # Plates progress: archive_count / target_count
         progress_percent = None
         progress_percent = None
         if project.target_count and project.target_count > 0:
         if project.target_count and project.target_count > 0:

+ 9 - 4
backend/app/api/routes/system.py

@@ -19,6 +19,7 @@ from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
+from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.project import Project
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
@@ -416,18 +417,22 @@ async def get_system_info(
     failed_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed"))
     failed_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed"))
     printing_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "printing"))
     printing_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "printing"))
 
 
-    # Total print time
+    # System-wide totals aggregate per-run from ``print_log_entries`` so
+    # reprints contribute each run and multi-plate sums are pulled from the
+    # measured per-run actuals — same source the per-archive stats and the
+    # project rollup use (#1593). Pre-fix this summed ``PrintArchive`` directly,
+    # which under-reported the same way the project page did (3 reprints of
+    # one file showed as one file's worth of filament/time).
     total_print_time = (
     total_print_time = (
         await db.scalar(
         await db.scalar(
-            select(func.sum(PrintArchive.print_time_seconds)).where(PrintArchive.print_time_seconds.isnot(None))
+            select(func.sum(PrintLogEntry.duration_seconds)).where(PrintLogEntry.duration_seconds.isnot(None))
         )
         )
         or 0
         or 0
     )
     )
 
 
-    # Total filament used
     total_filament = (
     total_filament = (
         await db.scalar(
         await db.scalar(
-            select(func.sum(PrintArchive.filament_used_grams)).where(PrintArchive.filament_used_grams.isnot(None))
+            select(func.sum(PrintLogEntry.filament_used_grams)).where(PrintLogEntry.filament_used_grams.isnot(None))
         )
         )
         or 0
         or 0
     )
     )

+ 60 - 31
backend/app/services/archive.py

@@ -188,49 +188,78 @@ class ThreeMFParser:
                             self.metadata["sliced_for_model"] = normalized
                             self.metadata["sliced_for_model"] = normalized
                         break
                         break
 
 
-                # Find the plate element (single-plate exports only have one plate)
-                plate = root.find(".//plate")
-
-                if plate is not None:
-                    # Extract metadata from plate element
+                # Loop every <plate> so multi-plate exports get summed file-level
+                # totals. Pre-fix, this used `root.find(".//plate")` which
+                # returned only the first plate — file-level `print_time_seconds`
+                # / `filament_used_grams` reflected plate 1 alone, and the
+                # archive card / project rollup under-reported by the number
+                # of plates (#1593). Per-plate breakdown is still served by
+                # the dedicated `/plates` endpoint.
+                plates = root.findall(".//plate")
+                summed_time = 0
+                summed_grams = 0.0
+                any_time_seen = False
+                any_grams_seen = False
+
+                for plate in plates:
+                    # Plate-level fields that only make sense at the file
+                    # level when there's exactly one plate. ``plate_number``
+                    # / ``_plate_index`` describe which plate the export
+                    # represents — meaningless for an all-plates 3MF, so we
+                    # only record them in the single-plate case. ``bed_type``
+                    # is also single-valued; we take the first plate's value
+                    # as a best-effort default for the archive metadata.
+                    plate_index_value: int | None = None
                     for meta in plate.findall("metadata"):
                     for meta in plate.findall("metadata"):
                         key = meta.get("key")
                         key = meta.get("key")
                         value = meta.get("value")
                         value = meta.get("value")
                         if key == "index" and value:
                         if key == "index" and value:
-                            # Extract plate index - this tells us which plate was exported
                             try:
                             try:
-                                extracted_index = int(value)
-                                # Set plate_number if not already set from filename
-                                if not self.plate_number:
-                                    self.plate_number = extracted_index
-                                # Store in metadata for print_name generation
-                                self.metadata["_plate_index"] = extracted_index
+                                plate_index_value = int(value)
                             except ValueError:
                             except ValueError:
                                 pass  # Skip non-numeric plate index
                                 pass  # Skip non-numeric plate index
                         elif key == "prediction" and value:
                         elif key == "prediction" and value:
-                            self.metadata["print_time_seconds"] = int(value)
+                            try:
+                                summed_time += int(value)
+                                any_time_seen = True
+                            except ValueError:
+                                pass
                         elif key == "weight" and value:
                         elif key == "weight" and value:
-                            self.metadata["filament_used_grams"] = float(value)
-                        elif key == "curr_bed_type" and value:
-                            self.metadata["bed_type"] = value
-
-                    # Extract printable objects for skip object functionality
-                    # Objects are stored as <object identify_id="123" name="Part1" skipped="false" />
-                    printable_objects = {}
-                    for obj in plate.findall("object"):
-                        identify_id = obj.get("identify_id")
-                        name = obj.get("name")
-                        skipped = obj.get("skipped", "false")
-
-                        # Only include objects that are not pre-skipped
-                        if identify_id and name and skipped.lower() != "true":
                             try:
                             try:
-                                printable_objects[int(identify_id)] = name
+                                summed_grams += float(value)
+                                any_grams_seen = True
                             except ValueError:
                             except ValueError:
-                                pass  # Skip objects with non-numeric identify_id
+                                pass
+                        elif key == "curr_bed_type" and value and "bed_type" not in self.metadata:
+                            self.metadata["bed_type"] = value
 
 
-                    if printable_objects:
-                        self.metadata["printable_objects"] = printable_objects
+                    # Per-plate object lists are only kept at the file level
+                    # when there's one plate — the skip-object affordance
+                    # operates on the plate being printed, which is the
+                    # `/plates` endpoint's job for multi-plate exports.
+                    if len(plates) == 1:
+                        if plate_index_value is not None:
+                            if not self.plate_number:
+                                self.plate_number = plate_index_value
+                            self.metadata["_plate_index"] = plate_index_value
+
+                        printable_objects: dict[int, str] = {}
+                        for obj in plate.findall("object"):
+                            identify_id = obj.get("identify_id")
+                            name = obj.get("name")
+                            skipped = obj.get("skipped", "false")
+                            if identify_id and name and skipped.lower() != "true":
+                                try:
+                                    printable_objects[int(identify_id)] = name
+                                except ValueError:
+                                    pass  # Skip objects with non-numeric identify_id
+                        if printable_objects:
+                            self.metadata["printable_objects"] = printable_objects
+
+                if any_time_seen:
+                    self.metadata["print_time_seconds"] = summed_time
+                if any_grams_seen:
+                    self.metadata["filament_used_grams"] = round(summed_grams, 2)
 
 
                 # Get filament info from filaments ACTUALLY USED in the print
                 # Get filament info from filaments ACTUALLY USED in the print
                 # slice_info has <filament id="1" type="PLA" color="#FFFFFF" used_g="100" />
                 # slice_info has <filament id="1" type="PLA" color="#FFFFFF" used_g="100" />

+ 232 - 6
backend/tests/integration/test_projects_api.py

@@ -287,10 +287,19 @@ class TestProjectPartsTracking:
 
 
     @pytest.fixture
     @pytest.fixture
     async def archive_factory(self, db_session):
     async def archive_factory(self, db_session):
-        """Factory to create test archives."""
+        """Factory to create a test archive plus a matching PrintLogEntry.
+
+        Project stats aggregate from ``print_log_entries`` (#1593), so a
+        test that only writes archives wouldn't exercise the production
+        path — production always writes one log entry per run. The
+        factory mirrors that: every archive whose status is anything other
+        than ``"archived"`` (file shelved without printing) gets a log
+        entry whose status matches the archive.
+        """
 
 
         async def _create_archive(**kwargs):
         async def _create_archive(**kwargs):
             from backend.app.models.archive import PrintArchive
             from backend.app.models.archive import PrintArchive
+            from backend.app.models.print_log import PrintLogEntry
 
 
             defaults = {
             defaults = {
                 "filename": "test.3mf",
                 "filename": "test.3mf",
@@ -306,6 +315,16 @@ class TestProjectPartsTracking:
             db_session.add(archive)
             db_session.add(archive)
             await db_session.commit()
             await db_session.commit()
             await db_session.refresh(archive)
             await db_session.refresh(archive)
+
+            if archive.status != "archived":
+                db_session.add(
+                    PrintLogEntry(
+                        archive_id=archive.id,
+                        print_name=archive.print_name,
+                        status=archive.status,
+                    )
+                )
+                await db_session.commit()
             return archive
             return archive
 
 
         return _create_archive
         return _create_archive
@@ -436,10 +455,12 @@ class TestProjectArchivedStatusNotCounted:
 
 
     @pytest.fixture
     @pytest.fixture
     async def archive_factory(self, db_session):
     async def archive_factory(self, db_session):
-        """Factory to create test archives."""
+        """Factory to create a test archive plus a matching PrintLogEntry —
+        see TestProjectPartsTracking.archive_factory for rationale (#1593)."""
 
 
         async def _create_archive(**kwargs):
         async def _create_archive(**kwargs):
             from backend.app.models.archive import PrintArchive
             from backend.app.models.archive import PrintArchive
+            from backend.app.models.print_log import PrintLogEntry
 
 
             defaults = {
             defaults = {
                 "filename": "test.3mf",
                 "filename": "test.3mf",
@@ -455,6 +476,16 @@ class TestProjectArchivedStatusNotCounted:
             db_session.add(archive)
             db_session.add(archive)
             await db_session.commit()
             await db_session.commit()
             await db_session.refresh(archive)
             await db_session.refresh(archive)
+
+            if archive.status != "archived":
+                db_session.add(
+                    PrintLogEntry(
+                        archive_id=archive.id,
+                        print_name=archive.print_name,
+                        status=archive.status,
+                    )
+                )
+                await db_session.commit()
             return archive
             return archive
 
 
         return _create_archive
         return _create_archive
@@ -499,7 +530,10 @@ class TestProjectArchivedStatusNotCounted:
         our_project = next((p for p in data if p["name"] == "List Archived Test"), None)
         our_project = next((p for p in data if p["name"] == "List Archived Test"), None)
         assert our_project is not None
         assert our_project is not None
         assert our_project["completed_count"] == 4  # Only completed, not archived
         assert our_project["completed_count"] == 4  # Only completed, not archived
-        assert our_project["archive_count"] == 2  # Both archives exist as plates
+        # Post-#1593: archive_count is "print runs", not "files attached". An
+        # ``archived``-status file (shelved without printing) has no
+        # PrintLogEntry and doesn't count — only the actual printed run does.
+        assert our_project["archive_count"] == 1
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -519,9 +553,201 @@ class TestProjectArchivedStatusNotCounted:
         data = response.json()
         data = response.json()
 
 
         assert data["stats"]["completed_prints"] == 10  # Only "completed"
         assert data["stats"]["completed_prints"] == 10  # Only "completed"
-        assert data["stats"]["failed_prints"] == 2  # failed + aborted (count of archives, not sum)
-        assert data["stats"]["total_archives"] == 4  # All archives
-        assert data["stats"]["total_items"] == 20  # Sum of all quantities
+        assert data["stats"]["failed_prints"] == 2  # failed + aborted (count of runs)
+        # Post-#1593: total_archives counts runs from print_log_entries, not
+        # files. The ``archived`` row is a shelved file with no run, so it
+        # contributes 0; the other three (completed, failed, aborted) each
+        # produced a run.
+        assert data["stats"]["total_archives"] == 3
+        # total_items sums quantity per run: 10 (completed) + 3 (failed) + 2 (aborted) = 15
+        assert data["stats"]["total_items"] == 15
+
+
+class TestProjectStatsPerRun:
+    """Project stats aggregate per-run from ``print_log_entries`` so
+    reprints and multi-plate prints count every run (#1593). Pre-fix the
+    stats counted ``print_archives`` (one row per file), so 3 reprints of
+    one file showed as 1 job with plate-1-only filament/time/cost.
+    """
+
+    @pytest.fixture
+    async def project_factory(self, db_session):
+        async def _create_project(**kwargs):
+            from backend.app.models.project import Project
+
+            defaults = {"name": "Per-Run Stats Project", "color": "#FF0000"}
+            defaults.update(kwargs)
+            project = Project(**defaults)
+            db_session.add(project)
+            await db_session.commit()
+            await db_session.refresh(project)
+            return project
+
+        return _create_project
+
+    @pytest.fixture
+    async def archive_with_runs(self, db_session):
+        """Build a single archive + N PrintLogEntry rows.
+
+        Models the reporter's case: one source file (archive) is reprinted
+        N times, each run with its own duration / filament / cost.
+        """
+
+        async def _create(*, project_id: int, runs: list[dict], archive_status: str = "completed", quantity: int = 1):
+            from backend.app.models.archive import PrintArchive
+            from backend.app.models.print_log import PrintLogEntry
+
+            archive = PrintArchive(
+                filename="reprinted.3mf",
+                file_path="test/reprinted.3mf",
+                file_size=1000,
+                print_name="Reprinted Print",
+                status=archive_status,
+                quantity=quantity,
+                project_id=project_id,
+            )
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+
+            for run in runs:
+                db_session.add(
+                    PrintLogEntry(
+                        archive_id=archive.id,
+                        print_name=archive.print_name,
+                        status=run.get("status", "completed"),
+                        duration_seconds=run.get("duration_seconds"),
+                        filament_used_grams=run.get("filament_used_grams"),
+                        cost=run.get("cost"),
+                        energy_kwh=run.get("energy_kwh"),
+                        energy_cost=run.get("energy_cost"),
+                    )
+                )
+            await db_session.commit()
+            return archive
+
+        return _create
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_three_reprints_count_as_three_jobs_with_summed_totals(
+        self, async_client: AsyncClient, project_factory, archive_with_runs
+    ):
+        """Reporter's case: 3 runs of one multi-plate file should report
+        3 jobs and summed time / filament / cost — pre-fix it reported 1
+        job with plate-1-only totals."""
+        project = await project_factory()
+        await archive_with_runs(
+            project_id=project.id,
+            runs=[
+                {"duration_seconds": 7140, "filament_used_grams": 19.2, "cost": 0.40},
+                {"duration_seconds": 6000, "filament_used_grams": 20.0, "cost": 0.40},
+                {"duration_seconds": 6300, "filament_used_grams": 18.8, "cost": 0.40},
+            ],
+        )
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}")
+        assert response.status_code == 200
+        stats = response.json()["stats"]
+
+        assert stats["total_archives"] == 3, "3 runs must show as 3 jobs"
+        assert stats["completed_prints"] == 3, "Each run with quantity=1 contributes 1 part"
+        assert stats["total_filament_grams"] == round(19.2 + 20.0 + 18.8, 2)
+        assert stats["total_print_time_hours"] == round((7140 + 6000 + 6300) / 3600, 2)
+        # Cost rounds at 2 decimals — 3 * 0.40 = 1.20
+        assert stats["estimated_cost"] == 1.20
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_orphan_log_entries_do_not_bleed_into_projects(
+        self, async_client: AsyncClient, project_factory, db_session
+    ):
+        """Log rows whose ``archive_id`` is NULL (archive deleted via
+        ON DELETE SET NULL) must not leak into any project — the inner
+        join filters them out by construction."""
+        from backend.app.models.print_log import PrintLogEntry
+
+        project = await project_factory()
+
+        # Orphan log entries — no archive_id.
+        for _ in range(5):
+            db_session.add(
+                PrintLogEntry(
+                    archive_id=None,
+                    print_name="Orphan Run",
+                    status="completed",
+                    duration_seconds=3600,
+                    filament_used_grams=20.0,
+                    cost=0.5,
+                )
+            )
+        await db_session.commit()
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}")
+        assert response.status_code == 200
+        stats = response.json()["stats"]
+
+        # None of the orphan rows are attributable to this project.
+        assert stats["total_archives"] == 0
+        assert stats["completed_prints"] == 0
+        assert stats["total_filament_grams"] == 0
+        assert stats["total_print_time_hours"] == 0
+        assert stats["estimated_cost"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_mixed_run_outcomes_split_completed_and_failed(
+        self, async_client: AsyncClient, project_factory, archive_with_runs
+    ):
+        """A multi-run archive with mixed outcomes splits cleanly between
+        completed_prints (per-quantity) and failed_prints (per-run)."""
+        project = await project_factory()
+        await archive_with_runs(
+            project_id=project.id,
+            quantity=2,
+            runs=[
+                {"status": "completed", "filament_used_grams": 30.0},
+                {"status": "completed", "filament_used_grams": 30.0},
+                {"status": "failed", "filament_used_grams": 5.0},
+                {"status": "aborted", "filament_used_grams": 2.0},
+            ],
+        )
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}")
+        stats = response.json()["stats"]
+
+        assert stats["total_archives"] == 4
+        # 2 completed runs × quantity=2 each = 4 parts
+        assert stats["completed_prints"] == 4
+        # 2 failure runs (failed + aborted) count as 2, not 2*quantity
+        assert stats["failed_prints"] == 2
+        # All 4 runs contribute filament: 30 + 30 + 5 + 2 = 67
+        assert stats["total_filament_grams"] == 67.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_quick_stats_in_list_view_agree_with_per_project_stats(
+        self, async_client: AsyncClient, project_factory, archive_with_runs
+    ):
+        """The /projects list view's quick stats must agree with
+        /projects/{id}'s detailed stats — both come from the same per-run
+        aggregation."""
+        project = await project_factory(name="Quick-Stats Alignment")
+        await archive_with_runs(
+            project_id=project.id,
+            quantity=1,
+            runs=[
+                {"status": "completed"},
+                {"status": "completed"},
+                {"status": "failed"},
+            ],
+        )
+
+        list_resp = await async_client.get("/api/v1/projects/")
+        ours = next(p for p in list_resp.json() if p["name"] == "Quick-Stats Alignment")
+        assert ours["archive_count"] == 3
+        assert ours["completed_count"] == 2
+        assert ours["failed_count"] == 1
 
 
 
 
 class TestProjectArchivesAPI:
 class TestProjectArchivesAPI:

+ 25 - 3
backend/tests/integration/test_system_api.py

@@ -219,10 +219,32 @@ class TestSystemAPI:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_system_info_with_archives(self, async_client: AsyncClient, printer_factory, archive_factory):
     async def test_system_info_with_archives(self, async_client: AsyncClient, printer_factory, archive_factory):
-        """Verify database stats include archive counts."""
+        """Verify database stats include archive counts.
+
+        Post-#1593 `total_print_time_seconds` is summed from
+        `PrintLogEntry.duration_seconds` (the *actual* per-run duration),
+        not `PrintArchive.print_time_seconds` (the slicer estimate). The
+        archive_factory derives the run's duration from
+        ``completed_at - started_at`` on the archive, so the test sets
+        those so each run carries a duration the system route can sum.
+        """
+        from datetime import datetime, timezone
+
         printer = await printer_factory()
         printer = await printer_factory()
-        await archive_factory(printer.id, status="completed", print_time_seconds=3600)
-        await archive_factory(printer.id, status="failed", print_time_seconds=1800)
+        await archive_factory(
+            printer.id,
+            status="completed",
+            print_time_seconds=3600,
+            started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
+            completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
+        )
+        await archive_factory(
+            printer.id,
+            status="failed",
+            print_time_seconds=1800,
+            started_at=datetime(2026, 5, 2, 10, 0, tzinfo=timezone.utc),
+            completed_at=datetime(2026, 5, 2, 10, 30, tzinfo=timezone.utc),
+        )
 
 
         with (
         with (
             patch("backend.app.api.routes.system.psutil") as mock_psutil,
             patch("backend.app.api.routes.system.psutil") as mock_psutil,

+ 146 - 0
backend/tests/unit/services/test_archive_service.py

@@ -729,3 +729,149 @@ class TestGcodeHeaderFilamentUsage:
         meta = ThreeMFParser(self._make_3mf("; total layer number: 10\n")).parse()
         meta = ThreeMFParser(self._make_3mf("; total layer number: 10\n")).parse()
         assert "filament_used_grams" not in meta
         assert "filament_used_grams" not in meta
         assert "filament_used_mm" not in meta
         assert "filament_used_mm" not in meta
+
+
+class TestMultiPlateSliceInfoSum:
+    """Multi-plate ``.gcode.3mf`` exports must produce file-level totals that
+    are the SUM of every plate's prediction + weight, not plate-1 only.
+
+    Pre-fix the parser used ``root.find(".//plate")`` and only read the
+    first plate's metadata, so the archive card and project rollup
+    under-reported by roughly the number of plates (#1593).
+    """
+
+    @staticmethod
+    def _make_3mf_with_slice_info(slice_info_xml: str) -> str:
+        """Write a minimal .3mf with the given slice_info.config payload.
+
+        Bambu Studio's slice_info.config is the file the parser reads for
+        file-level `prediction` / `weight`; the rest of the 3MF members
+        aren't required for this test.
+        """
+        import os
+        import tempfile
+        import zipfile
+
+        fd, path = tempfile.mkstemp(suffix=".3mf")
+        os.close(fd)
+        with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            zf.writestr("Metadata/slice_info.config", slice_info_xml)
+        return path
+
+    def test_three_plate_file_sums_prediction_and_weight(self):
+        """The reporter's case: three plates with distinct prediction +
+        weight values must yield file-level totals that are the sum.
+        """
+        from backend.app.services.archive import ThreeMFParser
+
+        slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="index" value="1" />
+                <metadata key="prediction" value="7140" />
+                <metadata key="weight" value="19.2" />
+            </plate>
+            <plate>
+                <metadata key="index" value="2" />
+                <metadata key="prediction" value="6000" />
+                <metadata key="weight" value="20.0" />
+            </plate>
+            <plate>
+                <metadata key="index" value="3" />
+                <metadata key="prediction" value="6300" />
+                <metadata key="weight" value="18.8" />
+            </plate>
+        </config>
+        """
+        parser = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml))
+        meta = parser.parse()
+        assert meta["print_time_seconds"] == 7140 + 6000 + 6300  # 19440
+        assert meta["filament_used_grams"] == round(19.2 + 20.0 + 18.8, 2)  # 58.0
+        # Multi-plate file: no single plate index should be claimed at the
+        # file level — the archive represents all plates, not a specific one.
+        assert parser.plate_number is None
+
+    def test_single_plate_file_preserves_plate_index_and_objects(self):
+        """The single-plate path must still set ``_plate_index`` and pick
+        up printable objects — these only make sense when the archive
+        represents exactly one plate.
+        """
+        from backend.app.services.archive import ThreeMFParser
+
+        slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="index" value="2" />
+                <metadata key="prediction" value="3600" />
+                <metadata key="weight" value="50.5" />
+                <metadata key="curr_bed_type" value="textured_pei" />
+                <object identify_id="1" name="Part_A" skipped="false" />
+                <object identify_id="2" name="Part_B" skipped="true" />
+            </plate>
+        </config>
+        """
+        parser = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml))
+        meta = parser.parse()
+        assert meta["print_time_seconds"] == 3600
+        assert meta["filament_used_grams"] == 50.5
+        # Single-plate exports surface the plate index via ``plate_number``
+        # (``_plate_index`` is an internal key cleared at the end of parse).
+        assert parser.plate_number == 2
+        assert meta["bed_type"] == "textured_pei"
+        assert meta["printable_objects"] == {1: "Part_A"}
+
+    def test_multi_plate_ignores_per_plate_objects(self):
+        """Multi-plate exports must NOT carry a single plate's objects at
+        the file level — the ``/plates`` endpoint surfaces them per-plate.
+        Conflating them would attach plate-1's parts to the whole archive.
+        """
+        from backend.app.services.archive import ThreeMFParser
+
+        slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="index" value="1" />
+                <metadata key="prediction" value="1000" />
+                <metadata key="weight" value="10.0" />
+                <object identify_id="1" name="Part_A" skipped="false" />
+            </plate>
+            <plate>
+                <metadata key="index" value="2" />
+                <metadata key="prediction" value="1500" />
+                <metadata key="weight" value="15.0" />
+                <object identify_id="2" name="Part_B" skipped="false" />
+            </plate>
+        </config>
+        """
+        parser = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml))
+        meta = parser.parse()
+        assert meta["print_time_seconds"] == 2500
+        assert meta["filament_used_grams"] == 25.0
+        # No archive-level object list when there's more than one plate.
+        assert "printable_objects" not in meta
+        assert parser.plate_number is None
+
+    def test_missing_or_malformed_values_are_skipped(self):
+        """A plate with a malformed prediction/weight string must skip
+        that field, not poison the sum or raise — defensive parsing was
+        already present per-field; the sum loop must preserve it.
+        """
+        from backend.app.services.archive import ThreeMFParser
+
+        slice_info_xml = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="prediction" value="100" />
+                <metadata key="weight" value="not-a-number" />
+            </plate>
+            <plate>
+                <metadata key="prediction" value="200" />
+                <metadata key="weight" value="5.0" />
+            </plate>
+        </config>
+        """
+        meta = ThreeMFParser(self._make_3mf_with_slice_info(slice_info_xml)).parse()
+        assert meta["print_time_seconds"] == 300
+        # Only the second plate's weight contributed.
+        assert meta["filament_used_grams"] == 5.0

+ 67 - 0
backend/tests/unit/test_archive_run_aggregation.py

@@ -245,3 +245,70 @@ async def test_soft_delete_keeps_runs_for_stats(
     stats = (await async_client.get("/api/v1/archives/stats")).json()
     stats = (await async_client.get("/api/v1/archives/stats")).json()
     assert stats["total_prints"] >= 1
     assert stats["total_prints"] >= 1
     assert stats["total_filament_grams"] >= 75.0
     assert stats["total_filament_grams"] >= 75.0
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_time_accuracy_excludes_multi_plate_plate_by_plate_outliers(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """Per-run accuracy clamps to a plausible 50%-200% band so multi-plate
+    archives printed plate-by-plate don't poison the printer-level average.
+
+    Pre-#1593 the parser stored plate-1-only time in
+    ``PrintArchive.print_time_seconds``, so a plate-by-plate run produced a
+    near-100% ratio by accident. Post-#1593 the field is the sum across
+    plates, so each plate-by-plate run produces estimate/actual = N×100%
+    for an N-plate file. Without the band filter a single 3-plate file
+    printed plate-by-plate would drag the printer's accuracy reading to
+    ~300%, which is pure noise. The metric is designed for the
+    single-plate-file case and should reflect real slicer drift there.
+    """
+    printer = await printer_factory()
+
+    # Archive 1: single-plate file. Estimate 3600s, actual 3700s
+    # → ratio 97.3% (well within band).
+    single = await archive_factory(
+        printer.id,
+        print_time_seconds=3600,
+        with_run=False,
+    )
+    db_session.add(
+        PrintLogEntry(
+            archive_id=single.id,
+            printer_id=printer.id,
+            status="completed",
+            duration_seconds=3700,
+        )
+    )
+
+    # Archive 2: multi-plate file (3 plates totaling 18000s). Two runs
+    # printed plate-by-plate at ~6000s each — ratio 18000/6000 = 300%.
+    # Both must be filtered out so the printer average stays at the
+    # single-plate file's 97.3% reading.
+    multi = await archive_factory(
+        printer.id,
+        print_time_seconds=18000,
+        with_run=False,
+    )
+    db_session.add(
+        PrintLogEntry(
+            archive_id=multi.id,
+            printer_id=printer.id,
+            status="completed",
+            duration_seconds=6000,
+        )
+    )
+    db_session.add(
+        PrintLogEntry(
+            archive_id=multi.id,
+            printer_id=printer.id,
+            status="completed",
+            duration_seconds=6100,
+        )
+    )
+    await db_session.commit()
+
+    body = (await async_client.get("/api/v1/archives/stats")).json()
+    assert body["average_time_accuracy"] == pytest.approx(97.3, abs=0.1)
+    assert body["time_accuracy_by_printer"][str(printer.id)] == pytest.approx(97.3, abs=0.1)

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików