Parcourir la source

feat(projects): per-file print progress and complete-sets tracking (#1897)

Projects made of many distinct files that each need N prints (e.g. 13
plates x 10 sets = 130 prints) only had aggregate progress. Finding out
"how many times have I printed plate_7?" meant reading the Activity
Timeline line by line, unusable at 130 events.

Projects now take an optional Copies per File target. Every printable
file in the project's linked folders shows an X / N badge with a mini
progress bar (gray not started, amber in progress, green done), and the
progress card gains a Complete Sets bar - the minimum per-file count,
i.e. how many finished assemblies can be shipped right now. Without the
target, printable files show a plain printed-count badge.

Counting matches the aggregate project stats: completed runs only,
served by a new /projects/{id}/file-progress endpoint. Runs attribute
to a file via a new library_file_id stamp on queue-dispatched archives,
falling back to content hash and then filename for historical rows.

Also fixed: files queued from a project-linked File Manager folder now
inherit that project, so their prints count toward project statistics -
previously only prints started from the project page were attributed.

Test-harness fix along the way: the test suite's get_db override never
committed, unlike production get_db, so endpoints relying on the
request-scoped commit silently lost their writes in tests. The override
now mirrors production commit/rollback semantics.
maziggy il y a 1 mois
Parent
commit
af7874546a

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Added
+- **Per-file print progress inside a project (#1897, reporter @FedericoPuntelli)** — Projects that consist of many distinct files each needing N prints (e.g. 13 plates × 10 sets = 130 prints) only had aggregate progress; finding out "how many times have I printed plate_7?" meant reading the Activity Timeline line by line. Projects now take an optional **Copies per File** target: each printable file in the project's linked folders shows an **X / N** badge with a mini progress bar (gray not started, amber in progress, green target reached), and the progress card gains a **Complete Sets** bar — the minimum per-file count, i.e. how many finished assemblies you can ship right now. Without the new target, files simply show a printed-count badge (3×). Counting matches the aggregate stats: only completed runs, attributed to a file by a new `library_file_id` stamp on queue-dispatched archives, with content-hash and filename fallbacks covering historical prints. Also fixed along the way: **files queued from a project-linked File Manager folder now attribute their prints to that project** — previously only prints started from the project page counted toward project statistics. Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **Users can now delete empty folders in the File Manager (#1781, reporter @cadtoolbox)** — Library folders have no ownership tracking, so folder deletion was gated entirely behind `library:delete_all` — a regular user with `library:delete_own` could create folders and delete their own files, but the emptied folder sat there until an admin removed it. Users with `library:delete_own` can now delete folders that are truly empty: no subfolders, no files — including trashed ones, since deleting a folder would silently drop another user's trash-restorable files. External folders (operator-configured mounts) and folders linked to a project or archive still require `library:delete_all`, even when empty. The folder tree's Delete entry enables accordingly, with a "You can only delete empty folders" tooltip on non-empty ones; the bulk-delete API applies the same rule. Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **AI failure detection is now visible on the printer cards (#1546, reporter @Jeff-GebhartCA)** — Previously the live Obico classification (safe / warning / failure, smoothed score) was only visible under Settings → Failure Detection, so tracking how detection matched an ongoing print meant flipping between the Printers screen and Settings. Each printer card's badge row now shows an AI badge whenever detection is enabled for that printer, like the other health badges: gray **Idle** while no print is being watched, then green **Safe**, amber **Warning**, or red **Failure** while a print is actively monitored. The tooltip carries the current score, and clicking opens a modal (like the HMS error badge) with the live status, score, frames analyzed, and the detection service's last error — plus a shortcut to the full settings. Toggling detection on or off updates the cards immediately. Printers excluded from monitoring and setups without failure detection show nothing. Served by a new lightweight `/obico/printer-status` endpoint readable with printer permissions alone (the existing settings-gated endpoint is unchanged and keeps configuration private). Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **Bark is now a notification provider (#1495)** — [Bark](https://github.com/Finb/Bark) is the open-source, account-free iOS push app (self-hostable via bark-server), popular especially with Chinese-speaking users. Configure it with just the device key from the app; the server URL defaults to the official `api.day.app` relay and accepts a self-hosted instance. Optional settings: notification **Group**, **Sound**, and iOS **Interruption Level** — Time Sensitive breaks through scheduled summaries, Critical bypasses Silent mode and Focus (useful for print-failure alerts), Passive delivers silently. Send failures wrapped in an HTTP 200 body by bark-server are detected and reported properly. Translated in all locales; wiki updated. Covered by backend and frontend tests.

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

@@ -2659,6 +2659,17 @@ async def add_files_to_queue(
     result = await db.execute(LibraryFile.active().where(LibraryFile.id.in_(request.file_ids)))
     files = {f.id: f for f in result.scalars().all()}
 
+    # Project attribution (#1897): a file queued from a project-linked folder
+    # inherits that project, so the resulting archive counts toward the
+    # project's progress. A file's own project link wins over its folder's.
+    folder_ids = {f.folder_id for f in files.values() if f.folder_id is not None}
+    folder_projects: dict[int, int | None] = {}
+    if folder_ids:
+        folder_result = await db.execute(
+            select(LibraryFolder.id, LibraryFolder.project_id).where(LibraryFolder.id.in_(folder_ids))
+        )
+        folder_projects = dict(folder_result.all())
+
     # Get max position for queue ordering
     pos_result = await db.execute(select(func.coalesce(func.max(PrintQueueItem.position), 0)))
     max_position = pos_result.scalar() or 0
@@ -2696,6 +2707,8 @@ async def add_files_to_queue(
             queue_item = PrintQueueItem(
                 printer_id=None,  # Unassigned
                 library_file_id=file_id,
+                project_id=lib_file.project_id
+                or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None),
                 position=max_position,
                 status="pending",
             )

+ 90 - 0
backend/app/api/routes/projects.py

@@ -34,6 +34,7 @@ from backend.app.schemas.project import (
     BOMItemUpdate,
     ProjectChildPreview,
     ProjectCreate,
+    ProjectFileProgress,
     ProjectImport,
     ProjectListResponse,
     ProjectResponse,
@@ -262,6 +263,7 @@ async def list_projects(
                 status=project.status,
                 target_count=project.target_count,
                 target_parts_count=project.target_parts_count,
+                target_sets=project.target_sets,
                 budget=project.budget,
                 tags=project.tags,
                 due_date=project.due_date,
@@ -304,6 +306,7 @@ async def create_project(
         color=data.color,
         target_count=data.target_count,
         target_parts_count=data.target_parts_count,
+        target_sets=data.target_sets,
         notes=data.notes,
         tags=data.tags,
         due_date=data.due_date,
@@ -326,6 +329,7 @@ async def create_project(
         status=project.status,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         attachments=project.attachments,
         url=project.url,
@@ -374,6 +378,7 @@ async def list_templates(
                 status=project.status,
                 target_count=project.target_count,
                 target_parts_count=project.target_parts_count,
+                target_sets=project.target_sets,
                 budget=project.budget,
                 tags=project.tags,
                 due_date=project.due_date,
@@ -415,6 +420,7 @@ async def create_project_from_template(
         color=template.color,
         target_count=template.target_count,
         target_parts_count=template.target_parts_count,
+        target_sets=template.target_sets,
         notes=template.notes,
         tags=template.tags,
         priority=template.priority,
@@ -457,6 +463,7 @@ async def create_project_from_template(
         status=project.status,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         attachments=project.attachments,
         url=project.url,
@@ -542,6 +549,7 @@ async def get_project(
         status=project.status,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         attachments=project.attachments,
         url=project.url,
@@ -590,6 +598,10 @@ async def update_project(
         project.target_count = data.target_count
     if data.target_parts_count is not None:
         project.target_parts_count = data.target_parts_count
+    # Sent-but-null clears the copies-per-file target (#1897); omitted leaves it
+    # alone (same #2536 semantics as tags/due_date below).
+    if "target_sets" in data.model_fields_set:
+        project.target_sets = data.target_sets
     if data.notes is not None:
         project.notes = data.notes
     # Sent-but-null clears the field; omitted leaves it alone. Guarding on
@@ -642,6 +654,7 @@ async def update_project(
         status=project.status,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         attachments=project.attachments,
         url=project.url,
@@ -740,6 +753,76 @@ async def list_project_queue(
     return items
 
 
+@router.get("/{project_id}/file-progress", response_model=list[ProjectFileProgress])
+async def get_project_file_progress(
+    project_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
+):
+    """Completed-run counts per library file inside a project (#1897).
+
+    Counts completed ``PrintLogEntry`` rows (same source as the aggregate
+    project stats) of archives attributed to this project, and maps each run to
+    one of the project's library files — the files living in folders linked to
+    the project, the same set the project detail page renders.
+
+    A run is attributed to exactly one file, by the strongest available match:
+    1. ``archive.library_file_id`` (stamped at queue dispatch since #1897),
+    2. content hash (covers historical rows),
+    3. filename (covers hash drift, e.g. re-sliced uploads of the same name).
+    Files with no completed runs are omitted — the frontend treats absence as 0.
+    """
+    result = await db.execute(select(Project.id).where(Project.id == project_id))
+    if result.scalar_one_or_none() is None:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    files_result = await db.execute(
+        select(LibraryFile.id, LibraryFile.file_hash, LibraryFile.filename)
+        .join(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id)
+        .where(LibraryFolder.project_id == project_id, LibraryFile.deleted_at.is_(None))
+    )
+    file_rows = files_result.all()
+    if not file_rows:
+        return []
+
+    # First match wins within each tier, so iteration order (file id) is stable
+    # when duplicates share a hash or filename.
+    by_id = {fid for fid, _, _ in file_rows}
+    by_hash: dict[str, int] = {}
+    by_name: dict[str, int] = {}
+    for fid, fhash, fname in file_rows:
+        if fhash and fhash not in by_hash:
+            by_hash[fhash] = fid
+        if fname not in by_name:
+            by_name[fname] = fid
+
+    runs_result = await db.execute(
+        select(
+            PrintArchive.library_file_id,
+            PrintArchive.content_hash,
+            PrintArchive.filename,
+            func.count(PrintLogEntry.id),
+        )
+        .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
+        .where(PrintArchive.project_id == project_id, PrintLogEntry.status == "completed")
+        .group_by(PrintArchive.library_file_id, PrintArchive.content_hash, PrintArchive.filename)
+    )
+
+    counts: dict[int, int] = {}
+    for lib_file_id, content_hash, filename, run_count in runs_result.all():
+        if lib_file_id in by_id:
+            fid = lib_file_id
+        elif content_hash and content_hash in by_hash:
+            fid = by_hash[content_hash]
+        elif filename in by_name:
+            fid = by_name[filename]
+        else:
+            continue
+        counts[fid] = counts.get(fid, 0) + run_count
+
+    return [ProjectFileProgress(file_id=fid, completed_count=n) for fid, n in sorted(counts.items())]
+
+
 @router.post("/{project_id}/add-archives")
 async def add_archives_to_project(
     project_id: int,
@@ -1402,6 +1485,7 @@ async def create_template_from_project(
         color=source.color,
         target_count=source.target_count,
         target_parts_count=source.target_parts_count,
+        target_sets=source.target_sets,
         notes=source.notes,
         tags=source.tags,
         priority=source.priority,
@@ -1444,6 +1528,7 @@ async def create_template_from_project(
         status=template.status,
         target_count=template.target_count,
         target_parts_count=template.target_parts_count,
+        target_sets=template.target_sets,
         notes=template.notes,
         attachments=template.attachments,
         url=template.url,
@@ -1653,6 +1738,7 @@ async def export_project(
         "status": project.status,
         "target_count": project.target_count,
         "target_parts_count": project.target_parts_count,
+        "target_sets": project.target_sets,
         "notes": project.notes,
         "tags": project.tags,
         "due_date": project.due_date.isoformat() if project.due_date else None,
@@ -1704,6 +1790,7 @@ async def import_project(
         status=data.status,
         target_count=data.target_count,
         target_parts_count=data.target_parts_count,
+        target_sets=data.target_sets,
         notes=data.notes,
         tags=data.tags,
         due_date=data.due_date,
@@ -1766,6 +1853,7 @@ async def import_project(
         status=project.status,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         attachments=project.attachments,
         url=project.url,
@@ -1829,6 +1917,7 @@ async def import_project_file(
         status=data.get("status", "active"),
         target_count=data.get("target_count"),
         target_parts_count=data.get("target_parts_count"),
+        target_sets=data.get("target_sets"),
         notes=data.get("notes"),
         tags=data.get("tags"),
         due_date=datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None,
@@ -1957,6 +2046,7 @@ async def import_project_file(
         status=project.status,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         attachments=project.attachments,
         url=project.url,

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

@@ -3787,6 +3787,17 @@ async def run_migrations(conn):
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     await _migrate_rename_user_print_template_names(conn)
 
+    # Migration: per-file print progress inside a project (#1897).
+    # - print_archives.library_file_id: which library file a queued run was
+    #   dispatched from; nullable, no FK constraint added to existing tables
+    #   (SQLite can't ADD CONSTRAINT; the application uses SET NULL semantics
+    #   via the ORM on fresh installs and tolerates dangling ids by matching
+    #   hash/filename as fallback anyway).
+    # - projects.target_sets: optional copies-per-file target. INTEGER is
+    #   spelled identically on SQLite and Postgres — no dialect branch.
+    await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN library_file_id INTEGER")
+    await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_sets INTEGER")
+
 
 _USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
     ("user_print_start", "User Print Started", "User Print Started Email"),

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

@@ -12,6 +12,12 @@ class PrintArchive(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True)
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
+    # Which library file this run was dispatched from (#1897). Set by the queue
+    # scheduler when it archives a library-file print; older rows are matched by
+    # content_hash/filename instead. SET NULL so deleting a file keeps history.
+    library_file_id: Mapped[int | None] = mapped_column(
+        ForeignKey("library_files.id", ondelete="SET NULL"), nullable=True
+    )
 
     # File info
     filename: Mapped[str] = mapped_column(String(255))

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

@@ -30,6 +30,9 @@ class Project(Base):
     target_parts_count: Mapped[int | None] = mapped_column(
         Integer, nullable=True
     )  # Optional target number of parts/objects
+    # Optional copies-per-file target (#1897): every printable file in the
+    # project's linked folders should be printed this many times ("sets").
+    target_sets: Mapped[int | None] = mapped_column(Integer, nullable=True)
 
     # Phase 2: Rich text notes (HTML from WYSIWYG editor)
     notes: Mapped[str | None] = mapped_column(Text, nullable=True)

+ 13 - 0
backend/app/schemas/project.py

@@ -26,6 +26,7 @@ class ProjectCreate(BaseModel):
     color: str | None = None
     target_count: int | None = None
     target_parts_count: int | None = None
+    target_sets: int | None = None  # Copies-per-file target (#1897)
     notes: str | None = None
     tags: str | None = None
     due_date: datetime | None = None
@@ -49,6 +50,7 @@ class ProjectUpdate(BaseModel):
     status: str | None = None  # active, completed, archived
     target_count: int | None = None
     target_parts_count: int | None = None
+    target_sets: int | None = None  # Copies-per-file target (#1897)
     notes: str | None = None
     tags: str | None = None
     due_date: datetime | None = None
@@ -108,6 +110,7 @@ class ProjectResponse(BaseModel):
     status: str
     target_count: int | None
     target_parts_count: int | None = None
+    target_sets: int | None = None  # Copies-per-file target (#1897)
     notes: str | None = None
     attachments: list | None = None
     tags: str | None = None
@@ -129,6 +132,13 @@ class ProjectResponse(BaseModel):
         from_attributes = True
 
 
+class ProjectFileProgress(BaseModel):
+    """Completed-run count for one library file inside a project (#1897)."""
+
+    file_id: int
+    completed_count: int
+
+
 class ArchivePreview(BaseModel):
     """Minimal archive data for project preview."""
 
@@ -150,6 +160,7 @@ class ProjectListResponse(BaseModel):
     status: str
     target_count: int | None
     target_parts_count: int | None = None
+    target_sets: int | None = None  # Copies-per-file target (#1897); the shared edit dialog needs it
     budget: float | None = None
     # The edit dialog is shared with the project detail page and seeds its fields
     # from whichever project object it is handed, so the list payload has to carry
@@ -276,6 +287,7 @@ class ProjectExport(BaseModel):
     status: str
     target_count: int | None
     target_parts_count: int | None
+    target_sets: int | None = None
     notes: str | None
     tags: str | None
     due_date: datetime | None
@@ -294,6 +306,7 @@ class ProjectImport(BaseModel):
     status: str = "active"
     target_count: int | None = None
     target_parts_count: int | None = None
+    target_sets: int | None = None
     notes: str | None = None
     tags: str | None = None
     due_date: datetime | None = None

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

@@ -1143,6 +1143,7 @@ class ArchiveService:
         subtask_id: str | None = None,
         prefer_filename_for_name: bool = False,
         plate_id: int | None = None,
+        library_file_id: int | None = None,
     ) -> PrintArchive | None:
         """Archive a 3MF file with metadata.
 
@@ -1155,6 +1156,8 @@ class ArchiveService:
                 stored with UUID names)
             project_id: Project to associate this archive with (optional, set when triggered
                 from the project view)
+            library_file_id: Library file this run was dispatched from (optional,
+                set by the queue scheduler — powers per-file project progress, #1897)
             subtask_id: MQTT-provided task identifier (optional). Used to match an
                 existing archive across a backend restart mid-print so the
                 original row can be resumed instead of cancelled (#972).
@@ -1314,6 +1317,7 @@ class ArchiveService:
             extra_data=metadata,
             created_by_id=created_by_id,
             project_id=project_id,
+            library_file_id=library_file_id,
             subtask_id=subtask_id,
             plate_id=plate_id,
         )

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

@@ -3122,6 +3122,7 @@ class PrintScheduler:
                     original_filename=filename,
                     created_by_id=item.created_by_id,
                     project_id=item.project_id,
+                    library_file_id=item.library_file_id,  # per-file project progress (#1897)
                     plate_id=item.plate_id,  # selected plate → Print History (#2603)
                 )
                 if archive:

+ 10 - 1
backend/tests/conftest.py

@@ -203,8 +203,17 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
     test_async_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
 
     async def override_get_db():
+        # Mirror production get_db (core/database.py): commit on success,
+        # rollback on error. Endpoints that rely on the request-scoped
+        # implicit commit (e.g. create_project, which only flushes) would
+        # otherwise silently lose their writes in tests (#1897).
         async with test_async_session() as session:
-            yield session
+            try:
+                yield session
+                await session.commit()
+            except BaseException:
+                await session.rollback()
+                raise
 
     app.dependency_overrides[get_db] = override_get_db
 

+ 224 - 0
backend/tests/integration/test_projects_api.py

@@ -1393,3 +1393,227 @@ class TestProjectListEditableFields:
         result = response.json()
         assert result["tags"] is None
         assert result["due_date"] is None
+
+
+class TestProjectFileProgress:
+    """Per-file print progress inside a project (#1897).
+
+    Covers GET /projects/{id}/file-progress (attribution: library_file_id →
+    content hash → filename, completed runs only, project-scoped), the
+    target_sets field round-trip, and the add-to-queue project inheritance
+    that feeds the attribution chain.
+    """
+
+    @pytest.fixture
+    async def project_factory(self, db_session):
+        _counter = [0]
+
+        async def _create_project(**kwargs):
+            from backend.app.models.project import Project
+
+            _counter[0] += 1
+            defaults = {"name": f"Progress Project {_counter[0]}"}
+            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 folder_factory(self, db_session):
+        _counter = [0]
+
+        async def _create_folder(**kwargs):
+            from backend.app.models.library import LibraryFolder
+
+            _counter[0] += 1
+            defaults = {"name": f"ProgressFolder {_counter[0]}"}
+            defaults.update(kwargs)
+            folder = LibraryFolder(**defaults)
+            db_session.add(folder)
+            await db_session.commit()
+            await db_session.refresh(folder)
+            return folder
+
+        return _create_folder
+
+    @pytest.fixture
+    async def file_factory(self, db_session):
+        _counter = [0]
+
+        async def _create_file(**kwargs):
+            from backend.app.models.library import LibraryFile
+
+            _counter[0] += 1
+            counter = _counter[0]
+            defaults = {
+                "filename": f"plate_{counter}.gcode.3mf",
+                "file_path": f"library/plate_{counter}.gcode.3mf",
+                "file_size": 1024,
+                "file_type": "3mf",
+            }
+            defaults.update(kwargs)
+            lib_file = LibraryFile(**defaults)
+            db_session.add(lib_file)
+            await db_session.commit()
+            await db_session.refresh(lib_file)
+            return lib_file
+
+        return _create_file
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_counts_by_library_file_id(
+        self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
+    ):
+        """Runs stamped with library_file_id count toward that file even when
+        the archive's filename differs (rename after dispatch)."""
+        project = await project_factory()
+        folder = await folder_factory(project_id=project.id)
+        file_a = await file_factory(folder_id=folder.id)
+        file_b = await file_factory(folder_id=folder.id)
+        printer = await printer_factory()
+
+        for _ in range(2):
+            await archive_factory(
+                printer.id,
+                project_id=project.id,
+                library_file_id=file_a.id,
+                filename="renamed_on_dispatch.gcode.3mf",
+            )
+        await archive_factory(printer.id, project_id=project.id, library_file_id=file_b.id)
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
+        assert response.status_code == 200
+        counts = {row["file_id"]: row["completed_count"] for row in response.json()}
+        assert counts == {file_a.id: 2, file_b.id: 1}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_hash_and_filename_fallback(
+        self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
+    ):
+        """Historical archives without library_file_id match by content hash,
+        then by filename."""
+        project = await project_factory()
+        folder = await folder_factory(project_id=project.id)
+        hashed_file = await file_factory(folder_id=folder.id, file_hash="a" * 64)
+        named_file = await file_factory(folder_id=folder.id, filename="unique_name.gcode.3mf")
+        printer = await printer_factory()
+
+        # Hash match despite a different filename
+        await archive_factory(
+            printer.id, project_id=project.id, content_hash="a" * 64, filename="printer_copy.gcode.3mf"
+        )
+        # Filename match with no hash on either side
+        await archive_factory(printer.id, project_id=project.id, filename="unique_name.gcode.3mf")
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
+        counts = {row["file_id"]: row["completed_count"] for row in response.json()}
+        assert counts == {hashed_file.id: 1, named_file.id: 1}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_only_completed_runs_count(
+        self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
+    ):
+        """Failed runs and never-printed archives do not advance the count."""
+        project = await project_factory()
+        folder = await folder_factory(project_id=project.id)
+        lib_file = await file_factory(folder_id=folder.id)
+        printer = await printer_factory()
+
+        await archive_factory(printer.id, project_id=project.id, library_file_id=lib_file.id)
+        await archive_factory(
+            printer.id, project_id=project.id, library_file_id=lib_file.id, status="failed", run_status="failed"
+        )
+        await archive_factory(printer.id, project_id=project.id, library_file_id=lib_file.id, with_run=False)
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
+        counts = {row["file_id"]: row["completed_count"] for row in response.json()}
+        assert counts == {lib_file.id: 1}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scoped_to_project(
+        self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
+    ):
+        """Runs of the same file outside the project (no project / another
+        project) are excluded."""
+        project = await project_factory()
+        other_project = await project_factory()
+        folder = await folder_factory(project_id=project.id)
+        lib_file = await file_factory(folder_id=folder.id)
+        printer = await printer_factory()
+
+        await archive_factory(printer.id, project_id=None, library_file_id=lib_file.id)
+        await archive_factory(printer.id, project_id=other_project.id, library_file_id=lib_file.id)
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
+        assert response.json() == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unknown_project_404(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/projects/999999/file-progress")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_target_sets_roundtrip(self, async_client: AsyncClient):
+        """target_sets survives create, update, and explicit-null clearing."""
+        create = await async_client.post("/api/v1/projects/", json={"name": "Sets Project", "target_sets": 10})
+        assert create.status_code == 200
+        project = create.json()
+        assert project["target_sets"] == 10
+
+        update = await async_client.patch(f"/api/v1/projects/{project['id']}", json={"target_sets": 4})
+        assert update.status_code == 200, update.json()
+        assert update.json()["target_sets"] == 4
+
+        cleared = await async_client.patch(f"/api/v1/projects/{project['id']}", json={"target_sets": None})
+        assert cleared.json()["target_sets"] is None
+
+        untouched = await async_client.patch(f"/api/v1/projects/{project['id']}", json={"name": "Renamed"})
+        assert untouched.json()["target_sets"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_inherits_folder_project(
+        self, async_client: AsyncClient, project_factory, folder_factory, file_factory, db_session, tmp_path
+    ):
+        """Queueing a file from a project-linked folder attributes the queue
+        item (and thus the later archive) to that project; a root file stays
+        unattributed."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        project = await project_factory()
+        folder = await folder_factory(project_id=project.id)
+
+        on_disk = tmp_path / "linked.gcode.3mf"
+        on_disk.write_bytes(b"fake sliced content")
+        linked_file = await file_factory(folder_id=folder.id, file_path=str(on_disk))
+
+        root_disk = tmp_path / "root.gcode.3mf"
+        root_disk.write_bytes(b"fake sliced content")
+        root_file = await file_factory(folder_id=None, file_path=str(root_disk))
+
+        response = await async_client.post(
+            "/api/v1/library/files/add-to-queue", json={"file_ids": [linked_file.id, root_file.id]}
+        )
+        assert response.status_code == 200
+        assert len(response.json()["added"]) == 2
+
+        result = await db_session.execute(
+            select(PrintQueueItem.library_file_id, PrintQueueItem.project_id).where(
+                PrintQueueItem.library_file_id.in_([linked_file.id, root_file.id])
+            )
+        )
+        projects_by_file = dict(result.all())
+        assert projects_by_file[linked_file.id] == project.id
+        assert projects_by_file[root_file.id] is None

+ 10 - 1
backend/tests/unit/test_scheduler_cleanup_library.py

@@ -111,7 +111,15 @@ async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effe
     scheduler = PrintScheduler()
 
     async def archive_print(
-        self, *, printer_id, source_file, original_filename, created_by_id=None, project_id=None, plate_id=None
+        self,
+        *,
+        printer_id,
+        source_file,
+        original_filename,
+        created_by_id=None,
+        project_id=None,
+        plate_id=None,
+        library_file_id=None,
     ):
         if archive_failure:
             raise RuntimeError("archive copy failed")
@@ -132,6 +140,7 @@ async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effe
             print_time_seconds=120,
             status="completed",
             project_id=project_id,
+            library_file_id=library_file_id,
             created_by_id=created_by_id,
         )
         self.db.add(archive)

+ 2 - 0
frontend/src/__tests__/mocks/handlers.ts

@@ -538,6 +538,8 @@ export const handlers = [
   http.get('/api/v1/obico/printer-status', () =>
     HttpResponse.json({ enabled: false, monitored_printers: null, per_printer: {}, last_error: null })
   ),
+  // Per-file project print progress (#1897) — empty means "no completed runs"
+  http.get('/api/v1/projects/:id/file-progress', () => HttpResponse.json([])),
   http.get('/api/v1/printers/:id/current-print-user', () => HttpResponse.json(null)),
   http.get('/api/v1/settings/check-ffmpeg', () =>
     HttpResponse.json({ available: false, version: null })

+ 82 - 0
frontend/src/__tests__/pages/ProjectDetailPage.test.tsx

@@ -222,4 +222,86 @@ describe('ProjectDetailPage', () => {
       });
     });
   });
+  describe('per-file print progress (#1897)', () => {
+    it('shows X / N badges and the Complete Sets bar when target_sets is set', async () => {
+      server.use(
+        http.get('/api/v1/projects/:id', () => {
+          return HttpResponse.json({ ...mockProject, target_sets: 10 });
+        }),
+        http.get('/api/v1/library/files', () => {
+          return HttpResponse.json([
+            makeFile({ id: 5, filename: 'plate_1.gcode.3mf', file_type: '3mf' }),
+            makeFile({ id: 6, filename: 'plate_2.gcode.3mf', file_type: '3mf' }),
+          ]);
+        }),
+        http.get('/api/v1/projects/:id/file-progress', () => {
+          return HttpResponse.json([{ file_id: 5, completed_count: 3 }]);
+        }),
+      );
+
+      render(<ProjectDetailPage />);
+
+      // Per-file badges: 3 / 10 for plate_1, 0 / 10 for the never-printed plate_2
+      await waitFor(() => {
+        expect(screen.getByTitle('3 of 10 completed prints')).toBeInTheDocument();
+      });
+      expect(screen.getByTitle('0 of 10 completed prints')).toBeInTheDocument();
+
+      // Complete sets = min across printable files = 0
+      expect(screen.getByText('Complete Sets')).toBeInTheDocument();
+      expect(
+        screen.getByText((_, element) => element?.tagName === 'SPAN' && element.textContent === '0 / 10 sets')
+      ).toBeInTheDocument();
+    });
+
+    it('counts a complete set once every printable file reached the target', async () => {
+      server.use(
+        http.get('/api/v1/projects/:id', () => {
+          return HttpResponse.json({ ...mockProject, target_sets: 2 });
+        }),
+        http.get('/api/v1/library/files', () => {
+          return HttpResponse.json([
+            makeFile({ id: 5, filename: 'plate_1.gcode.3mf', file_type: '3mf' }),
+            // STL is not printable and must not drag the set count to 0
+            makeFile({ id: 8, filename: 'source.stl', file_type: 'stl' }),
+            makeFile({ id: 6, filename: 'plate_2.gcode.3mf', file_type: '3mf' }),
+          ]);
+        }),
+        http.get('/api/v1/projects/:id/file-progress', () => {
+          // plate_1 overshot the target; capped at 2 for the set count
+          return HttpResponse.json([
+            { file_id: 5, completed_count: 3 },
+            { file_id: 6, completed_count: 2 },
+          ]);
+        }),
+      );
+
+      render(<ProjectDetailPage />);
+
+      await waitFor(() => {
+        expect(
+          screen.getByText((_, element) => element?.tagName === 'SPAN' && element.textContent === '2 / 2 sets')
+        ).toBeInTheDocument();
+      });
+    });
+
+    it('shows a plain printed-count badge when no target_sets is set', async () => {
+      server.use(
+        http.get('/api/v1/library/files', () => {
+          return HttpResponse.json([makeFile({ id: 5, filename: 'plate_1.gcode.3mf', file_type: '3mf' })]);
+        }),
+        http.get('/api/v1/projects/:id/file-progress', () => {
+          return HttpResponse.json([{ file_id: 5, completed_count: 4 }]);
+        }),
+      );
+
+      render(<ProjectDetailPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('4\u00d7')).toBeInTheDocument();
+      });
+      // No sets bar without a target
+      expect(screen.queryByText('Complete Sets')).not.toBeInTheDocument();
+    });
+  });
 });

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

@@ -911,6 +911,7 @@ export interface Project {
   status: string;  // active, completed, archived
   target_count: number | null;  // Target number of plates/print jobs
   target_parts_count: number | null;  // Target number of parts/objects
+  target_sets: number | null;  // Copies-per-file target (#1897)
   notes: string | null;
   attachments: ProjectAttachment[] | null;
   tags: string | null;
@@ -936,6 +937,12 @@ export interface ProjectAttachment {
   uploaded_at: string;
 }
 
+// Completed-run count for one library file inside a project (#1897)
+export interface ProjectFileProgress {
+  file_id: number;
+  completed_count: number;
+}
+
 export interface ArchivePreview {
   id: number;
   print_name: string | null;
@@ -953,6 +960,7 @@ export interface ProjectListItem {
   status: string;
   target_count: number | null;  // Target number of plates/print jobs
   target_parts_count: number | null;  // Target number of parts/objects
+  target_sets: number | null;  // #1897 — the shared edit dialog seeds itself from this
   budget: number | null;
   tags: string | null;  // #2536 — the shared edit dialog seeds itself from this
   due_date: string | null;  // #2536
@@ -975,6 +983,7 @@ export interface ProjectCreate {
   color?: string;
   target_count?: number;
   target_parts_count?: number;
+  target_sets?: number;
   notes?: string;
   tags?: string;
   due_date?: string;
@@ -991,6 +1000,7 @@ export interface ProjectUpdate {
   status?: string;
   target_count?: number;
   target_parts_count?: number;
+  target_sets?: number | null;  // #1897 — explicit null clears the copies-per-file target
   notes?: string;
   tags?: string | null;  // #2536 — explicit null clears the tags
   due_date?: string | null;  // #2536 — explicit null clears the due date
@@ -1062,6 +1072,7 @@ export interface ProjectExport {
   status: string;
   target_count: number | null;
   target_parts_count: number | null;
+  target_sets: number | null;  // #1897
   notes: string | null;
   tags: string | null;
   due_date: string | null;
@@ -1078,6 +1089,7 @@ export interface ProjectImport {
   status?: string;
   target_count?: number;
   target_parts_count?: number;
+  target_sets?: number;  // #1897
   notes?: string;
   tags?: string;
   due_date?: string;
@@ -5947,6 +5959,9 @@ export const api = {
     request<{ message: string }>(`/projects/${id}`, { method: 'DELETE' }),
   getProjectArchives: (id: number, limit = 100, offset = 0) =>
     request<Archive[]>(`/projects/${id}/archives?limit=${limit}&offset=${offset}`),
+  // Completed-run counts per library file (#1897); files with 0 runs are omitted
+  getProjectFileProgress: (id: number) =>
+    request<ProjectFileProgress[]>(`/projects/${id}/file-progress`),
   addArchivesToProject: (projectId: number, archiveIds: number[]) =>
     request<{ message: string }>(`/projects/${projectId}/add-archives`, {
       method: 'POST',

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

@@ -3784,6 +3784,9 @@ export default {
     targetParts: 'Ziel-Teile',
     targetPartsPlaceholder: 'z.B. 150',
     targetPartsHelp: 'Benötigte Objekte insgesamt',
+    targetSets: 'Kopien pro Datei',
+    targetSetsPlaceholder: 'z. B. 10',
+    targetSetsHelp: 'Wie oft jede druckbare Datei gedruckt werden soll',
     tagsLabel: 'Tags (kommagetrennt)',
     tagsPlaceholder: 'z.B. voron, funktional, geschenk',
     dueDate: 'Fälligkeitsdatum',
@@ -3859,6 +3862,9 @@ export default {
     progress: {
       platesProgress: 'Platten-Fortschritt',
       partsProgress: 'Teile-Fortschritt',
+      setsProgress: 'Vollständige Sätze',
+      sets: 'Sätze',
+      setsHint: 'Ein Satz ist vollständig, wenn jede druckbare Datei diese Anzahl erreicht hat',
       printJobs: 'Druckaufträge',
       parts: 'Teile',
       percentComplete: '{{percent}}% abgeschlossen',
@@ -3897,6 +3903,8 @@ export default {
       forQuickAccess: 'für schnellen Zugriff auf dieses Projekt.',
       fileCount: '{{count}} Datei(en)',
       empty: 'Keine Ordner verknüpft. Gehen Sie zum Dateimanager und verknüpfen Sie einen Ordner mit diesem Projekt.',
+      printedCount: '{{count}}-mal gedruckt',
+      progressTooltip: '{{done}} von {{target}} abgeschlossenen Drucken',
       noFiles: 'Keine Dateien in diesem Ordner.',
     },
     bom: {

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

@@ -3813,6 +3813,9 @@ export default {
     targetParts: 'Target Parts',
     targetPartsPlaceholder: 'e.g., 150',
     targetPartsHelp: 'Total objects needed',
+    targetSets: 'Copies per File',
+    targetSetsPlaceholder: 'e.g., 10',
+    targetSetsHelp: 'Times each printable file should be printed',
     tagsLabel: 'Tags (comma-separated)',
     tagsPlaceholder: 'e.g., voron, functional, gift',
     dueDate: 'Due Date',
@@ -3888,6 +3891,9 @@ export default {
     progress: {
       platesProgress: 'Plates Progress',
       partsProgress: 'Parts Progress',
+      setsProgress: 'Complete Sets',
+      sets: 'sets',
+      setsHint: 'A set is complete once every printable file has reached this count',
       printJobs: 'print jobs',
       parts: 'parts',
       percentComplete: '{{percent}}% complete',
@@ -3926,6 +3932,8 @@ export default {
       forQuickAccess: 'to this project for quick access.',
       fileCount: '{{count}} file(s)',
       empty: 'No folders linked. Go to File Manager and link a folder to this project.',
+      printedCount: 'Printed {{count}} time(s)',
+      progressTooltip: '{{done}} of {{target}} completed prints',
       noFiles: 'No files in this folder.',
     },
     bom: {

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

@@ -3787,6 +3787,9 @@ export default {
     targetParts: 'Piezas objetivo',
     targetPartsPlaceholder: 'p. ej., 150',
     targetPartsHelp: 'Total de objetos necesarios',
+    targetSets: 'Copias por archivo',
+    targetSetsPlaceholder: 'p. ej., 10',
+    targetSetsHelp: 'Veces que debe imprimirse cada archivo imprimible',
     tagsLabel: 'Etiquetas (separadas por comas)',
     tagsPlaceholder: 'p. ej., voron, funcional, regalo',
     dueDate: 'Fecha límite',
@@ -3862,6 +3865,9 @@ export default {
     progress: {
       platesProgress: 'Progreso de camas',
       partsProgress: 'Progreso de piezas',
+      setsProgress: 'Juegos completos',
+      sets: 'juegos',
+      setsHint: 'Un juego está completo cuando cada archivo imprimible alcanza este número',
       printJobs: 'trabajos de impresión',
       parts: 'piezas',
       percentComplete: '{{percent}}% completado',
@@ -3900,6 +3906,8 @@ export default {
       forQuickAccess: 'a este proyecto para un acceso rápido.',
       fileCount: '{{count}} archivo(s)',
       empty: 'No hay carpetas vinculadas. Vaya al gestor de archivos y vincule una carpeta a este proyecto.',
+      printedCount: 'Impreso {{count}} veces',
+      progressTooltip: '{{done}} de {{target}} impresiones completadas',
       noFiles: 'No hay archivos en esta carpeta.',
     },
     bom: {

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

@@ -3773,6 +3773,9 @@ export default {
     targetParts: 'Pièces cibles',
     targetPartsPlaceholder: 'ex: 150',
     targetPartsHelp: 'Nombre total d\'objets',
+    targetSets: 'Copies par fichier',
+    targetSetsPlaceholder: 'ex. 10',
+    targetSetsHelp: 'Nombre d\'impressions de chaque fichier imprimable',
     tagsLabel: 'Tags (séparés par virgules)',
     tagsPlaceholder: 'ex: voron, cadeau',
     dueDate: 'Échéance',
@@ -3848,6 +3851,9 @@ export default {
     progress: {
       platesProgress: 'Progression Plateaux',
       partsProgress: 'Progression Pièces',
+      setsProgress: 'Jeux complets',
+      sets: 'jeux',
+      setsHint: 'Un jeu est complet quand chaque fichier imprimable atteint ce nombre',
       printJobs: 'jobs d\'impression',
       parts: 'pièces',
       percentComplete: '{{percent}}% terminé',
@@ -3886,6 +3892,8 @@ export default {
       forQuickAccess: 'pour un accès rapide.',
       fileCount: '{{count}} fichier(s)',
       empty: 'Aucun dossier lié.',
+      printedCount: 'Imprimé {{count}} fois',
+      progressTooltip: '{{done}} sur {{target}} impressions terminées',
       noFiles: 'Aucun fichier dans ce dossier.',
     },
     bom: {

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

@@ -3772,6 +3772,9 @@ export default {
     targetParts: 'Parti target',
     targetPartsPlaceholder: 'es., 150',
     targetPartsHelp: 'Totale oggetti necessari',
+    targetSets: 'Copie per file',
+    targetSetsPlaceholder: 'es. 10',
+    targetSetsHelp: 'Quante volte stampare ogni file stampabile',
     tagsLabel: 'Tag (separati da virgola)',
     tagsPlaceholder: 'es., voron, funzionale, regalo',
     dueDate: 'Data scadenza',
@@ -3847,6 +3850,9 @@ export default {
     progress: {
       platesProgress: 'Avanzamento piatti',
       partsProgress: 'Avanzamento parti',
+      setsProgress: 'Set completi',
+      sets: 'set',
+      setsHint: 'Un set è completo quando ogni file stampabile raggiunge questo numero',
       printJobs: 'job di stampa',
       parts: 'parti',
       percentComplete: '{{percent}}% completato',
@@ -3885,6 +3891,8 @@ export default {
       forQuickAccess: 'a questo progetto per accesso rapido.',
       fileCount: '{{count}} file',
       empty: 'Nessuna cartella collegata. Vai a Gestore file e collega una cartella a questo progetto.',
+      printedCount: 'Stampato {{count}} volte',
+      progressTooltip: '{{done}} di {{target}} stampe completate',
       noFiles: 'Nessun file in questa cartella.',
     },
     bom: {

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

@@ -3784,6 +3784,9 @@ export default {
     targetParts: '目標パーツ数',
     targetPartsPlaceholder: '例: 50',
     targetPartsHelp: '必要なオブジェクトの総数',
+    targetSets: 'ファイルごとの部数',
+    targetSetsPlaceholder: '例: 10',
+    targetSetsHelp: '各印刷可能ファイルを印刷する回数',
     tagsLabel: 'タグ(カンマ区切り)',
     tagsPlaceholder: 'カンマ区切りのタグ',
     dueDate: '期限',
@@ -3859,6 +3862,9 @@ export default {
     progress: {
       platesProgress: 'プレート進捗',
       partsProgress: 'パーツ進捗',
+      setsProgress: '完成セット',
+      sets: 'セット',
+      setsHint: 'すべての印刷可能ファイルがこの回数に達するとセットが完成します',
       printJobs: '印刷ジョブ',
       parts: 'パーツ',
       percentComplete: '{{percent}}% 完了',
@@ -3897,6 +3903,8 @@ export default {
       forQuickAccess: 'してクイックアクセスできるようにします。',
       fileCount: '{{count}}ファイル',
       empty: '<空>',
+      printedCount: '{{count}}回印刷済み',
+      progressTooltip: '{{target}}回中{{done}}回の印刷が完了',
       noFiles: 'このフォルダにファイルはありません。',
     },
     bom: {

+ 8 - 0
frontend/src/i18n/locales/ko.ts

@@ -3592,6 +3592,9 @@ export default {
     targetParts: '목표 부품',
     targetPartsPlaceholder: '예: 150',
     targetPartsHelp: '필요한 총 개체 수',
+    targetSets: '파일당 복사본 수',
+    targetSetsPlaceholder: '예: 10',
+    targetSetsHelp: '각 인쇄 가능한 파일을 인쇄할 횟수',
     tagsLabel: '태그 (쉼표로 구분)',
     tagsPlaceholder: '예: voron, 기능성, 선물',
     dueDate: '마감일',
@@ -3660,6 +3663,9 @@ export default {
     progress: {
       platesProgress: '플레이트 진행률',
       partsProgress: '부품 진행률',
+      setsProgress: '완성된 세트',
+      sets: '세트',
+      setsHint: '모든 인쇄 가능한 파일이 이 횟수에 도달하면 세트가 완성됩니다',
       printJobs: '인쇄 작업',
       parts: '부품',
       percentComplete: '{{percent}}% 완료',
@@ -3698,6 +3704,8 @@ export default {
       forQuickAccess: '빠른 접근을 위해 이 프로젝트에 연결합니다.',
       fileCount: '{{count}}개 파일',
       empty: '연결된 폴더가 없습니다. 파일 관리자로 이동하여 폴더를 이 프로젝트에 연결하세요.',
+      printedCount: '{{count}}회 인쇄됨',
+      progressTooltip: '{{target}}회 중 {{done}}회 인쇄 완료',
       noFiles: '이 폴더에 파일이 없습니다.'
     },
     bom: {

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

@@ -3772,6 +3772,9 @@ export default {
     targetParts: 'Peças Alvo',
     targetPartsPlaceholder: 'ex., 150',
     targetPartsHelp: 'Total de objetos necessários',
+    targetSets: 'Cópias por arquivo',
+    targetSetsPlaceholder: 'ex.: 10',
+    targetSetsHelp: 'Quantas vezes cada arquivo imprimível deve ser impresso',
     tagsLabel: 'Tags (separadas por vírgula)',
     tagsPlaceholder: 'ex., voron, funcional, presente',
     dueDate: 'Data de Vencimento',
@@ -3847,6 +3850,9 @@ export default {
     progress: {
       platesProgress: 'Progresso das Placas',
       partsProgress: 'Progresso das Peças',
+      setsProgress: 'Conjuntos completos',
+      sets: 'conjuntos',
+      setsHint: 'Um conjunto está completo quando cada arquivo imprimível atinge esse número',
       printJobs: 'Trabalhos de Impressão',
       parts: 'Peças',
       percentComplete: '{{percent}}% concluído',
@@ -3885,6 +3891,8 @@ export default {
       forQuickAccess: 'a este projeto para acesso rápido.',
       fileCount: '{{count}} arquivo(s)',
       empty: 'Nenhuma pasta vinculada. Vá para o Gerenciador de Arquivos e vincule uma pasta a este projeto.',
+      printedCount: 'Impresso {{count}} vez(es)',
+      progressTooltip: '{{done}} de {{target}} impressões concluídas',
       noFiles: 'Nenhum arquivo nesta pasta.',
     },
     bom: {

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

@@ -3584,6 +3584,9 @@ export default {
     targetParts: "План по деталям",
     targetPartsPlaceholder: "например, 150",
     targetPartsHelp: "Общее требуемое количество объектов",
+    targetSets: "Копий на файл",
+    targetSetsPlaceholder: "напр. 10",
+    targetSetsHelp: "Сколько раз нужно напечатать каждый печатаемый файл",
     tagsLabel: "Метки (через запятую)",
     tagsPlaceholder: "например, voron, функциональное, подарок",
     dueDate: "Срок",
@@ -3652,6 +3655,9 @@ export default {
     progress: {
       platesProgress: "Выполнение по пластинам",
       partsProgress: "Выполнение по деталям",
+      setsProgress: "Полные комплекты",
+      sets: "комплектов",
+      setsHint: "Комплект готов, когда каждый печатаемый файл достиг этого количества",
       printJobs: "заданий печати",
       parts: "деталей",
       percentComplete: "Выполнено {{percent}}%",
@@ -3690,6 +3696,8 @@ export default {
       forQuickAccess: "с этим проектом для быстрого доступа.",
       fileCount: "Файлов: {{count}}",
       empty: "Связанных папок нет. Откройте файловый менеджер и свяжите папку с проектом.",
+      printedCount: "Напечатано {{count}} раз",
+      progressTooltip: "{{done}} из {{target}} завершённых печатей",
       noFiles: "В этой папке нет файлов.",
     },
     bom: {

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

@@ -3779,6 +3779,9 @@ export default {
     targetParts: 'Hedef Parçalar',
     targetPartsPlaceholder: 'örn., 150',
     targetPartsHelp: 'Gereken toplam nesne',
+    targetSets: 'Dosya başına kopya',
+    targetSetsPlaceholder: 'örn. 10',
+    targetSetsHelp: 'Her yazdırılabilir dosyanın kaç kez yazdırılacağı',
     tagsLabel: 'Etiketler (virgülle ayrılmış)',
     tagsPlaceholder: 'örn., voron, fonksiyonel, hediye',
     dueDate: 'Son Tarih',
@@ -3849,6 +3852,9 @@ export default {
     progress: {
       platesProgress: 'Plaka İlerlemesi',
       partsProgress: 'Parça İlerlemesi',
+      setsProgress: 'Tamamlanan setler',
+      sets: 'set',
+      setsHint: 'Her yazdırılabilir dosya bu sayıya ulaştığında bir set tamamlanır',
       printJobs: 'baskı işi',
       parts: 'parça',
       percentComplete: '%{{percent}} tamamlandı',
@@ -3887,6 +3893,8 @@ export default {
       forQuickAccess: 'hızlı erişim için bu projeye.',
       fileCount: '{{count}} dosya',
       empty: 'Bağlı klasör yok. Dosya Yöneticisine gidin ve bu projeye bir klasör bağlayın.',
+      printedCount: '{{count}} kez yazdırıldı',
+      progressTooltip: '{{target}} yazdırmadan {{done}} tanesi tamamlandı',
       noFiles: 'Bu klasörde dosya yok.',
     },
     bom: {

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

@@ -3772,6 +3772,9 @@ export default {
     targetParts: '目标零件数',
     targetPartsPlaceholder: '例如:150',
     targetPartsHelp: '所需零件总数',
+    targetSets: '每个文件的份数',
+    targetSetsPlaceholder: '例如 10',
+    targetSetsHelp: '每个可打印文件需要打印的次数',
     tagsLabel: '标签(逗号分隔)',
     tagsPlaceholder: '例如:voron、功能件、礼物',
     dueDate: '截止日期',
@@ -3847,6 +3850,9 @@ export default {
     progress: {
       platesProgress: '板进度',
       partsProgress: '零件进度',
+      setsProgress: '完整套数',
+      sets: '套',
+      setsHint: '当每个可打印文件都达到此次数时,即完成一套',
       printJobs: '打印任务',
       parts: '零件',
       percentComplete: '{{percent}}% 完成',
@@ -3885,6 +3891,8 @@ export default {
       forQuickAccess: '到此项目以便快速访问。',
       fileCount: '{{count}} 个文件',
       empty: '未链接文件夹。前往文件管理器将文件夹链接到此项目。',
+      printedCount: '已打印 {{count}} 次',
+      progressTooltip: '已完成 {{done}}/{{target}} 次打印',
       noFiles: '此文件夹中没有文件。',
     },
     bom: {

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

@@ -3772,6 +3772,9 @@ export default {
     targetParts: '目標零件數',
     targetPartsPlaceholder: '例如:150',
     targetPartsHelp: '所需零件總數',
+    targetSets: '每個檔案的份數',
+    targetSetsPlaceholder: '例如 10',
+    targetSetsHelp: '每個可列印檔案需要列印的次數',
     tagsLabel: '標籤(逗號分隔)',
     tagsPlaceholder: '例如:voron、功能件、禮物',
     dueDate: '截止日期',
@@ -3847,6 +3850,9 @@ export default {
     progress: {
       platesProgress: '板進度',
       partsProgress: '零件進度',
+      setsProgress: '完整套數',
+      sets: '套',
+      setsHint: '當每個可列印檔案都達到此次數時,即完成一套',
       printJobs: '列印任務',
       parts: '零件',
       percentComplete: '{{percent}}% 完成',
@@ -3885,6 +3891,8 @@ export default {
       forQuickAccess: '到此項目以便快速存取。',
       fileCount: '{{count}} 個檔案',
       empty: '未連結資料夾。前往檔案管理器將資料夾連結到此項目。',
+      printedCount: '已列印 {{count}} 次',
+      progressTooltip: '已完成 {{done}}/{{target}} 次列印',
       noFiles: '此資料夾中沒有檔案。',
     },
     bom: {

+ 80 - 1
frontend/src/pages/ProjectDetailPage.tsx

@@ -268,6 +268,29 @@ export function ProjectDetailPage() {
     return map;
   }, [allProjectFiles]);
 
+  // Per-file completed-run counts (#1897); a file absent from the response has 0
+  const { data: fileProgress } = useQuery({
+    queryKey: ['project-file-progress', projectId],
+    queryFn: () => api.getProjectFileProgress(projectId),
+    enabled: projectId > 0,
+  });
+
+  const progressByFileId = useMemo(() => {
+    const map = new Map<number, number>();
+    for (const row of fileProgress ?? []) map.set(row.file_id, row.completed_count);
+    return map;
+  }, [fileProgress]);
+
+  // Complete sets (#1897): the number of finished assemblies — the minimum
+  // completed count across the project's printable files, capped at the target.
+  const completeSets = useMemo(() => {
+    const target = project?.target_sets;
+    if (!target || !allProjectFiles) return null;
+    const printable = allProjectFiles.filter((f) => isSlicedFilename(f.filename));
+    if (printable.length === 0) return null;
+    return Math.min(...printable.map((f) => Math.min(progressByFileId.get(f.id) ?? 0, target)));
+  }, [project?.target_sets, allProjectFiles, progressByFileId]);
+
   const currency = getCurrencySymbol(settings?.currency || 'USD');
   const timeFormat: TimeFormat = settings?.time_format || 'system';
 
@@ -536,7 +559,7 @@ export function ProjectDetailPage() {
       </div>
 
       {/* Progress bars (if targets set) */}
-      {(project.target_count || project.target_parts_count) && (
+      {(project.target_count || project.target_parts_count || project.target_sets) && (
         <Card>
           <CardContent className="p-4 space-y-4">
             {/* Plates progress */}
@@ -599,6 +622,27 @@ export function ProjectDetailPage() {
                 </div>
               </div>
             )}
+            {/* Complete sets progress (#1897): min per-file completed count */}
+            {project.target_sets ? (
+              <div>
+                <div className="flex items-center justify-between mb-2">
+                  <span className="text-sm text-bambu-gray">{t('projectDetail.progress.setsProgress')}</span>
+                  <span className="text-sm font-medium text-white">
+                    {completeSets ?? 0} / {project.target_sets} {t('projectDetail.progress.sets')}
+                  </span>
+                </div>
+                <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
+                  <div
+                    className="h-full transition-all duration-500"
+                    style={{
+                      width: `${Math.min(((completeSets ?? 0) / project.target_sets) * 100, 100)}%`,
+                      backgroundColor: (completeSets ?? 0) >= project.target_sets ? '#22c55e' : project.color || '#6b7280',
+                    }}
+                  />
+                </div>
+                <p className="text-xs text-bambu-gray/70 mt-1">{t('projectDetail.progress.setsHint')}</p>
+              </div>
+            ) : null}
           </CardContent>
         </Card>
       )}
@@ -953,6 +997,41 @@ export function ProjectDetailPage() {
                                 </span>
                               </div>
 
+                              {/* Per-file print progress (#1897) */}
+                              {printable && (() => {
+                                const done = progressByFileId.get(file.id) ?? 0;
+                                const target = project.target_sets;
+                                if (!target) {
+                                  // No copies-per-file target — show a plain printed-count badge
+                                  return done > 0 ? (
+                                    <span
+                                      className="shrink-0 text-xs px-1.5 py-0.5 rounded-full bg-bambu-dark text-bambu-gray"
+                                      title={t('projectDetail.files.printedCount', { count: done })}
+                                    >
+                                      {done}×
+                                    </span>
+                                  ) : null;
+                                }
+                                const pct = Math.min((done / target) * 100, 100);
+                                const textColor =
+                                  done >= target ? 'text-status-ok' : done > 0 ? 'text-status-warning' : 'text-bambu-gray';
+                                const barColor =
+                                  done >= target ? 'bg-status-ok' : done > 0 ? 'bg-status-warning' : 'bg-bambu-gray';
+                                return (
+                                  <div
+                                    className="shrink-0 w-20"
+                                    title={t('projectDetail.files.progressTooltip', { done, target })}
+                                  >
+                                    <p className={`text-xs font-medium text-right ${textColor}`}>
+                                      {done} / {target}
+                                    </p>
+                                    <div className="h-1 bg-bambu-dark rounded-full overflow-hidden mt-1">
+                                      <div className={`h-full ${barColor}`} style={{ width: `${pct}%` }} />
+                                    </div>
+                                  </div>
+                                );
+                              })()}
+
                               {/* Print actions for sliced files */}
                               {printable && (
                                 <div className="flex items-center gap-1 shrink-0">

+ 19 - 0
frontend/src/pages/ProjectsPage.tsx

@@ -61,6 +61,7 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
   const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
   const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || '');
   const [targetPartsCount, setTargetPartsCount] = useState(project?.target_parts_count?.toString() || '');
+  const [targetSets, setTargetSets] = useState(project?.target_sets?.toString() || '');
   const [status, setStatus] = useState(project?.status || 'active');
   const [tags, setTags] = useState(project?.tags || '');
   const [dueDate, setDueDate] = useState(project?.due_date?.split('T')[0] || '');
@@ -120,6 +121,8 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
       color,
       target_count: targetCount ? parseInt(targetCount, 10) : undefined,
       target_parts_count: targetPartsCount ? parseInt(targetPartsCount, 10) : undefined,
+      // Null clears the copies-per-file target on edit (#1897); undefined omits on create.
+      target_sets: project ? (targetSets ? parseInt(targetSets, 10) : null) : (targetSets ? parseInt(targetSets, 10) : undefined),
       // Null clears the stored value on edit; undefined omits the key on create.
       // Sending undefined on edit would make an emptied field un-clearable.
       tags: project ? (tags.trim() || null) : (tags.trim() || undefined),
@@ -299,6 +302,22 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
             </div>
           </div>
 
+          {/* Copies-per-file target (#1897) */}
+          <div>
+            <label className="block text-sm font-medium text-white mb-1">
+              {t('projects.targetSets')}
+            </label>
+            <input
+              type="number"
+              value={targetSets}
+              onChange={(e) => setTargetSets(e.target.value)}
+              className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+              placeholder={t('projects.targetSetsPlaceholder')}
+              min="1"
+            />
+            <p className="text-xs text-bambu-gray mt-1">{t('projects.targetSetsHelp')}</p>
+          </div>
+
           {/* Tags */}
           <div>
             <label className="block text-sm font-medium text-white mb-1">

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-DLc2EliX.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CO7zRvej.js"></script>
+    <script type="module" crossorigin src="/assets/index-DLc2EliX.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Di24iyOw.css">
   </head>
   <body>

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff