Просмотр исходного кода

fix(inventory): a used weight entered for a new spool is kept; Spoolman last_used means print use; the slot K picker accounts for nozzle flow

- Create, Add copies and CSV import lock a used weight above 0 (an
  explicit weight_locked wins); a negative used weight is refused on create
  and edit, more than the label is still taken.
- update_spool no longer stamps last_used: syncs, location changes and
  weighing are not use. Print usage goes through Spoolman's /use, which sets it.
- ConfigureAmsSlotModal matches K-profiles to the fitted nozzle's flow
  (others under Other) and labels each [HF] / [S]; the spool form uses the
  same flowApplies helper.
maziggy 4 дней назад
Родитель
Сommit
f7bd06641a
64 измененных файлов с 216 добавлено и 23 удалено
  1. 3 0
      CHANGELOG.md
  2. 13 1
      backend/app/api/routes/inventory.py
  3. 5 2
      backend/app/schemas/spool.py
  4. 7 5
      backend/app/services/spoolman.py
  5. 85 0
      backend/tests/integration/test_spool_weight_entry.py
  6. 33 0
      backend/tests/unit/services/test_spoolman_last_used.py
  7. 32 2
      frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx
  8. 32 6
      frontend/src/components/ConfigureAmsSlotModal.tsx
  9. 2 2
      frontend/src/components/spool-form/PrinterProfilesSection.tsx
  10. 0 0
      static/assets/AdditionalSection-OMNzflsQ.js
  11. 0 0
      static/assets/AmsUnitCard-DLmpqWm7.js
  12. 0 0
      static/assets/ArchivesPage-Bb0Td7so.js
  13. 0 0
      static/assets/AssignToAmsModal-LVBEVWdj.js
  14. 0 0
      static/assets/CamWallPage-BcG1K89k.js
  15. 0 0
      static/assets/CameraDiagnoseModal-CR3za315.js
  16. 0 0
      static/assets/CameraPage-BAtB6UpC.js
  17. 0 0
      static/assets/CameraWall-K1MJhr_0.js
  18. 0 0
      static/assets/ColumnConfigModal-d9F25Sh2.js
  19. 0 0
      static/assets/ConfigureAmsSlotModal-CJs6Rs0R.js
  20. 0 0
      static/assets/ConfigureAmsSlotModal-Dkl5UliL.js
  21. 0 0
      static/assets/ConnectAuthorizePage-jbzIvisb.js
  22. 0 0
      static/assets/ContextMenu-BBd6nd-x.js
  23. 0 0
      static/assets/ExternalLinkPage-Bj_3ndby.js
  24. 0 1
      static/assets/FileManagerPage-DWAE6fEF.js
  25. 0 0
      static/assets/FileUploadModal-CsmsvE7L.js
  26. 0 0
      static/assets/FinancePage-BHXLP3O4.js
  27. 0 0
      static/assets/GCodeViewerPage-CEj4yDwk.js
  28. 0 0
      static/assets/GroupEditPage-Ldvql2kA.js
  29. 0 0
      static/assets/ImagePreviewModal-CihNBZ7M.js
  30. 0 0
      static/assets/InventoryPage-LT8XtYDw.js
  31. 0 0
      static/assets/InventoryPage-W_60I-qs.js
  32. 0 0
      static/assets/LibraryTrashPage-CIXiHCgj.js
  33. 0 0
      static/assets/LoginPage-B8DXGTtD.js
  34. 0 0
      static/assets/MaintenancePage-BYHgKmNM.js
  35. 0 0
      static/assets/ModelSourcesPage-D46rCpeq.js
  36. 0 0
      static/assets/NotificationsPage-B-Md2hru.js
  37. 0 1
      static/assets/PdfPreviewModal-Dmqen3Su.js
  38. 0 0
      static/assets/PhotoGalleryModal-iZNGhagJ.js
  39. 0 0
      static/assets/PrinterLocationsPage-DjHTvw-c.js
  40. 0 0
      static/assets/PrintersPage-DSyNMnS_.js
  41. 0 0
      static/assets/ProfilesPage-DH7pS5f_.js
  42. 0 0
      static/assets/ProjectDetailPage-DbbhqCAE.js
  43. 0 0
      static/assets/ProjectsPage-Cp9hkmar.js
  44. 0 0
      static/assets/QueuePage-B789u2wa.js
  45. 0 0
      static/assets/RichTextEditor-D1mAmsBG.js
  46. 0 0
      static/assets/SettingsPage-CDUHI0aZ.js
  47. 0 0
      static/assets/SetupPage-B1l8RIBo.js
  48. 0 0
      static/assets/SliceModal-CT-eMuYF.js
  49. 0 0
      static/assets/SpoolBuddyAmsPage-ByB2Nn0X.js
  50. 0 0
      static/assets/SpoolBuddyDashboard-55sCVP_J.js
  51. 0 0
      static/assets/SpoolBuddyInventoryPage-mu2aGIQn.js
  52. 1 1
      static/assets/SpoolBuddySettingsPage-D0wlQVm0.js
  53. 0 0
      static/assets/SpoolBuddyWriteTagPage-D5CgBP4m.js
  54. 0 0
      static/assets/SpreadsheetPreviewModal-wjtvEvk6.js
  55. 0 0
      static/assets/StatsPage-b1DqF9V1.js
  56. 0 0
      static/assets/StreamOverlayPage-DQsrnFtr.js
  57. 0 0
      static/assets/SystemInfoPage-CAnaYPd-.js
  58. 0 0
      static/assets/index-DAlcmDFx.js
  59. 1 1
      static/assets/inventorySearch-DTH3xXZS.js
  60. 0 0
      static/assets/locationSensorDefaults-CMRgKewn.js
  61. 1 0
      static/assets/nozzleFlow-vrXwck9b.js
  62. 0 0
      static/assets/pdf-Dm8l9tqC.js
  63. 0 0
      static/assets/utils-CWU-DmvD.js
  64. 1 1
      static/index.html

+ 3 - 0
CHANGELOG.md

@@ -164,6 +164,9 @@ 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.
 - **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
 ### Fixed
+- **A used weight entered for a new spool is kept** — Adding a spool (one, several with **Add copies**, or from a CSV) left its weight unlocked even when a used weight was entered, so the automatic AMS sync could overwrite a part-used spool's weight on its first pass. A used weight entered at creation now locks the spool, as editing the weight already did. A negative used weight is refused; more than the label weight is still accepted, since a mis-labelled spool really can give more.
+- **Spoolman's "last used" means a print used the spool** — Every AMS sync stamped *last used* on every loaded Spoolman spool, as did clearing a removed spool's location and weighing on the SpoolBuddy scale. Only print usage sets it now (through Spoolman's own usage endpoint), as in built-in inventory.
+- **The AMS slot K-profile picker accounts for the nozzle's flow** — On printers that report a high-flow or standard nozzle (H2-series), the picker offered and pre-selected K-profiles measured on the other kind, which are not applied when the slot is configured. They now sit under **Other**, and every profile shows its flow ([HF] / [S]), as in the spool form.
 - **Printer temperature history retention can be set, and no retention setting can wipe history** — How long the nozzle, bed and chamber temperature history is kept had no setting in the interface; it now sits next to the AMS history under **Settings → Filament → History Retention**. Both settings take at least one day: 0 or less was accepted through the API and made the daily cleanup delete all history, and a value stored that way is now read as one day.
 - **Printer temperature history retention can be set, and no retention setting can wipe history** — How long the nozzle, bed and chamber temperature history is kept had no setting in the interface; it now sits next to the AMS history under **Settings → Filament → History Retention**. Both settings take at least one day: 0 or less was accepted through the API and made the daily cleanup delete all history, and a value stored that way is now read as one day.
 - **Bug reports no longer keep the reporter's email** — Every report sent from Bambuddy was also stored locally with the email it was sent with, and nothing ever read or removed it. The email still goes with the report; the local record keeps only the outcome, and emails stored before are cleared at startup.
 - **Bug reports no longer keep the reporter's email** — Every report sent from Bambuddy was also stored locally with the email it was sent with, and nothing ever read or removed it. The email still goes with the report; the local record keeps only the outcome, and emails stored before are cleared at startup.
 - **Unused API pieces removed and the rest documented** — The firmware upload no longer broadcasts a WebSocket progress message nothing listened to (the page polls the upload status, as before); the old route that deleted every plate-detection calibration of a printer at once is gone (references are deleted one at a time from the plate-detection dialog); `/spoolbuddy/calibration` now opens the SpoolBuddy settings page, where calibration lives. The wiki now documents `POST /archives/upload-source` for slicer post-processing scripts and `PUT /local-presets/{id}`.
 - **Unused API pieces removed and the rest documented** — The firmware upload no longer broadcasts a WebSocket progress message nothing listened to (the page polls the upload status, as before); the old route that deleted every plate-detection calibration of a printer at once is gone (references are deleted one at a time from the plate-detection dialog); `/spoolbuddy/calibration` now opens the SpoolBuddy settings page, where calibration lives. The wiki now documents `POST /archives/upload-source` for slicer post-processing scripts and `PUT /local-presets/{id}`.

+ 13 - 1
backend/app/api/routes/inventory.py

@@ -1480,7 +1480,8 @@ async def import_spools_csv(
             # the other create paths: the file is authoritative. A CSV that
             # the other create paths: the file is authoritative. A CSV that
             # leaves the column blank is stating "no number", not asking for
             # leaves the column blank is stating "no number", not asking for
             # one to be guessed from whatever else is in the inventory.
             # one to be guessed from whatever else is in the inventory.
-            spool = Spool(**row.spool)
+            # The file has no lock column: a used weight in it is locked.
+            spool = Spool(**_lock_entered_weight(dict(row.spool), set()))
             db.add(spool)
             db.add(spool)
             # Supplier assignments resolved by name during parsing (#2988).
             # Supplier assignments resolved by name during parsing (#2988).
             # Flush first so the spool has an id to hang the links on.
             # Flush first so the spool has an id to hang the links on.
@@ -1565,6 +1566,15 @@ async def get_spool(
     return spool
     return spool
 
 
 
 
+def _lock_entered_weight(payload: dict, fields_set: set[str]) -> dict:
+    """A used weight entered for a new spool is locked, as an edited one is:
+    otherwise the AMS remain% sync can overwrite it on its first pass. An
+    explicit ``weight_locked`` from the client wins."""
+    if (payload.get("weight_used") or 0) > 0 and "weight_locked" not in fields_set:
+        payload["weight_locked"] = True
+    return payload
+
+
 @router.post("/spools", response_model=SpoolResponse)
 @router.post("/spools", response_model=SpoolResponse)
 async def create_spool(
 async def create_spool(
     spool_data: SpoolCreate,
     spool_data: SpoolCreate,
@@ -1576,6 +1586,7 @@ async def create_spool(
         payload = await prepare_internal_spool_payload(db, spool_data.model_dump(), set(spool_data.model_fields_set))
         payload = await prepare_internal_spool_payload(db, spool_data.model_dump(), set(spool_data.model_fields_set))
     except ValueError as exc:
     except ValueError as exc:
         raise HTTPException(status_code=400, detail=str(exc)) from exc
         raise HTTPException(status_code=400, detail=str(exc)) from exc
+    payload = _lock_entered_weight(payload, set(spool_data.model_fields_set))
     # A new spool of an already-numbered product inherits its material number (#2870).
     # A new spool of an already-numbered product inherits its material number (#2870).
     payload = await apply_material_number_inheritance(db, payload)
     payload = await apply_material_number_inheritance(db, payload)
     spool = Spool(**payload)
     spool = Spool(**payload)
@@ -1604,6 +1615,7 @@ async def bulk_create_spools(
         payload = await prepare_internal_spool_payload(db, data.spool.model_dump(), fields_set)
         payload = await prepare_internal_spool_payload(db, data.spool.model_dump(), fields_set)
     except ValueError as exc:
     except ValueError as exc:
         raise HTTPException(status_code=400, detail=str(exc)) from exc
         raise HTTPException(status_code=400, detail=str(exc)) from exc
+    payload = _lock_entered_weight(payload, fields_set)
     # A new spool of an already-numbered product inherits its material number (#2870).
     # A new spool of an already-numbered product inherits its material number (#2870).
     payload = await apply_material_number_inheritance(db, payload)
     payload = await apply_material_number_inheritance(db, payload)
     for _ in range(data.quantity):
     for _ in range(data.quantity):

+ 5 - 2
backend/app/schemas/spool.py

@@ -167,7 +167,10 @@ class SpoolBase(BaseModel):
 
 
 
 
 class SpoolCreate(SpoolBase):
 class SpoolCreate(SpoolBase):
-    pass
+    # Never negative from a client; more than the label is possible (print
+    # tracking of a mis-labelled spool). Here, not on SpoolBase: a response
+    # must still serialise a value stored before there was a bound.
+    weight_used: float = Field(default=0, ge=0)
 
 
 
 
 class SpoolBulkCreate(BaseModel):
 class SpoolBulkCreate(BaseModel):
@@ -198,7 +201,7 @@ class SpoolUpdate(IgnoresNullForRequiredColumns):
     label_weight: int | None = None
     label_weight: int | None = None
     core_weight: int | None = None
     core_weight: int | None = None
     core_weight_catalog_id: int | None = None
     core_weight_catalog_id: int | None = None
-    weight_used: float | None = None
+    weight_used: float | None = Field(default=None, ge=0)
     slicer_filament: str | None = None
     slicer_filament: str | None = None
     slicer_filament_name: str | None = None
     slicer_filament_name: str | None = None
     nozzle_temp_min: int | None = None
     nozzle_temp_min: int | None = None

+ 7 - 5
backend/app/services/spoolman.py

@@ -7,7 +7,6 @@ import math
 import time
 import time
 import weakref
 import weakref
 from dataclasses import dataclass
 from dataclasses import dataclass
-from datetime import datetime, timezone
 from typing import Literal
 from typing import Literal
 
 
 import httpx
 import httpx
@@ -458,7 +457,12 @@ class SpoolmanClient:
         clear_location: bool = False,
         clear_location: bool = False,
         extra: dict | None = None,
         extra: dict | None = None,
     ) -> dict:
     ) -> dict:
-        """Update an existing spool in Spoolman, always setting last_used."""
+        """Update an existing spool in Spoolman.
+
+        Leaves ``last_used`` alone: syncs, location changes and weighing are not
+        use. Print usage goes through ``use_spool`` (Spoolman's /use endpoint),
+        which sets it.
+        """
         data: dict = {}
         data: dict = {}
         if remaining_weight is not None:
         if remaining_weight is not None:
             data["remaining_weight"] = remaining_weight
             data["remaining_weight"] = remaining_weight
@@ -469,8 +473,6 @@ class SpoolmanClient:
         if extra:
         if extra:
             data["extra"] = extra
             data["extra"] = extra
             await self._ensure_extra_fields(extra)
             await self._ensure_extra_fields(extra)
-        data["last_used"] = datetime.now(timezone.utc).isoformat()
-
         response = await self._request_spool("PATCH", spool_id, json_body=data, operation="update")
         response = await self._request_spool("PATCH", spool_id, json_body=data, operation="update")
         return response.json()
         return response.json()
 
 
@@ -741,7 +743,7 @@ class SpoolmanClient:
         clear_spool_weight: bool = False,
         clear_spool_weight: bool = False,
         initial_weight: float | None = None,
         initial_weight: float | None = None,
     ) -> dict:
     ) -> dict:
-        """Update a spool with full field support; unlike update_spool, does not auto-set last_used."""
+        """Update a spool with full field support; like update_spool, leaves last_used alone."""
         data: dict = {}
         data: dict = {}
         if filament_id is not None:
         if filament_id is not None:
             data["filament_id"] = filament_id
             data["filament_id"] = filament_id

+ 85 - 0
backend/tests/integration/test_spool_weight_entry.py

@@ -0,0 +1,85 @@
+"""A weight entered for a new spool is kept, and a negative one is refused.
+
+Creating a spool (one, several, or from a CSV) left ``weight_locked`` off even
+when a used weight was entered, so the AMS remain% sync could overwrite a
+part-used spool's weight on its first pass; editing a spool's weight locks it.
+A weight above 0 entered at creation is now locked too. And ``weight_used`` had
+no lower bound in the API: a negative value gave a spool more than its label.
+Print tracking may still take a spool past its label (mis-labelled spools).
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.spool import Spool
+from backend.tests.integration.test_inventory_csv import _csv_upload
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+BASE = {"material": "PLA", "brand": "Bambu", "color_name": "White", "rgba": "FFFFFFFF", "label_weight": 1000}
+
+
+async def _spools(db_session):
+    db_session.expire_all()
+    return (await db_session.execute(select(Spool).order_by(Spool.id))).scalars().all()
+
+
+@pytest.mark.parametrize(("weight_used", "locked"), [(250, True), (0, False)])
+async def test_a_new_spool_keeps_the_weight_entered(async_client: AsyncClient, weight_used, locked):
+    response = await async_client.post("/api/v1/inventory/spools", json={**BASE, "weight_used": weight_used})
+
+    assert response.status_code == 200, response.text
+    assert response.json()["weight_locked"] is locked
+
+
+async def test_an_explicit_unlock_is_respected(async_client: AsyncClient):
+    response = await async_client.post(
+        "/api/v1/inventory/spools", json={**BASE, "weight_used": 250, "weight_locked": False}
+    )
+
+    assert response.json()["weight_locked"] is False
+
+
+async def test_copies_keep_the_weight_entered(async_client: AsyncClient, db_session):
+    response = await async_client.post(
+        "/api/v1/inventory/spools/bulk", json={"spool": {**BASE, "weight_used": 400}, "quantity": 2}
+    )
+
+    assert response.status_code == 200, response.text
+    assert [s.weight_locked for s in await _spools(db_session)] == [True, True]
+
+
+async def test_an_imported_weight_is_kept(async_client: AsyncClient, db_session):
+    csv_text = "material,brand,color_name,weight_used\nPLA,Bambu,White,300\nPETG,Bambu,Black,0\n"
+
+    response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
+
+    assert response.status_code == 200, response.text
+    by_material = {s.material: s.weight_locked for s in await _spools(db_session)}
+    assert by_material == {"PLA": True, "PETG": False}
+
+
+async def test_a_negative_weight_is_refused(async_client: AsyncClient):
+    created = await async_client.post("/api/v1/inventory/spools", json={**BASE, "weight_used": -10})
+    assert created.status_code == 422
+
+    spool = (await async_client.post("/api/v1/inventory/spools", json=BASE)).json()
+    edited = await async_client.patch(f"/api/v1/inventory/spools/{spool['id']}", json={"weight_used": -1})
+    assert edited.status_code == 422
+
+
+async def test_more_than_the_label_is_still_taken(async_client: AsyncClient):
+    """A mis-labelled spool really can give more than its label."""
+    response = await async_client.post("/api/v1/inventory/spools", json={**BASE, "weight_used": 1100})
+
+    assert response.status_code == 200, response.text
+
+
+async def test_a_negative_weight_stored_before_still_lists(async_client: AsyncClient, db_session):
+    db_session.add(Spool(material="PLA", label_weight=1000, core_weight=250, weight_used=-20))
+    await db_session.commit()
+
+    response = await async_client.get("/api/v1/inventory/spools")
+
+    assert response.status_code == 200, response.text

+ 33 - 0
backend/tests/unit/services/test_spoolman_last_used.py

@@ -0,0 +1,33 @@
+"""Spoolman's "last used" means a print used the spool.
+
+``update_spool`` stamped ``last_used = now`` on every call, and it is called by
+the AMS sync for every loaded spool, by clearing a removed spool's location and
+by weighing on the SpoolBuddy scale: every sync marked every loaded spool as
+just used. Print usage goes through Spoolman's /use endpoint, which sets
+last_used itself (Spoolman's database.spool.use_weight), so update_spool no
+longer touches it. Local mode already behaved this way.
+"""
+
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from backend.app.services.spoolman import SpoolmanClient
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "kwargs",
+    [{"remaining_weight": 500.0}, {"location": "Printer 1 AMS A"}, {"clear_location": True}],
+    ids=["weight", "location", "clear-location"],
+)
+async def test_an_update_does_not_mark_the_spool_used(kwargs):
+    client = SpoolmanClient("http://spoolman.invalid")
+    response = MagicMock()
+    response.json.return_value = {"id": 3}
+    client._request_spool = AsyncMock(return_value=response)
+
+    await client.update_spool(spool_id=3, **kwargs)
+
+    body = client._request_spool.await_args.kwargs["json_body"]
+    assert "last_used" not in body

+ 32 - 2
frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx

@@ -29,6 +29,8 @@ vi.mock('../../api/client', () => ({
     searchColors: vi.fn(),
     searchColors: vi.fn(),
     getColorCatalog: vi.fn(),
     getColorCatalog: vi.fn(),
     resetAmsSlot: vi.fn(),
     resetAmsSlot: vi.fn(),
+    // The fitted nozzle's flow; none reported unless a test sets one.
+    getPrinterStatus: vi.fn().mockResolvedValue({}),
   },
   },
 }));
 }));
 
 
@@ -80,6 +82,7 @@ const defaultProps = {
 describe('ConfigureAmsSlotModal', () => {
 describe('ConfigureAmsSlotModal', () => {
   beforeEach(() => {
   beforeEach(() => {
     vi.clearAllMocks();
     vi.clearAllMocks();
+    (api.getPrinterStatus as ReturnType<typeof vi.fn>).mockResolvedValue({});
     // Mock scrollIntoView which is not available in jsdom
     // Mock scrollIntoView which is not available in jsdom
     Element.prototype.scrollIntoView = vi.fn();
     Element.prototype.scrollIntoView = vi.fn();
     (api.getCloudSettings as ReturnType<typeof vi.fn>).mockResolvedValue(mockCloudSettings);
     (api.getCloudSettings as ReturnType<typeof vi.fn>).mockResolvedValue(mockCloudSettings);
@@ -778,7 +781,7 @@ describe('ConfigureAmsSlotModal', () => {
       // own calibration table for GFL99.
       // own calibration table for GFL99.
       for (const profile of genericPlaProfiles) {
       for (const profile of genericPlaProfiles) {
         expect(
         expect(
-          screen.getByRole('option', { name: `${profile.name} (K=${profile.k_value})` }),
+          screen.getByRole('option', { name: `[HF] ${profile.name} (K=${profile.k_value})` }),
         ).toBeInTheDocument();
         ).toBeInTheDocument();
       }
       }
     });
     });
@@ -834,7 +837,7 @@ describe('ConfigureAmsSlotModal', () => {
       // Every GFL99 profile is demoted to the other group: the brand gate on
       // Every GFL99 profile is demoted to the other group: the brand gate on
       // "Bambu" still applies, and none of these names carry it.
       // "Bambu" still applies, and none of these names carry it.
       for (const profile of genericPlaProfiles) {
       for (const profile of genericPlaProfiles) {
-        const option = screen.getByRole('option', { name: `${profile.name} (K=${profile.k_value})` });
+        const option = screen.getByRole('option', { name: `[HF] ${profile.name} (K=${profile.k_value})` });
         expect(option.closest('optgroup')).toHaveAttribute(
         expect(option.closest('optgroup')).toHaveAttribute(
           'label',
           'label',
           'Other K profiles on this printer',
           'Other K profiles on this printer',
@@ -1017,6 +1020,32 @@ describe('ConfigureAmsSlotModal', () => {
       expect(other.parentElement?.tagName).toBe('OPTGROUP');
       expect(other.parentElement?.tagName).toBe('OPTGROUP');
     });
     });
 
 
+    it('offers the profiles of the fitted nozzle flow, the others under Other with their flow', async () => {
+      // H2D with a standard nozzle fitted: the high-flow copy of the same
+      // filament is not applied at assign time, so it must not be the match
+      // the picker selects (FD-16).
+      (api.getPrinterStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
+        nozzles: [{ nozzle_type: 'HS01', nozzle_diameter: '0.4' }],
+      });
+      (api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
+        profiles: [
+          { ...sharedProfiles[0], extruder_id: 0, nozzle_id: 'HH00-0.4', k_value: '0.030', slot_id: 1 },
+          { ...sharedProfiles[0], extruder_id: 0, nozzle_id: 'HS00-0.4', k_value: '0.020', slot_id: 2 },
+        ],
+      });
+      render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={{ ...rightHandSlot, extruderId: 0, caliIdx: null }} />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: /\[S\] PLA \(K=0.020\)/ })).toBeInTheDocument();
+      });
+      const matches = Array.from(screen.getByRole('combobox').querySelectorAll(':scope > option'))
+        .map(o => (o as HTMLOptionElement).value)
+        .filter(Boolean);
+      expect(matches).toEqual(['0|PLA|0.020']);
+      const highFlow = screen.getByRole('option', { name: /\[HF\] PLA \(K=0.030\)/ });
+      expect(highFlow.parentElement?.tagName).toBe('OPTGROUP');
+    });
+
     it('names each hotend once, not two or three times', async () => {
     it('names each hotend once, not two or three times', async () => {
       // The label rendered kProfileNozzleSuffix twice in the matching group and
       // The label rendered kProfileNozzleSuffix twice in the matching group and
       // three times under "Other", so every option on a dual-nozzle printer
       // three times under "Other", so every option on a dual-nozzle printer
@@ -1113,6 +1142,7 @@ describe('ConfigureAmsSlotModal — per-nozzle K-profiles', () => {
 
 
   beforeEach(() => {
   beforeEach(() => {
     vi.clearAllMocks();
     vi.clearAllMocks();
+    (api.getPrinterStatus as ReturnType<typeof vi.fn>).mockResolvedValue({});
     Element.prototype.scrollIntoView = vi.fn();
     Element.prototype.scrollIntoView = vi.fn();
     (api.getCloudSettings as ReturnType<typeof vi.fn>).mockResolvedValue(mockCloudSettings);
     (api.getCloudSettings as ReturnType<typeof vi.fn>).mockResolvedValue(mockCloudSettings);
     (api.getBuiltinFilaments as ReturnType<typeof vi.fn>).mockResolvedValue([
     (api.getBuiltinFilaments as ReturnType<typeof vi.fn>).mockResolvedValue([

+ 32 - 6
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -10,6 +10,7 @@ import { Button } from './Button';
 import { getAmsLabel } from '../utils/amsHelpers';
 import { getAmsLabel } from '../utils/amsHelpers';
 import { getSwatchStyle } from '../utils/colors';
 import { getSwatchStyle } from '../utils/colors';
 import { useCancellableTimeout } from '../hooks/useCancellableTimeout';
 import { useCancellableTimeout } from '../hooks/useCancellableTimeout';
+import { flowApplies, flowLabel, normaliseFlow } from '../utils/nozzleFlow';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 
 
 interface SlotInfo {
 interface SlotInfo {
@@ -111,6 +112,13 @@ function parsePresetName(name: string): { material: string; brand: string; varia
 // are both offered, because such a slot can reach either nozzle. Keying on
 // are both offered, because such a slot can reach either nozzle. Keying on
 // name+K alone made the two indistinguishable and, when their K happened to
 // name+K alone made the two indistinguishable and, when their K happened to
 // match, silently collapsed them into whichever came first.
 // match, silently collapsed them into whichever came first.
+// "[HF] " / "[S] " for a profile measured on a high-flow or standard nozzle;
+// nothing where the printer declares no flow (an X1C declares none).
+function kProfileFlowPrefix(profile: KProfile): string {
+  const label = flowLabel(normaliseFlow(profile.nozzle_id));
+  return label ? `[${label}] ` : '';
+}
+
 function kProfileOptionValue(profile: KProfile): string {
 function kProfileOptionValue(profile: KProfile): string {
   return `${profile.extruder_id ?? 0}|${profile.name}|${profile.k_value}`;
   return `${profile.extruder_id ?? 0}|${profile.name}|${profile.k_value}`;
 }
 }
@@ -907,6 +915,19 @@ export function ConfigureAmsSlotModal({
     [kprofilesData?.profiles]
     [kprofilesData?.profiles]
   );
   );
 
 
+  // The flow of the nozzle on the slot's hotend, from the printer status the
+  // printer card already keeps (H2-series report HH01 / HS01; others nothing).
+  const { data: printerStatus } = useQuery({
+    queryKey: ['printerStatus', printerId],
+    queryFn: () => api.getPrinterStatus(printerId),
+    enabled: isOpen,
+  });
+  const fittedFlow = useMemo(() => {
+    const nozzles = printerStatus?.nozzles ?? [];
+    const extruder = slotInfo.extruderId ?? 0;
+    return normaliseFlow(nozzles[extruder]?.nozzle_type) ?? normaliseFlow(nozzles[0]?.nozzle_type);
+  }, [printerStatus?.nozzles, slotInfo.extruderId]);
+
   const matchingKProfiles = useMemo(() => {
   const matchingKProfiles = useMemo(() => {
     if (!kprofilesData?.profiles) return [];
     if (!kprofilesData?.profiles) return [];
     if (!selectedPresetInfo) {
     if (!selectedPresetInfo) {
@@ -1010,9 +1031,14 @@ export function ConfigureAmsSlotModal({
     // the list instead — the X2D case in #3044, where the second AMS's slots
     // the list instead — the X2D case in #3044, where the second AMS's slots
     // point at the first's entries — so the tagging is ignored rather than
     // point at the first's entries — so the tagging is ignored rather than
     // enforced.
     // enforced.
-    const onThisNozzle = tableNamesThisHotend(kprofilesData.profiles, slotInfo.extruderId)
+    const onThisHotend = tableNamesThisHotend(kprofilesData.profiles, slotInfo.extruderId)
       ? filtered.filter(p => (p.extruder_id ?? 0) === slotInfo.extruderId)
       ? filtered.filter(p => (p.extruder_id ?? 0) === slotInfo.extruderId)
       : filtered;
       : filtered;
+    // And to the flow of the nozzle fitted there: a K measured on a high-flow
+    // nozzle is not a fact about a standard one, and the backend does not apply
+    // it at assign time. Unknown flow on either side matches (an X1C reports
+    // none). The others stay reachable under "Other", labelled [HF] / [S].
+    const onThisNozzle = onThisHotend.filter(p => flowApplies(p.nozzle_id, fittedFlow));
 
 
     // Deduplicate genuine duplicates — same nozzle, same name, same K.
     // Deduplicate genuine duplicates — same nozzle, same name, same K.
     const seen = new Map<string, KProfile>();
     const seen = new Map<string, KProfile>();
@@ -1045,7 +1071,7 @@ export function ConfigureAmsSlotModal({
     }
     }
 
 
     return result;
     return result;
-  }, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId, slotInfo.caliIdx, slotInfo.trayInfoIdx, selectedPresetId, openedPresetId]);
+  }, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId, slotInfo.caliIdx, slotInfo.trayInfoIdx, selectedPresetId, openedPresetId, fittedFlow]);
 
 
   // Every remaining K-profile the printer holds, offered under a separate group
   // Every remaining K-profile the printer holds, offered under a separate group
   // after the matching ones (#2710). The matcher works off preset names and
   // after the matching ones (#2710). The matcher works off preset names and
@@ -1402,14 +1428,14 @@ export function ConfigureAmsSlotModal({
                         <option value="">{t('configureAmsSlot.noKProfile')}</option>
                         <option value="">{t('configureAmsSlot.noKProfile')}</option>
                         {matchingKProfiles.map((profile) => (
                         {matchingKProfiles.map((profile) => (
                           <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
                           <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
-                            {profile.name} (K={profile.k_value}){kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}
+                            {kProfileFlowPrefix(profile)}{profile.name} (K={profile.k_value}){kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}
                           </option>
                           </option>
                         ))}
                         ))}
                         {otherKProfiles.length > 0 && (
                         {otherKProfiles.length > 0 && (
                           <optgroup label={t('configureAmsSlot.otherKProfiles')}>
                           <optgroup label={t('configureAmsSlot.otherKProfiles')}>
                             {otherKProfiles.map((profile) => (
                             {otherKProfiles.map((profile) => (
                               <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
                               <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
-                                {profile.name} (K={profile.k_value}){kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}
+                                {kProfileFlowPrefix(profile)}{profile.name} (K={profile.k_value}){kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}
                               </option>
                               </option>
                             ))}
                             ))}
                           </optgroup>
                           </optgroup>
@@ -1647,14 +1673,14 @@ export function ConfigureAmsSlotModal({
                       <option value="">{t('configureAmsSlot.noKProfile')}</option>
                       <option value="">{t('configureAmsSlot.noKProfile')}</option>
                       {matchingKProfiles.map((profile) => (
                       {matchingKProfiles.map((profile) => (
                         <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
                         <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
-                          {profile.name} (K={profile.k_value}){kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}
+                          {kProfileFlowPrefix(profile)}{profile.name} (K={profile.k_value}){kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}
                         </option>
                         </option>
                       ))}
                       ))}
                       {otherKProfiles.length > 0 && (
                       {otherKProfiles.length > 0 && (
                         <optgroup label={t('configureAmsSlot.otherKProfiles')}>
                         <optgroup label={t('configureAmsSlot.otherKProfiles')}>
                           {otherKProfiles.map((profile) => (
                           {otherKProfiles.map((profile) => (
                             <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
                             <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
-                              {profile.name} (K={profile.k_value}){kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}
+                              {kProfileFlowPrefix(profile)}{profile.name} (K={profile.k_value}){kProfileNozzleSuffix(profile, isDualNozzleProfiles, t)}
                             </option>
                             </option>
                           ))}
                           ))}
                         </optgroup>
                         </optgroup>

+ 2 - 2
frontend/src/components/spool-form/PrinterProfilesSection.tsx

@@ -11,7 +11,7 @@ import { hotendKey, isMatchingCalibration, presetKey } from './utils';
 import { STANDARD_NOZZLE_DIAMETERS } from './constants';
 import { STANDARD_NOZZLE_DIAMETERS } from './constants';
 import { PresetPicker } from './PresetPicker';
 import { PresetPicker } from './PresetPicker';
 import { extractPresetModel, matchesPrinterModelSuffix } from '../../utils/slicerPrinterMatch';
 import { extractPresetModel, matchesPrinterModelSuffix } from '../../utils/slicerPrinterMatch';
-import { flowLabel, normaliseFlow } from '../../utils/nozzleFlow';
+import { flowApplies, flowLabel, normaliseFlow } from '../../utils/nozzleFlow';
 import type { NozzleFlow } from '../../utils/nozzleFlow';
 import type { NozzleFlow } from '../../utils/nozzleFlow';
 
 
 /**
 /**
@@ -588,7 +588,7 @@ export function PrinterProfilesSection({
                                 // look configured and quietly do nothing.
                                 // look configured and quietly do nothing.
                                 const fitted = fittedFlow(entry, column.extruder);
                                 const fitted = fittedFlow(entry, column.extruder);
                                 const chosenFlow = normaliseFlow(chosen?.nozzle_id);
                                 const chosenFlow = normaliseFlow(chosen?.nozzle_id);
-                                const flowMismatch = !!(fitted && chosenFlow && fitted !== chosenFlow);
+                                const flowMismatch = !!chosen && !flowApplies(chosen.nozzle_id, fitted);
                                 return (
                                 return (
                                   <select
                                   <select
                                     key={key}
                                     key={key}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AdditionalSection-OMNzflsQ.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AmsUnitCard-DLmpqWm7.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ArchivesPage-Bb0Td7so.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AssignToAmsModal-LVBEVWdj.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CamWallPage-BcG1K89k.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CameraDiagnoseModal-CR3za315.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CameraPage-BAtB6UpC.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CameraWall-K1MJhr_0.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ColumnConfigModal-d9F25Sh2.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ConfigureAmsSlotModal-CJs6Rs0R.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ConfigureAmsSlotModal-Dkl5UliL.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ConnectAuthorizePage-jbzIvisb.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ContextMenu-BBd6nd-x.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ExternalLinkPage-Bj_3ndby.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/FileManagerPage-DWAE6fEF.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/FileUploadModal-CsmsvE7L.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/FinancePage-BHXLP3O4.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/GCodeViewerPage-CEj4yDwk.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/GroupEditPage-Ldvql2kA.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ImagePreviewModal-CihNBZ7M.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/InventoryPage-LT8XtYDw.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/InventoryPage-W_60I-qs.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/LibraryTrashPage-CIXiHCgj.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/LoginPage-B8DXGTtD.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/MaintenancePage-BYHgKmNM.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ModelSourcesPage-D46rCpeq.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/NotificationsPage-B-Md2hru.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/PdfPreviewModal-Dmqen3Su.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PhotoGalleryModal-iZNGhagJ.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PrinterLocationsPage-DjHTvw-c.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PrintersPage-DSyNMnS_.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProfilesPage-DH7pS5f_.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProjectDetailPage-DbbhqCAE.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProjectsPage-Cp9hkmar.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/QueuePage-B789u2wa.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/RichTextEditor-D1mAmsBG.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SettingsPage-CDUHI0aZ.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SetupPage-B1l8RIBo.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SliceModal-CT-eMuYF.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyAmsPage-ByB2Nn0X.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyDashboard-55sCVP_J.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-mu2aGIQn.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
static/assets/SpoolBuddySettingsPage-D0wlQVm0.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-D5CgBP4m.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpreadsheetPreviewModal-wjtvEvk6.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/StatsPage-b1DqF9V1.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/StreamOverlayPage-DQsrnFtr.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SystemInfoPage-CAnaYPd-.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DAlcmDFx.js


+ 1 - 1
static/assets/inventorySearch-DGFWhWni.js → static/assets/inventorySearch-DTH3xXZS.js

@@ -1 +1 @@
-import{mt as e}from"./index-BiT5tvve.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{mt as e}from"./index-DAlcmDFx.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};

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/locationSensorDefaults-CMRgKewn.js


+ 1 - 0
static/assets/nozzleFlow-vrXwck9b.js

@@ -0,0 +1 @@
+function e(e){let t=(e??``).trim().toUpperCase().slice(0,2);return t===`HH`||t===`HS`?t:null}function t(e){return e?e===`HH`?`HF`:`S`:null}function n(t,n){let r=e(t),i=e(n);return!r||!i?!0:r===i}export{t as n,e as r,n as t};

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/pdf-Dm8l9tqC.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/utils-CWU-DmvD.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BiT5tvve.js"></script>
+    <script type="module" crossorigin src="/assets/index-DAlcmDFx.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-p9vNUtaN.js">
     <link rel="modulepreload" crossorigin href="/assets/client-p9vNUtaN.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-B5zKGR0V.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-B5zKGR0V.js">

Некоторые файлы не были показаны из-за большого количества измененных файлов