Procházet zdrojové kódy

fix(finance): a print whose user was deleted while it ran is billed to its cost centre

maziggy před 1 týdnem
rodič
revize
b2ed37b6c3
100 změnil soubory, kde provedl 469 přidání a 22 odebrání
  1. 1 0
      CHANGELOG.md
  2. 79 0
      backend/app/api/routes/users.py
  3. 47 13
      backend/app/services/finance_billing.py
  4. 316 0
      backend/tests/unit/services/test_finance_billing_deleted_user.py
  5. 7 4
      frontend/src/__tests__/pages/FinanceDeletedUser.test.tsx
  6. 1 0
      frontend/src/i18n/locales/de.ts
  7. 1 0
      frontend/src/i18n/locales/en.ts
  8. 1 0
      frontend/src/i18n/locales/es.ts
  9. 1 0
      frontend/src/i18n/locales/fr.ts
  10. 1 0
      frontend/src/i18n/locales/it.ts
  11. 1 0
      frontend/src/i18n/locales/ja.ts
  12. 1 0
      frontend/src/i18n/locales/ko.ts
  13. 1 0
      frontend/src/i18n/locales/nl.ts
  14. 1 0
      frontend/src/i18n/locales/pt-BR.ts
  15. 1 0
      frontend/src/i18n/locales/ru.ts
  16. 1 0
      frontend/src/i18n/locales/sv.ts
  17. 1 0
      frontend/src/i18n/locales/tr.ts
  18. 1 0
      frontend/src/i18n/locales/uk.ts
  19. 1 0
      frontend/src/i18n/locales/zh-CN.ts
  20. 1 0
      frontend/src/i18n/locales/zh-TW.ts
  21. 2 1
      frontend/src/pages/FinancePage.tsx
  22. 0 0
      static/assets/AdditionalSection-BYVdgtFJ.js
  23. 0 0
      static/assets/AmsUnitCard-DgMS5Oh5.js
  24. 0 0
      static/assets/ArchivesPage-DIsMdGVZ.js
  25. 0 0
      static/assets/AssignToAmsModal-Di1ZW_Ml.js
  26. 0 0
      static/assets/CamWallPage-DsQAKx5y.js
  27. 0 0
      static/assets/CameraDiagnoseModal-aEQUh6Bb.js
  28. 0 0
      static/assets/CameraPage-CF0rka0w.js
  29. 0 0
      static/assets/CameraWall-Bv_B8aC4.js
  30. 0 0
      static/assets/ColumnConfigModal-D_0mg7E6.js
  31. 0 0
      static/assets/ConfigureAmsSlotModal-BVSkTZnH.js
  32. 0 0
      static/assets/ConnectAuthorizePage-DusEf8ha.js
  33. 0 0
      static/assets/ContextMenu-CRmGk4Vx.js
  34. 0 0
      static/assets/ExternalLinkPage-BIaJ_-O8.js
  35. 0 1
      static/assets/FileManagerPage-CnnuugFL.js
  36. 0 0
      static/assets/FileUploadModal-BydK3uWr.js
  37. 0 0
      static/assets/FinancePage-BHGW2T01.js
  38. 0 0
      static/assets/GCodeViewerPage-D2eedBjV.js
  39. 0 0
      static/assets/GroupEditPage-C8-u0ZQk.js
  40. 0 0
      static/assets/ImagePreviewModal-CtcnpTdJ.js
  41. 0 0
      static/assets/InventoryPage-Dj7QrHDx.js
  42. 0 0
      static/assets/LibraryTrashPage-CQwm9n2w.js
  43. 0 0
      static/assets/LoginPage-Cq3CqbNb.js
  44. 0 0
      static/assets/MaintenancePage-D6z-yHPp.js
  45. 0 0
      static/assets/ModelSourcesPage-DWi2HTBa.js
  46. 0 0
      static/assets/NotificationsPage-beoswXGn.js
  47. 0 1
      static/assets/PdfPreviewModal-BuYpsscN.js
  48. 0 0
      static/assets/PhotoGalleryModal-DTaWUdJP.js
  49. 0 0
      static/assets/PrinterLocationsPage-CYMyhV59.js
  50. 0 0
      static/assets/PrintersPage-DM9aOSkD.js
  51. 0 0
      static/assets/ProfilesPage-CBhFUinM.js
  52. 0 0
      static/assets/ProjectDetailPage-2q_iWeqJ.js
  53. 0 0
      static/assets/ProjectsPage-DSK-6Si9.js
  54. 0 0
      static/assets/QueuePage-BvSkLTD6.js
  55. 0 0
      static/assets/RichTextEditor-D6QLCbi9.js
  56. 0 0
      static/assets/SettingsPage-U3dnzGCV.js
  57. 0 0
      static/assets/SetupPage-CD1wXyCU.js
  58. 0 0
      static/assets/SliceModal-DsHOFwMx.js
  59. 0 0
      static/assets/SpoolBuddyAmsPage-Bypzvy0M.js
  60. 0 0
      static/assets/SpoolBuddyCalibrationPage-D5a_rY1W.js
  61. 0 0
      static/assets/SpoolBuddyDashboard-tqTIH2wL.js
  62. 0 0
      static/assets/SpoolBuddyInventoryPage-7QsTwW6k.js
  63. 1 1
      static/assets/SpoolBuddySettingsPage-Bm_I5pFj.js
  64. 0 0
      static/assets/SpoolBuddyWriteTagPage-YQkcr5kv.js
  65. 0 0
      static/assets/SpreadsheetPreviewModal-D89O3ggF.js
  66. 0 0
      static/assets/StatsPage-B0SsYqzc.js
  67. 0 0
      static/assets/StreamOverlayPage-DkyRhxvH.js
  68. 0 0
      static/assets/SystemInfoPage-BllN6HgM.js
  69. 0 0
      static/assets/de-d4f0ctP_.js
  70. 0 0
      static/assets/de-m6eKVbb2.js
  71. 0 0
      static/assets/es-Cjs7c4Fr.js
  72. 0 0
      static/assets/es-Dj4End9b.js
  73. 0 0
      static/assets/fr-BUDFE3iT.js
  74. 0 0
      static/assets/fr-D6mzxySl.js
  75. 0 0
      static/assets/index-DUKqgwNl.js
  76. 1 1
      static/assets/inventorySearch-mXpLV720.js
  77. 0 0
      static/assets/it-BBKk6UPP.js
  78. 0 0
      static/assets/it-CFF2YIPs.js
  79. 0 0
      static/assets/ja-BzdPx63j.js
  80. 0 0
      static/assets/ja-qS_D-r1-.js
  81. 0 0
      static/assets/ko-B-kp19gj.js
  82. 0 0
      static/assets/ko-Ta4cxLLC.js
  83. 0 0
      static/assets/locationSensorDefaults-Dq8gENPa.js
  84. 0 0
      static/assets/nl-DqDyb0GB.js
  85. 0 0
      static/assets/nl-IxfSpKfq.js
  86. 0 0
      static/assets/pdf-Brd6mhHG.js
  87. 0 0
      static/assets/pt-BR-DnvNDzJ-.js
  88. 0 0
      static/assets/pt-BR-iyc59etW.js
  89. 0 0
      static/assets/ru-BWVuXLjw.js
  90. 0 0
      static/assets/ru-DyqHcVB_.js
  91. 0 0
      static/assets/sv-CW52pk2Y.js
  92. 0 0
      static/assets/sv-D2FAMtpx.js
  93. 0 0
      static/assets/tr-DBlfzO3S.js
  94. 0 0
      static/assets/tr-NrNjpPlr.js
  95. 0 0
      static/assets/uk-3EyJHzFg.js
  96. 0 0
      static/assets/uk-B9kpp-m6.js
  97. 0 0
      static/assets/utils-i7ktJI-2.js
  98. 0 0
      static/assets/zh-CN-Dl5y5riy.js
  99. 0 0
      static/assets/zh-CN-DslIGhDJ.js
  100. 0 0
      static/assets/zh-TW-b1Op5Sk9.js

+ 1 - 0
CHANGELOG.md

@@ -162,6 +162,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A cost centre is charged for a print whose user was deleted while it ran** — When a user was deleted during their print, the print was never charged: the cost centre's ledger missed its cost, and its budget reservation stayed active for good, holding back that much of the budget. The print is now charged to its cost centre, also to the deleted user's own private one, which their personal prints go through, and its reservation is settled. A queue print is charged under the user who queued it, never under the owner of the archive it reprints. Ledger entries without a user now read "No user" instead of "Deleted user": with login turned off, prints on a cost centre are now charged too and never had a user. Deleting a user together with their items now also releases the budget their queued prints still held; a print of theirs still running is kept until it ends (without a user) and billed to its cost centre like any other. Only a print with no cost centre at all (started while billing was off) has nobody left to charge.
 - **Deleting a printer no longer deletes an archive of a print starting at that moment (PostgreSQL)** — If a queue or reprint print started on the printer while it was being deleted, the delete could take the print's archive with it, even with "keep archives" and even when the archive belonged to another printer or a virtual printer queue. The delete now locks the printer while it runs, so such a print start waits and is turned away, and its archive stays as it was; this is logged as a print start for a deleted printer, not as an error, and sends no start notification. An archive that could not be written no longer leaves its copied 3MF behind in the archive folder.
 - **A printer deleted while its print was starting no longer gets an archive or tracking** — When a print starts, Bambuddy downloads its 3MF from the printer, which can take minutes. A printer deleted during that download still got an archive afterwards: on SQLite one that showed "printing" forever, since the deleted printer would never report the end, along with a layer timelapse and tracking for it; on PostgreSQL the archive was refused and an error logged. Bambuddy now checks that the printer still exists once the download is done, and again once the archive is written; a queue or reprint print starting just as its printer is deleted is no longer attached to that printer either. Archives the delete was told to keep stay as they are. A queue print dispatched to a printer deleted during its upload no longer records its owner for that printer either.
 - **Closing one camera view no longer cuts off the others** — With an external RTSP or USB camera, closing one view of a printer (a Cam Wall tile turning to snapshot, the Camera page, the camera window's refresh) stopped that printer's camera stream for every other open view too, which then froze until it reconnected. Each view now names itself on its stream and its stop: the stop ends that view's own stream (at once, as before, so a USB camera is released when its view closes) and any stream that has stopped sending pictures, and leaves the other views' streams running.

+ 79 - 0
backend/app/api/routes/users.py

@@ -528,6 +528,85 @@ async def delete_user(
 
         await delete_dependent_variants(db, doomed_library_file_ids)
         await release_queue_references(db, doomed_library_file_ids)
+        # A print still running keeps what it needs to be billed when it
+        # ends: its queue row and the user's archive it prints are detached
+        # (no user), as without delete_items, rather than deleted. Deleted, the
+        # completion found no queue row and charged the archive's creator
+        # instead (for a reprint someone else, who never printed it), or found
+        # no archive and billed nothing, its reservation held for good.
+        from sqlalchemy import and_, or_
+
+        from backend.app.models.finance import BudgetReservation
+
+        running_rows = (
+            await db.execute(
+                select(PrintQueueItem.id, PrintQueueItem.archive_id).where(
+                    PrintQueueItem.created_by_id == user_id, PrintQueueItem.status == "printing"
+                )
+            )
+        ).all()
+        running_items = [row.id for row in running_rows]
+        running_archives = [row.archive_id for row in running_rows if row.archive_id is not None]
+        running_archives += (
+            (
+                await db.execute(
+                    select(PrintArchive.id).where(
+                        PrintArchive.created_by_id == user_id, PrintArchive.status == "printing"
+                    )
+                )
+            )
+            .scalars()
+            .all()
+        )
+        # Also one another user is printing (a reprint of this user's archive,
+        # dispatched before the printer's start marks the archive printing):
+        # deleted, it took their queue row with it and their print went unbilled.
+        own_archives = select(PrintArchive.id).where(PrintArchive.created_by_id == user_id)
+        running_archives += (
+            (
+                await db.execute(
+                    select(PrintQueueItem.archive_id).where(
+                        PrintQueueItem.status == "printing", PrintQueueItem.archive_id.in_(own_archives)
+                    )
+                )
+            )
+            .scalars()
+            .all()
+        )
+        if running_items:
+            await db.execute(
+                sql_update(PrintQueueItem).where(PrintQueueItem.id.in_(running_items)).values(created_by_id=None)
+            )
+        if running_archives:
+            await db.execute(
+                sql_update(PrintArchive)
+                .where(PrintArchive.id.in_(running_archives), PrintArchive.created_by_id == user_id)
+                .values(created_by_id=None)
+            )
+
+        # Budget held for the queue items and archives deleted here: no print
+        # completion can settle it any more (it finds no archive), and an
+        # active reservation held the cost centre's budget, and kept the cost
+        # centre from being deleted, for good. Nothing is charged for those:
+        # they go, as the admin chose. (A running print's was detached above.)
+        await db.execute(
+            sql_update(BudgetReservation)
+            .where(
+                BudgetReservation.status == "active",
+                or_(
+                    and_(
+                        BudgetReservation.source_type == "print_queue",
+                        BudgetReservation.source_id.in_(
+                            select(PrintQueueItem.id).where(PrintQueueItem.created_by_id == user_id)
+                        ),
+                    ),
+                    BudgetReservation.print_archive_id.in_(
+                        select(PrintArchive.id).where(PrintArchive.created_by_id == user_id)
+                    ),
+                ),
+            )
+            .values(status="released", released_at=datetime.now(timezone.utc))
+        )
         await db.execute(delete(PrintArchive).where(PrintArchive.created_by_id == user_id))
         await db.execute(delete(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id))
         await db.execute(delete(LibraryFile).where(LibraryFile.created_by_id == user_id))

+ 47 - 13
backend/app/services/finance_billing.py

@@ -19,7 +19,7 @@ class BillingRunIdCollisionError(RuntimeError):
 
 async def _get_balance_after_for_transaction(
     db: AsyncSession,
-    user_id: int,
+    user_id: int | None,
     cost_center_id: int | None,
     amount: float,
 ) -> float:
@@ -161,13 +161,45 @@ async def apply_print_charge_for_archive(
             logger.info(f"Archive ID {archive_id} has status {archive.status}, which is not chargeable.")
             return False
 
-        actual_user_id = charged_user_id if charged_user_id is not None else archive.created_by_id
-        if actual_user_id is None:
-            logger.warning(f"Archive ID {archive_id} has no creator ID.")
+        # A queue print's user is the one who queued it, also when deleted
+        # since: a reprint reuses the source archive, whose creator did not
+        # print it. The archive's creator only for a print without a queue row.
+        if charged_user_id is not None:
+            # Read by the caller seconds before (the completion does usage and
+            # archive work first): a user deleted since is no user. Charged by
+            # id, PostgreSQL refused the wallet (FK) and the reservation stayed
+            # active; SQLite wrote a wallet and a charge for a missing user.
+            from backend.app.models.user import User
+
+            actual_user_id = await db.scalar(select(User.id).where(User.id == charged_user_id))
+        elif print_queue_id is not None:
+            actual_user_id = None
+        else:
+            actual_user_id = archive.created_by_id
+        actual_cost_center_id = cost_center_id if cost_center_id is not None else archive.cost_center_id
+        # No user: deleted while the print ran, or never had one (login turned
+        # off). A cost centre's print is still its cost: charged to the cost
+        # centre without a user (shown as "No user"), which also consumes its
+        # reservation. That includes
+        # the user's own private cost centre, which their personal prints go
+        # through with billing on and which outlives them. Only a print with no
+        # cost centre at all (started while billing was off) has nobody left to
+        # charge, and holds no reservation (budgets exist per cost centre).
+        if actual_user_id is None and actual_cost_center_id is None:
+            logger.info("Archive ID %s has no user to charge (deleted) and no cost centre.", archive_id)
             return False
 
         base_cost = float(base_cost_override if base_cost_override is not None else (archive.cost or 0.0))
         if base_cost <= 0:
+            # Nothing to charge, but the print is over: settle what it held
+            # (reserved from an estimate, e.g. an older archive with grams but
+            # no stored cost), as the zero-charge case below does.
+            if print_queue_id is not None:
+                await release_budget_reservation(
+                    db, source_type="print_queue", source_id=print_queue_id, status="released"
+                )
+            else:
+                await release_budget_reservation(db, print_archive_id=archive.id, status="released")
             logger.info(f"Base cost for archive ID {archive_id} is zero or negative.")
             return False
 
@@ -216,14 +248,16 @@ async def apply_print_charge_for_archive(
             logger.info(f"Calculated charge for archive ID {archive_id} is zero or negative.")
             return False
 
-        actual_cost_center_id = cost_center_id if cost_center_id is not None else archive.cost_center_id
-
-        wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == actual_user_id))).scalar_one_or_none()
-        if wallet is None:
-            wallet = UserWallet(user_id=actual_user_id, balance=0.0)
-            db.add(wallet)
-            await db.flush()
-            logger.info("Created new wallet for user ID %s.", actual_user_id)
+        wallet = None
+        if actual_user_id is not None:
+            wallet = (
+                await db.execute(select(UserWallet).where(UserWallet.user_id == actual_user_id))
+            ).scalar_one_or_none()
+            if wallet is None:
+                wallet = UserWallet(user_id=actual_user_id, balance=0.0)
+                db.add(wallet)
+                await db.flush()
+                logger.info("Created new wallet for user ID %s.", actual_user_id)
 
         label = archive.print_name or archive.filename or f"Archive {archive.id}"
         description = f"Print charge: {label}{' ' + reason_suffix if reason_suffix else ''}"
@@ -276,7 +310,7 @@ async def apply_print_charge_for_archive(
 
         # Rebuild from the canonical personal-ledger definition. A shared cost
         # center charge must not debit the user's personal wallet.
-        new_wallet_balance = await sync_personal_wallet_balance(db, wallet)
+        new_wallet_balance = await sync_personal_wallet_balance(db, wallet) if wallet is not None else None
 
         # Consume matching budget reservations after the transaction is persisted
         if print_queue_id is not None:

+ 316 - 0
backend/tests/unit/services/test_finance_billing_deleted_user.py

@@ -0,0 +1,316 @@
+"""A print still running when its user is deleted is billed to its cost centre.
+
+At completion neither the queue row's billing user nor the archive's creator is
+left, and the charge returned "has no creator ID" before it released the budget
+reservation: the cost centre's reservation stayed active for good and its ledger
+missed the print. It is now charged to its cost centre without a user (shown
+as "No user", as is a print that never had one with login off), also the
+user's own private one their personal prints go through. Only a print with no cost centre at all has nobody to charge. Deleting
+the user together with their items releases what their prints still held.
+"""
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.finance import BudgetReservation, CostCenter, WalletTransaction
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.services.finance_billing import apply_print_charge_for_archive
+from backend.tests.unit.services.test_finance_service_billing import enable_billing
+
+pytestmark = pytest.mark.asyncio
+
+
+async def _orphaned_print(db_session, *, cost_center: bool):
+    """A completed print whose user was deleted while it ran: no creator, no
+    billing user, and an active reservation from its dispatch."""
+    await enable_billing(db_session)
+    center = None
+    if cost_center:
+        center = CostCenter(name="Lab", is_active=True, is_private=False)
+        db_session.add(center)
+        await db_session.flush()
+    archive = PrintArchive(
+        filename="part.3mf",
+        file_path="archives/test/part.3mf",
+        file_size=1,
+        status="completed",
+        cost=5.0,
+        created_by_id=None,
+        cost_center_id=center.id if center else None,
+    )
+    db_session.add(archive)
+    await db_session.flush()
+    item = PrintQueueItem(
+        archive_id=archive.id,
+        cost_center_id=center.id if center else None,
+        estimated_cost=5.0,
+        position=1,
+        status="printing",
+        created_by_id=None,
+        billing_run_id="run-of-a-deleted-user",
+    )
+    db_session.add(item)
+    await db_session.flush()
+    reservation = None
+    if center:  # budgets, and so reservations, exist per cost centre only
+        reservation = BudgetReservation(
+            cost_center_id=center.id,
+            amount=5.0,
+            status="active",
+            source_type="print_queue",
+            source_id=item.id,
+            print_archive_id=archive.id,
+        )
+        db_session.add(reservation)
+    await db_session.commit()
+    return archive, item, reservation, center
+
+
+async def _charge(db_session, archive, item, center):
+    return await apply_print_charge_for_archive(
+        db_session,
+        archive.id,
+        charged_user_id=None,
+        cost_center_id=center.id if center else None,
+        print_queue_id=item.id,
+        print_run_id="run-of-a-deleted-user",
+    )
+
+
+async def _charges(db_session) -> list[WalletTransaction]:
+    return list((await db_session.scalars(select(WalletTransaction))).all())
+
+
+async def test_a_cost_centre_print_of_a_deleted_user_is_charged_to_the_cost_centre(db_session):
+    archive, item, reservation, center = await _orphaned_print(db_session, cost_center=True)
+
+    assert await _charge(db_session, archive, item, center) is True
+    await db_session.commit()
+
+    (charge,) = await _charges(db_session)
+    assert charge.user_id is None
+    assert charge.cost_center_id == center.id
+    assert charge.amount == -5.0
+    assert charge.balance_after == -5.0
+    assert charge.print_archive_id == archive.id
+    await db_session.refresh(reservation)
+    assert reservation.status == "consumed"
+
+
+async def test_a_personal_print_of_a_deleted_user_charges_nobody(db_session):
+    """No cost centre at all (started while billing was off): nobody is left to
+    charge, and it holds no reservation."""
+    archive, item, _reservation, center = await _orphaned_print(db_session, cost_center=False)
+
+    assert await _charge(db_session, archive, item, center) is False
+    await db_session.commit()
+
+    assert await _charges(db_session) == []
+
+
+async def test_the_same_run_is_not_charged_twice(db_session):
+    archive, item, _reservation, center = await _orphaned_print(db_session, cost_center=True)
+    await _charge(db_session, archive, item, center)
+    await db_session.commit()
+
+    assert await _charge(db_session, archive, item, center) is False
+    await db_session.commit()
+
+    assert len(await _charges(db_session)) == 1
+
+
+async def test_a_private_cost_centre_of_a_deleted_user_is_charged(db_session):
+    """With billing on a "personal" print goes through the user's own private
+    cost centre, which stays after the user (its owner set to none)."""
+    from backend.app.models.finance import CostCenter
+
+    archive, item, reservation, center = await _orphaned_print(db_session, cost_center=True)
+    private = await db_session.get(CostCenter, center.id)
+    private.is_private = True
+    private.owner_user_id = None
+    await db_session.commit()
+
+    assert await _charge(db_session, archive, item, center) is True
+    await db_session.commit()
+
+    (charge,) = await _charges(db_session)
+    assert charge.user_id is None and charge.cost_center_id == center.id
+    await db_session.refresh(reservation)
+    assert reservation.status == "consumed"
+
+
+async def test_deleting_a_user_with_their_items_releases_their_reservations(async_client, db_session):
+    """Their archives and queue items are deleted, so no completion can settle
+    the reservations (it finds no archive): they held budget for good, and the
+    cost centre could not be deleted."""
+    from backend.app.models.user import User
+
+    user = User(username="leaver", password_hash="x", role="user")
+    db_session.add(user)
+    await db_session.commit()
+    archive, item, reservation, _center = await _orphaned_print(db_session, cost_center=True)
+    archive.created_by_id = user.id
+    item.created_by_id = user.id
+    item.status = "pending"  # not printing yet: it goes, and so does what it held
+    user_id, reservation_id = user.id, reservation.id
+    await db_session.commit()
+
+    response = await async_client.delete(f"/api/v1/users/{user_id}", params={"delete_items": "true"})
+    assert response.status_code in (200, 204), response.text
+
+    db_session.expire_all()
+    reservation = await db_session.get(BudgetReservation, reservation_id)
+    assert reservation.status == "released"
+    assert reservation.released_at is not None
+
+
+async def test_a_reprint_by_a_deleted_user_is_not_put_on_the_archives_owner(db_session):
+    """A queue print's user is the one who queued it. A reprint reuses the
+    source archive, whose creator is someone else: that owner did not print it."""
+    from backend.app.models.user import User
+
+    owner = User(username="archive_owner", password_hash="x", role="user")
+    db_session.add(owner)
+    await db_session.commit()
+    archive, item, _reservation, center = await _orphaned_print(db_session, cost_center=True)
+    archive.created_by_id = owner.id  # the source archive; the queue row's user was deleted
+    await db_session.commit()
+
+    assert await _charge(db_session, archive, item, center) is True
+    await db_session.commit()
+
+    (charge,) = await _charges(db_session)
+    assert charge.user_id is None
+
+
+async def test_deleting_a_user_with_their_items_keeps_a_print_still_running(async_client, db_session):
+    """A running reprint of another user's archive: its queue row went, and the
+    completion then charged the archive's owner, who never printed it. Running,
+    it is detached (no user) instead, and billed to its cost centre as such."""
+    from backend.app.models.user import User
+
+    owner = User(username="archive_owner", password_hash="x", role="user")
+    leaver = User(username="leaver", password_hash="x", role="user")
+    db_session.add_all([owner, leaver])
+    await db_session.commit()
+    archive, item, reservation, center = await _orphaned_print(db_session, cost_center=True)
+    archive.created_by_id = owner.id  # the source archive of the reprint
+    item.created_by_id = leaver.id  # printing
+    leaver_id, item_id, reservation_id = leaver.id, item.id, reservation.id
+    archive_id, center_id = archive.id, center.id
+    await db_session.commit()
+
+    response = await async_client.delete(f"/api/v1/users/{leaver_id}", params={"delete_items": "true"})
+    assert response.status_code in (200, 204), response.text
+
+    db_session.expire_all()
+    kept = await db_session.get(PrintQueueItem, item_id)
+    assert kept is not None and kept.created_by_id is None
+    assert (await db_session.get(BudgetReservation, reservation_id)).status == "active"
+
+    archive = await db_session.get(PrintArchive, archive_id)
+    center = await db_session.get(CostCenter, center_id)
+    assert await _charge(db_session, archive, kept, center) is True
+    await db_session.commit()
+    (charge,) = await _charges(db_session)
+    assert charge.user_id is None
+    assert (await db_session.get(BudgetReservation, reservation_id)).status == "consumed"
+
+
+async def test_deleting_a_user_with_their_items_keeps_their_own_archive_still_printing(async_client, db_session):
+    """Their own archive of a running print: deleted, the completion found no
+    archive and the print went unbilled. Detached, it is billed as "No user"."""
+    from backend.app.models.user import User
+
+    leaver = User(username="leaver", password_hash="x", role="user")
+    db_session.add(leaver)
+    await db_session.commit()
+    archive, item, _reservation, _center = await _orphaned_print(db_session, cost_center=True)
+    archive.status = "printing"
+    archive.created_by_id = leaver.id
+    item.created_by_id = leaver.id
+    leaver_id, archive_id = leaver.id, archive.id
+    await db_session.commit()
+
+    response = await async_client.delete(f"/api/v1/users/{leaver_id}", params={"delete_items": "true"})
+    assert response.status_code in (200, 204), response.text
+
+    db_session.expire_all()
+    kept = await db_session.get(PrintArchive, archive_id)
+    assert kept is not None and kept.created_by_id is None
+
+
+async def test_a_print_whose_cost_comes_out_as_zero_releases_its_reservation(db_session):
+    """An older or imported archive with grams but no stored cost is reserved
+    from the estimate; at completion its cost is none, and the charge returned
+    before releasing the reservation, which then held budget for good."""
+    archive, item, reservation, center = await _orphaned_print(db_session, cost_center=True)
+    archive.cost = None
+    reservation_id = reservation.id
+    await db_session.commit()
+
+    assert await _charge(db_session, archive, item, center) is False
+    await db_session.commit()
+
+    assert await _charges(db_session) == []
+    db_session.expire_all()
+    assert (await db_session.get(BudgetReservation, reservation_id)).status == "released"
+
+
+async def test_deleting_a_user_with_their_items_keeps_their_archive_another_user_is_printing(async_client, db_session):
+    """Another user's reprint of their archive, dispatched (queue row printing)
+    before the printer's start marked the archive printing: deleted, the archive
+    took that user's queue row with it and their print was never billed."""
+    from backend.app.models.user import User
+
+    leaver = User(username="leaver", password_hash="x", role="user")
+    printer_user = User(username="reprinter", password_hash="x", role="user")
+    db_session.add_all([leaver, printer_user])
+    await db_session.commit()
+    archive, item, reservation, _center = await _orphaned_print(db_session, cost_center=True)
+    archive.created_by_id = leaver.id  # still "completed": the start has not come yet
+    item.created_by_id = printer_user.id  # their row, printing
+    leaver_id, archive_id, item_id, reservation_id = leaver.id, archive.id, item.id, reservation.id
+    printer_user_id = printer_user.id
+    await db_session.commit()
+
+    response = await async_client.delete(f"/api/v1/users/{leaver_id}", params={"delete_items": "true"})
+    assert response.status_code in (200, 204), response.text
+
+    db_session.expire_all()
+    kept = await db_session.get(PrintArchive, archive_id)
+    assert kept is not None and kept.created_by_id is None
+    assert (await db_session.get(PrintQueueItem, item_id)).created_by_id == printer_user_id
+    assert (await db_session.get(BudgetReservation, reservation_id)).status == "active"
+
+
+async def test_a_billing_user_deleted_just_before_the_charge_counts_as_none(db_session):
+    """The completion read the queue row's user, then the user was deleted
+    before the charge (seconds of usage and archive work in between): the id
+    names nobody. PostgreSQL refused the wallet for it (FK) and the reservation
+    stayed active; SQLite wrote a wallet and a charge for a missing user."""
+    from backend.app.models.finance import UserWallet
+
+    archive, item, reservation, center = await _orphaned_print(db_session, cost_center=True)
+    reservation_id = reservation.id
+    gone_user_id = 987654  # no such user any more
+
+    assert (
+        await apply_print_charge_for_archive(
+            db_session,
+            archive.id,
+            charged_user_id=gone_user_id,
+            cost_center_id=center.id,
+            print_queue_id=item.id,
+            print_run_id="run-of-a-deleted-user",
+        )
+        is True
+    )
+    await db_session.commit()
+
+    (charge,) = await _charges(db_session)
+    assert charge.user_id is None
+    assert await db_session.scalar(select(UserWallet).where(UserWallet.user_id == gone_user_id)) is None
+    db_session.expire_all()
+    assert (await db_session.get(BudgetReservation, reservation_id)).status == "consumed"

+ 7 - 4
frontend/src/__tests__/pages/FinanceDeletedUser.test.tsx

@@ -1,8 +1,10 @@
 /**
  * What a deleted user leaves in the finance admin view: ledger entries without
  * a user (kept, so a shared cost centre stays spent) and private cost centres
- * without an owner. Both say so, rather than "User #null" or "Personal", which
- * read as the viewing admin's own.
+ * without an owner. Neither reads as "User #null" or "Personal", which look
+ * like the viewing admin's own. An entry without a user says "No user": with
+ * auth off a print never had one, so "Deleted user" would be wrong there. A
+ * private centre is always created with an owner, so none means deleted.
  */
 
 import { describe, it, expect } from 'vitest';
@@ -45,7 +47,7 @@ const orphanedEntry = {
 };
 
 describe('FinancePage after a user was deleted', () => {
-  it('labels their ledger entries and their private cost centre as a deleted user', async () => {
+  it('labels their ledger entries as having no user and their private cost centre as a deleted user', async () => {
     server.use(
       http.get('*/api/v1/settings/ui-flags', () => HttpResponse.json({ billing_enabled: true, currency: 'USD' })),
       http.get('*/api/v1/finance/me/balance', () =>
@@ -64,7 +66,8 @@ describe('FinancePage after a user was deleted', () => {
     await userEvent.click(await screen.findByText('Admin view'));
 
     const entry = (await screen.findByText('Left by a deleted user')).closest('tr') as HTMLElement;
-    expect(within(entry).getByText('Deleted user')).toBeInTheDocument();
+    expect(within(entry).getByText('No user')).toBeInTheDocument();
+    expect(within(entry).queryByText('Deleted user')).not.toBeInTheDocument();
     await waitFor(() => {
       const rows = screen
         .getAllByText('Alice private')

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

@@ -204,6 +204,7 @@ export default {
     balanceAfter: 'Kontostand danach',
     userWithId: 'Benutzer #{{id}}',
     deletedUser: 'Gelöschter Benutzer',
+    noUser: 'Kein Benutzer',
     partial: 'Teilweise',
     editTransaction: 'Transaktion bearbeiten',
     selectCostCenter: 'Kostenstelle auswählen...',

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

@@ -204,6 +204,7 @@ export default {
     balanceAfter: 'Balance after',
     userWithId: 'User #{{id}}',
     deletedUser: 'Deleted user',
+    noUser: 'No user',
     partial: 'Partial',
     editTransaction: 'Edit Transaction',
     selectCostCenter: 'Select cost center...',

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

@@ -204,6 +204,7 @@ export default {
     balanceAfter: 'Saldo posterior',
     userWithId: 'Usuario n.º {{id}}',
     deletedUser: 'Usuario eliminado',
+    noUser: 'Sin usuario',
     partial: 'Parcial',
     editTransaction: 'Editar transacción',
     selectCostCenter: 'Seleccionar centro de costes...',

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

@@ -204,6 +204,7 @@ export default {
     balanceAfter: 'Solde après',
     userWithId: 'Utilisateur #{{id}}',
     deletedUser: 'Utilisateur supprimé',
+    noUser: 'Aucun utilisateur',
     partial: 'Partiel',
     editTransaction: 'Modifier la transaction',
     selectCostCenter: 'Sélectionner un centre de coûts...',

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

@@ -204,6 +204,7 @@ export default {
     balanceAfter: 'Saldo successivo',
     userWithId: 'Utente #{{id}}',
     deletedUser: 'Utente eliminato',
+    noUser: 'Nessun utente',
     partial: 'Parziale',
     editTransaction: 'Modifica transazione',
     selectCostCenter: 'Seleziona centro di costo...',

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

@@ -204,6 +204,7 @@ export default {
     balanceAfter: '調整後残高',
     userWithId: 'ユーザー #{{id}}',
     deletedUser: '削除されたユーザー',
+    noUser: 'ユーザーなし',
     partial: '部分',
     editTransaction: 'トランザクションを編集',
     selectCostCenter: 'コストセンターを選択...',

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

@@ -200,6 +200,7 @@ export default {
     balanceAfter: '거래 후 잔액',
     userWithId: '사용자 #{{id}}',
     deletedUser: '삭제된 사용자',
+    noUser: '사용자 없음',
     partial: '일부',
     editTransaction: '거래 편집',
     selectCostCenter: '비용 센터 선택...',

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

@@ -204,6 +204,7 @@ export default {
     balanceAfter: 'Saldo na',
     userWithId: 'Gebruiker #{{id}}',
     deletedUser: 'Verwijderde gebruiker',
+    noUser: 'Geen gebruiker',
     partial: 'Gedeeltelijk',
     editTransaction: 'Transactie bewerken',
     selectCostCenter: 'Kostenplaats selecteren...',

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

@@ -204,6 +204,7 @@ export default {
     balanceAfter: 'Saldo após',
     userWithId: 'Usuário #{{id}}',
     deletedUser: 'Usuário excluído',
+    noUser: 'Sem usuário',
     partial: 'Parcial',
     editTransaction: 'Editar transação',
     selectCostCenter: 'Selecionar centro de custo...',

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

@@ -200,6 +200,7 @@ export default {
     balanceAfter: "Баланс после операции",
     userWithId: "Пользователь №{{id}}",
     deletedUser: "Удалённый пользователь",
+    noUser: "Без пользователя",
     partial: "Частично",
     editTransaction: "Изменить операцию",
     selectCostCenter: "Выберите центр затрат...",

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

@@ -204,6 +204,7 @@ export default {
     balanceAfter: 'Saldo efter',
     userWithId: 'Användare #{{id}}',
     deletedUser: 'Borttagen användare',
+    noUser: 'Ingen användare',
     partial: 'Delvis',
     editTransaction: 'Redigera transaktion',
     selectCostCenter: 'Välj kostnadsställe...',

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

@@ -204,6 +204,7 @@ export default {
     balanceAfter: 'İşlem sonrası bakiye',
     userWithId: 'Kullanıcı #{{id}}',
     deletedUser: 'Silinmiş kullanıcı',
+    noUser: 'Kullanıcı yok',
     partial: 'Kısmi',
     editTransaction: 'İşlemi düzenle',
     selectCostCenter: 'Masraf merkezi seç...',

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

@@ -204,6 +204,7 @@ export default {
     balanceAfter: "Баланс після операції",
     userWithId: "Користувач №{{id}}",
     deletedUser: "Видалений користувач",
+    noUser: "Без користувача",
     partial: "Частково",
     editTransaction: "Редагувати операцію",
     selectCostCenter: "Виберіть центр витрат...",

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

@@ -204,6 +204,7 @@ export default {
     balanceAfter: '调整后余额',
     userWithId: '用户 #{{id}}',
     deletedUser: '已删除的用户',
+    noUser: '无用户',
     partial: '部分',
     editTransaction: '编辑交易',
     selectCostCenter: '选择成本中心...',

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

@@ -204,6 +204,7 @@ export default {
     balanceAfter: '調整後餘額',
     userWithId: '使用者 #{{id}}',
     deletedUser: '已刪除的使用者',
+    noUser: '無使用者',
     partial: '部分',
     editTransaction: '編輯交易',
     selectCostCenter: '選擇成本中心...',

+ 2 - 1
frontend/src/pages/FinancePage.tsx

@@ -1206,8 +1206,9 @@ export function FinancePage() {
                       const txCostCenter = tx.cost_center_id == null
                         ? null
                         : (costCenters || []).find((center) => center.id === tx.cost_center_id);
+                      // No user: deleted since, or with auth off never had one.
                       const txUser = tx.user_id == null
-                        ? t('finance.deletedUser', 'Deleted user')
+                        ? t('finance.noUser', 'No user')
                         : usersById.get(tx.user_id) || t('finance.userWithId', 'User #{{id}}', { id: tx.user_id });
                       const parsed = tx.transaction_type === 'print_charge'
                         ? parsePrintChargeDescription((tx as { description: string | null }).description ?? null)

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/AdditionalSection-BYVdgtFJ.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/AmsUnitCard-DgMS5Oh5.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ArchivesPage-DIsMdGVZ.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/AssignToAmsModal-Di1ZW_Ml.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/CamWallPage-DsQAKx5y.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/CameraDiagnoseModal-aEQUh6Bb.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/CameraPage-CF0rka0w.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/CameraWall-Bv_B8aC4.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ColumnConfigModal-D_0mg7E6.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ConfigureAmsSlotModal-BVSkTZnH.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ConnectAuthorizePage-DusEf8ha.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ContextMenu-CRmGk4Vx.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ExternalLinkPage-BIaJ_-O8.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 1
static/assets/FileManagerPage-CnnuugFL.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/FileUploadModal-BydK3uWr.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/FinancePage-BHGW2T01.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/GCodeViewerPage-D2eedBjV.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/GroupEditPage-C8-u0ZQk.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ImagePreviewModal-CtcnpTdJ.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/InventoryPage-Dj7QrHDx.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/LibraryTrashPage-CQwm9n2w.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/LoginPage-Cq3CqbNb.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/MaintenancePage-D6z-yHPp.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ModelSourcesPage-DWi2HTBa.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/NotificationsPage-beoswXGn.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 1
static/assets/PdfPreviewModal-BuYpsscN.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/PhotoGalleryModal-DTaWUdJP.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/PrinterLocationsPage-CYMyhV59.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/PrintersPage-DM9aOSkD.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ProfilesPage-CBhFUinM.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ProjectDetailPage-2q_iWeqJ.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ProjectsPage-DSK-6Si9.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/QueuePage-BvSkLTD6.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/RichTextEditor-D6QLCbi9.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SettingsPage-U3dnzGCV.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SetupPage-CD1wXyCU.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SliceModal-DsHOFwMx.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpoolBuddyAmsPage-Bypzvy0M.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-D5a_rY1W.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpoolBuddyDashboard-tqTIH2wL.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-7QsTwW6k.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 1
static/assets/SpoolBuddySettingsPage-Bm_I5pFj.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-YQkcr5kv.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpreadsheetPreviewModal-D89O3ggF.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/StatsPage-B0SsYqzc.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/StreamOverlayPage-DkyRhxvH.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SystemInfoPage-BllN6HgM.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/de-d4f0ctP_.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/de-m6eKVbb2.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/es-Cjs7c4Fr.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/es-Dj4End9b.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/fr-BUDFE3iT.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/fr-D6mzxySl.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-DUKqgwNl.js


+ 1 - 1
static/assets/inventorySearch-D_c9vB-F.js → static/assets/inventorySearch-mXpLV720.js

@@ -1 +1 @@
-import{ft as e}from"./index-DPM_Eoz0.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
+import{ft as e}from"./index-DUKqgwNl.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/it-BBKk6UPP.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/it-CFF2YIPs.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ja-BzdPx63j.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ja-qS_D-r1-.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ko-B-kp19gj.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ko-Ta4cxLLC.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/locationSensorDefaults-Dq8gENPa.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/nl-DqDyb0GB.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/nl-IxfSpKfq.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/pdf-Brd6mhHG.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/pt-BR-DnvNDzJ-.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/pt-BR-iyc59etW.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ru-BWVuXLjw.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ru-DyqHcVB_.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/sv-CW52pk2Y.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/sv-D2FAMtpx.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/tr-DBlfzO3S.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/tr-NrNjpPlr.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/uk-3EyJHzFg.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/uk-B9kpp-m6.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/utils-i7ktJI-2.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/zh-CN-Dl5y5riy.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/zh-CN-DslIGhDJ.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/zh-TW-b1Op5Sk9.js


Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů