diff --git a/backend/app/api/routes/_spoolman_helpers.py b/backend/app/api/routes/_spoolman_helpers.py index 8269e3e3f..9847d3ace 100644 --- a/backend/app/api/routes/_spoolman_helpers.py +++ b/backend/app/api/routes/_spoolman_helpers.py @@ -32,6 +32,7 @@ class MappedSpoolFields(TypedDict): effect_type: None label_weight: int | None core_weight: int | None + core_weight_is_inherited: bool core_weight_catalog_id: None weight_used: float | None weight_used_baseline: float | None @@ -378,6 +379,11 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields: "core_weight": _safe_int( spool.get("spool_weight") if spool.get("spool_weight") is not None else filament.get("spool_weight"), 250 ), + # True when the spool has no spool_weight of its own and core_weight is + # the filament type's (or the 250 g fallback). The spool form needs it + # to copy a spool without dropping an own tare or stamping an + # inherited one (#2908). + "core_weight_is_inherited": spool.get("spool_weight") is None, "core_weight_catalog_id": None, "weight_used": used_weight, "weight_used_baseline": weight_used_baseline, diff --git a/backend/app/api/routes/spoolman_inventory.py b/backend/app/api/routes/spoolman_inventory.py index c3663e47c..a58753de4 100644 --- a/backend/app/api/routes/spoolman_inventory.py +++ b/backend/app/api/routes/spoolman_inventory.py @@ -311,9 +311,12 @@ class SpoolmanInventoryCreate(BaseModel): color_name: str | None = Field(None, max_length=64) rgba: str | None = Field(None, max_length=8, description="6-digit hex (RRGGBB) or 8-digit (RRGGBBAA)") label_weight: int = Field(1000, ge=1, le=100_000) - core_weight: int = Field( - 250, ge=0, le=10_000 - ) # Accepted for schema parity but not persisted to Spoolman (stored on filament type, not spool) + # Persisted to the Spoolman spool's own `spool_weight` (tare), which takes + # priority over the filament-level value both in _map_spoolman_spool and in + # the weigh endpoint. Only written when the request actually sets it: the + # 250 default is the display fallback, and writing it on every create would + # stamp an explicit tare on spools that should keep inheriting one (#2908). + core_weight: int = Field(250, ge=0, le=10_000) weight_used: float = Field(0.0, ge=0.0, le=100_000.0) note: str | None = Field(None, max_length=1000) cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0) @@ -352,9 +355,11 @@ class SpoolmanInventoryUpdate(BaseModel): color_name: str | None = Field(None, max_length=64) rgba: str | None = Field(None, max_length=8, description="6-digit hex (RRGGBB) or 8-digit (RRGGBBAA)") label_weight: int | None = Field(None, ge=1, le=100_000) - core_weight: int | None = Field( - None, ge=0, le=10_000 - ) # Accepted for schema parity but not persisted to Spoolman (stored on filament type, not spool) + # Persisted to the spool's own `spool_weight` (see the Create schema). + # Omitted / null leaves the current value alone, as with every other field + # here. There is no per-spool "go back to inheriting" through this route; + # the filament-level route already owns that decision (#2908). + core_weight: int | None = Field(None, ge=0, le=10_000) weight_used: float | None = Field(None, ge=0.0, le=100_000.0) note: str | None = Field(None, max_length=1000) cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0) @@ -552,6 +557,7 @@ async def create_spool( remaining_weight=remaining, comment=data.note or None, location=storage_location or None, + spool_weight=(data.core_weight if "core_weight" in data.model_fields_set else None), ) except HTTPException as exc: if exc.status_code == 404 and data.spoolman_filament_id is not None: @@ -641,6 +647,7 @@ async def bulk_create_spools( remaining_weight=remaining, comment=data.note or None, location=storage_location or None, + spool_weight=(data.core_weight if "core_weight" in data.model_fields_set else None), ) except (SpoolmanUnavailableError, SpoolmanClientError, SpoolmanNotFoundError) as exc: logger.warning("Bulk spool creation: one spool failed: %s", exc) @@ -854,6 +861,11 @@ async def update_spool( extra=extra, location=storage_location or None, clear_location=storage_location_changed and not storage_location, + # No model_fields_set guard here, unlike create: this schema + # already defaults core_weight to None, and None is what + # update_spool_full reads as "leave the tare alone". A guard + # would be a second spelling of the same condition. + spool_weight=data.core_weight, ) # Persist BambuStudio slicer preset AND color_name under spool.extra. diff --git a/backend/app/services/spoolman.py b/backend/app/services/spoolman.py index 919c79631..036015486 100644 --- a/backend/app/services/spoolman.py +++ b/backend/app/services/spoolman.py @@ -413,6 +413,7 @@ class SpoolmanClient: lot_nr: str | None = None, comment: str | None = None, extra: dict | None = None, + spool_weight: float | None = None, ) -> dict: """Create a new spool in Spoolman.""" data: dict = {"filament_id": filament_id} @@ -424,6 +425,11 @@ class SpoolmanClient: data["lot_nr"] = lot_nr if comment: data["comment"] = comment + # `is not None`, not truthiness: 0 g is a legitimate tare (a spool-less + # coil), and it is not the same answer as "inherit from the filament", + # which is what leaving the field off means to Spoolman. + if spool_weight is not None: + data["spool_weight"] = spool_weight if extra: data["extra"] = extra await self._ensure_extra_fields(extra) diff --git a/backend/tests/integration/test_spoolman_inventory_api.py b/backend/tests/integration/test_spoolman_inventory_api.py index aa8792552..26706eacd 100644 --- a/backend/tests/integration/test_spoolman_inventory_api.py +++ b/backend/tests/integration/test_spoolman_inventory_api.py @@ -2217,14 +2217,14 @@ class TestLinkTagDuplicate: class TestSpoolmanInventoryUpdateCoreWeight: - """core_weight is accepted for schema parity but not persisted — any value should be accepted.""" + """core_weight is forwarded to Spoolman when sent — any value should be accepted.""" @pytest.mark.asyncio @pytest.mark.integration async def test_patch_core_weight_other_than_250_accepted( self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client ): - """PATCH with core_weight != 250 is accepted (field is ignored server-side, not rejected).""" + """PATCH with core_weight != 250 is accepted and carried through, not rejected.""" resp = await async_client.patch( "/api/v1/spoolman/inventory/spools/42", json={"core_weight": 100}, @@ -2759,7 +2759,7 @@ class TestCreateSpoolWeightValidation: async def test_create_spool_with_non_default_core_weight_accepted( self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client ): - """A3: core_weight != 250 must no longer be rejected → 201.""" + """A3: core_weight != 250 is accepted and reaches Spoolman → 201.""" resp = await async_client.post( "/api/v1/spoolman/inventory/spools", json={"material": "PLA", "label_weight": 1000, "weight_used": 0, "core_weight": 196}, @@ -2945,3 +2945,129 @@ class TestGetAllSlotAssignmentsEnriched: assert data[0]["printer_id"] == 1 assert data[0]["printer_name"] == "P1" assert data[0]["spoolman_spool_id"] == 201 + + +class TestPerSpoolCoreWeight: + """The per-spool tare reaches Spoolman now (#2908). + + `core_weight` was declared on both write schemas and dropped after + validation, with a comment saying so. The read path never showed it: it + derives the value from ``spool.spool_weight ?? filament.spool_weight ?? 250`` + (_spoolman_helpers.py), so an edit that went nowhere came back as the + inherited value and looked like it had simply not changed. + + It is not cosmetic, because the same resolution is the tare the weigh + endpoint subtracts. A spool whose real empty weight differs from its + filament's produced a wrong remaining weight on every weigh-in -- 70 g for + the reporter's third-party spools against Bambu's 250 g reusable ones. + + Spoolman already has the field and already gives it priority. Only the + write was missing. + """ + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_an_edited_tare_is_written_to_the_spools_own_field( + self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client + ): + response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json={"core_weight": 180}) + + assert response.status_code == 200 + assert mock_spoolman_client.update_spool_full.call_args.kwargs["spool_weight"] == 180 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_an_edit_that_does_not_mention_the_tare_leaves_it_inheriting( + self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client + ): + """The reason this keys off model_fields_set rather than the value. + + `core_weight` carries a default, so a PATCH that never mentions it still + arrives at the handler holding one. Writing that would stamp an explicit + tare on every spool the user edits for any reason, silently detaching it + from its filament -- a worse bug than the one being fixed, and an + invisible one, since the number displayed would not change. + """ + response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json={"note": "just a note"}) + + assert response.status_code == 200 + assert mock_spoolman_client.update_spool_full.call_args.kwargs["spool_weight"] is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_a_tare_given_at_creation_is_written( + self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client + ): + response = await async_client.post( + "/api/v1/spoolman/inventory/spools", + json={"material": "PLA", "label_weight": 1000, "core_weight": 180}, + ) + + assert response.status_code == 200 + assert mock_spoolman_client.create_spool.call_args.kwargs["spool_weight"] == 180 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_a_creation_that_omits_the_tare_leaves_the_spool_inheriting( + self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client + ): + """Same defaulting hazard as the update, and the commoner path: the + form posts without a tare far more often than with one.""" + response = await async_client.post( + "/api/v1/spoolman/inventory/spools", + json={"material": "PLA", "label_weight": 1000}, + ) + + assert response.status_code == 200 + assert mock_spoolman_client.create_spool.call_args.kwargs["spool_weight"] is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_bulk_creation_persists_the_tare_on_every_spool( + self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client + ): + """Bulk create takes the same schema, so it dropped the field the same way.""" + response = await async_client.post( + "/api/v1/spoolman/inventory/spools/bulk", + json={"spool": {"material": "PLA", "label_weight": 1000, "core_weight": 180}, "quantity": 3}, + ) + + assert response.status_code in (200, 201) + assert mock_spoolman_client.create_spool.await_count == 3 + assert all(c.kwargs["spool_weight"] == 180 for c in mock_spoolman_client.create_spool.await_args_list) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_a_zero_tare_is_a_value_not_an_absence( + self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client + ): + """0 g is a real answer -- a coil with no spool -- and the schema allows + it (``ge=0``). Guarding the write on truthiness rather than ``is not + None`` would silently turn it into "inherit", which resolves to 250.""" + response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json={"core_weight": 0}) + + assert response.status_code == 200 + assert mock_spoolman_client.update_spool_full.call_args.kwargs["spool_weight"] == 0 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_the_written_tare_is_the_one_the_weigh_endpoint_subtracts( + self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client + ): + """What the fix is actually for. + + The weigh endpoint resolves the tare exactly as the read path does, so + once the per-spool value is stored it is the number a measured gross + weight is reduced by. With a 180 g spool inheriting the filament's 250 g + this same weigh-in would have recorded 550 g remaining instead of 620 -- + the 70 g error from the report, on every weigh-in. + """ + mock_spoolman_client.get_spool.return_value = { + **SAMPLE_SPOOLMAN_SPOOL, + "spool_weight": 180.0, + } + + response = await async_client.patch("/api/v1/spoolman/inventory/spools/42/weight", json={"weight_grams": 800.0}) + + assert response.status_code == 200 + assert mock_spoolman_client.update_spool_full.call_args.kwargs["remaining_weight"] == 620.0 diff --git a/backend/tests/unit/test_spoolman_inventory_helpers.py b/backend/tests/unit/test_spoolman_inventory_helpers.py index 463defbe6..f81bb4408 100644 --- a/backend/tests/unit/test_spoolman_inventory_helpers.py +++ b/backend/tests/unit/test_spoolman_inventory_helpers.py @@ -430,6 +430,18 @@ class TestMapSpoolmanSpool: spool = {**MINIMAL_SPOOL, "spool_weight": None, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}} assert _map_spoolman_spool(spool)["core_weight"] == 196 + @pytest.mark.parametrize( + ("spool_level", "inherited"), + [(180, False), (0, False), (None, True), ("absent", True)], + ) + def test_core_weight_is_inherited_says_whose_tare_it_is(self, spool_level, inherited): + """The spool form copies an own tare onto a copy and leaves an + inherited one alone (#2908). 0 is an own tare, not a missing one.""" + spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}} + if spool_level != "absent": + spool["spool_weight"] = spool_level + assert _map_spoolman_spool(spool)["core_weight_is_inherited"] is inherited + def test_spool_level_absent_falls_back_to_filament(self): spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}} assert _map_spoolman_spool(spool)["core_weight"] == 196 diff --git a/backend/tests/unit/test_spoolman_inventory_methods.py b/backend/tests/unit/test_spoolman_inventory_methods.py index 28bc6deae..bd31c5361 100644 --- a/backend/tests/unit/test_spoolman_inventory_methods.py +++ b/backend/tests/unit/test_spoolman_inventory_methods.py @@ -665,3 +665,43 @@ class TestRenameLocationBulkAndFallback: pytest.raises(httpx.HTTPStatusError), ): await client.rename_location("Drybox 1", "Drybox 2") + + +class TestCreateSpoolTare: + """create_spool carries the per-spool tare, and tells 0 apart from absent (#2908). + + Leaving `spool_weight` off the payload is meaningful to Spoolman -- it means + the spool inherits its filament's value -- so the two cases have to stay + distinguishable all the way down to the request body. + """ + + @pytest.mark.asyncio + async def test_a_tare_is_sent(self, client): + mock_http = AsyncMock() + mock_http.post = AsyncMock(return_value=_make_response(SAMPLE_SPOOL)) + with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)): + await client.create_spool(filament_id=7, spool_weight=180) + + assert mock_http.post.call_args.kwargs["json"]["spool_weight"] == 180 + + @pytest.mark.asyncio + async def test_a_zero_tare_is_sent_rather_than_dropped(self, client): + """A guard on truthiness would silently turn 0 g into "inherit", which + resolves to 250 g -- a 250 g error on every weigh-in for a bare coil.""" + mock_http = AsyncMock() + mock_http.post = AsyncMock(return_value=_make_response(SAMPLE_SPOOL)) + with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)): + await client.create_spool(filament_id=7, spool_weight=0) + + assert mock_http.post.call_args.kwargs["json"]["spool_weight"] == 0 + + @pytest.mark.asyncio + async def test_no_tare_leaves_the_key_off_entirely(self, client): + """Sending an explicit null would pin the spool to "no inheritance", + which is not the same as not having been told.""" + mock_http = AsyncMock() + mock_http.post = AsyncMock(return_value=_make_response(SAMPLE_SPOOL)) + with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)): + await client.create_spool(filament_id=7) + + assert "spool_weight" not in mock_http.post.call_args.kwargs["json"] diff --git a/frontend/src/__tests__/components/AdditionalSection.test.tsx b/frontend/src/__tests__/components/AdditionalSection.test.tsx index c4affc5c4..383cb472d 100644 --- a/frontend/src/__tests__/components/AdditionalSection.test.tsx +++ b/frontend/src/__tests__/components/AdditionalSection.test.tsx @@ -21,25 +21,9 @@ const baseProps = { }; describe('AdditionalSection', () => { - it('renders SpoolWeightPicker when spoolmanMode is false', () => { - render(); - // SpoolWeightPicker renders the 'inventory.coreWeight' label - expect(screen.getByText('inventory.coreWeight')).toBeTruthy(); - // Info notice must NOT be present - expect(screen.queryByText('inventory.spoolWeightManagedBySpoolman')).toBeNull(); - }); - - it('hides SpoolWeightPicker and shows info notice when spoolmanMode is true', () => { - render(); - // Info notice must appear - expect(screen.getByText('inventory.spoolWeightManagedBySpoolman')).toBeTruthy(); - // SpoolWeightPicker must NOT be rendered - expect(screen.queryByText('inventory.coreWeight')).toBeNull(); - }); - - it('defaults to spoolmanMode=false when prop is omitted', () => { + it('renders SpoolWeightPicker', () => { render(); - // SpoolWeightPicker present by default + // SpoolWeightPicker renders the 'inventory.coreWeight' label expect(screen.getByText('inventory.coreWeight')).toBeTruthy(); }); }); diff --git a/frontend/src/__tests__/components/SpoolFormModal.test.tsx b/frontend/src/__tests__/components/SpoolFormModal.test.tsx index 86dea10d3..7685d8ca3 100644 --- a/frontend/src/__tests__/components/SpoolFormModal.test.tsx +++ b/frontend/src/__tests__/components/SpoolFormModal.test.tsx @@ -1432,3 +1432,174 @@ describe('SpoolFormModal header spool ID (#1385)', () => { expect(screen.queryByText(/^#\d+$/)).not.toBeInTheDocument(); }); }); + +describe('SpoolFormModal — per-spool tare in Spoolman mode (#2908)', () => { + // The mapped Spoolman spool carries the tare it resolves to: its own + // spool_weight if set, else the filament type's. The form opens on that value. + const spoolmanSpool = { + ...existingSpool, + id: 42, + core_weight: 250, + core_weight_catalog_id: null, + data_origin: 'spoolman', + tag_type: 'spoolman', + } as InventorySpool; + + const catalog = [ + { id: 7, name: 'Bambu Lab 250g', weight: 250 }, + { id: 3, name: 'Standard 300g', weight: 300 }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(api.getSpoolCatalog).mockResolvedValue(catalog); + }); + + function weightPicker() { + const picker = screen + .getAllByPlaceholderText(/search/i) + .find((input) => input.getAttribute('placeholder')?.toLowerCase().includes('spool')); + expect(picker).toBeTruthy(); + return picker!; + } + + async function openEdit() { + render( + + ); + await waitFor(() => { + expect(screen.getByText('Edit Spool')).toBeInTheDocument(); + }); + openColorAndCostTab(); + await waitFor(() => { + expect(api.getSpoolCatalog).toHaveBeenCalled(); + }); + } + + async function savedPayload() { + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => { + expect(api.updateSpoolmanInventorySpool).toHaveBeenCalledTimes(1); + }); + return vi.mocked(api.updateSpoolmanInventorySpool).mock.calls[0][1] as Record; + } + + it('shows the empty spool weight picker, which it used to replace with a notice', async () => { + await openEdit(); + + expect(weightPicker()).toBeInTheDocument(); + }); + + it('does not send the tare when the user left it alone', async () => { + // An untouched edit must not copy the inherited value onto the spool: + // that would stop it following its filament type. + await openEdit(); + + const payload = await savedPayload(); + + expect(payload).not.toHaveProperty('core_weight'); + expect(payload).not.toHaveProperty('core_weight_catalog_id'); + }); + + it('does not count the picker selecting a catalogue entry by itself as a touch', async () => { + // One catalogue row matches the opening weight, so the picker selects it + // on mount. That changes core_weight_catalog_id without the user doing + // anything, which is why the touched flag keys on core_weight instead. + await openEdit(); + await screen.findByDisplayValue('Bambu Lab 250g'); + + const payload = await savedPayload(); + + expect(payload).not.toHaveProperty('core_weight'); + }); + + it('sends the tare the user picked, without the catalogue id', async () => { + await openEdit(); + fireEvent.focus(weightPicker()); + fireEvent.click(await screen.findByText('Standard 300g')); + + const payload = await savedPayload(); + + expect(payload).toHaveProperty('core_weight', 300); + // No field for it on SpoolmanInventoryCreate / Update; it would be dropped. + expect(payload).not.toHaveProperty('core_weight_catalog_id'); + }); + + it('does not send the form default on create either', async () => { + render(); + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument(); + }); + + const addButtons = screen.getAllByRole('button', { name: /add spool/i }); + const submitButton = addButtons.find((btn) => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save')); + fireEvent.click(submitButton!); + + await waitFor(() => { + expect(api.createSpoolmanInventorySpool).toHaveBeenCalledTimes(1); + }); + const payload = vi.mocked(api.createSpoolmanInventorySpool).mock.calls[0][0] as Record; + expect(payload).not.toHaveProperty('core_weight'); + }); + + it('loads a 0 g tare as 0, not the 250 fallback', async () => { + // A spool-less coil. `|| 250` read the real 0 as missing, so the form + // showed 250 and the measured-weight field subtracted it. + render( + + ); + await waitFor(() => { + expect(screen.getByText('Edit Spool')).toBeInTheDocument(); + }); + openColorAndCostTab(); + + const tareInput = screen.getAllByRole('spinbutton').find((el) => el.getAttribute('max') === '2000'); + expect(tareInput).toHaveValue(0); + }); + + async function copiedPayload(source: InventorySpool) { + render( + + ); + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument(); + }); + const submit = screen + .getAllByRole('button', { name: /copy spool/i }) + .find((btn) => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save')); + fireEvent.click(submit!); + await waitFor(() => { + expect(api.createSpoolmanInventorySpool).toHaveBeenCalledTimes(1); + }); + return vi.mocked(api.createSpoolmanInventorySpool).mock.calls[0][0] as Record; + } + + it('carries a spool\'s own tare onto its copy', async () => { + // The copy's form shows 180; left untouched it used to send nothing, and + // the copy came out inheriting the filament's 250. + const payload = await copiedPayload({ ...spoolmanSpool, core_weight: 180, core_weight_is_inherited: false }); + + expect(payload).toHaveProperty('core_weight', 180); + expect(payload).not.toHaveProperty('core_weight_catalog_id'); + }); + + it('lets a copy of an inheriting spool keep inheriting', async () => { + const payload = await copiedPayload({ ...spoolmanSpool, core_weight: 250, core_weight_is_inherited: true }); + + expect(payload).not.toHaveProperty('core_weight'); + }); +}); diff --git a/frontend/src/__tests__/pages/SpoolBuddyDashboard.test.tsx b/frontend/src/__tests__/pages/SpoolBuddyDashboard.test.tsx index 87d6be1dd..09c43df8d 100644 --- a/frontend/src/__tests__/pages/SpoolBuddyDashboard.test.tsx +++ b/frontend/src/__tests__/pages/SpoolBuddyDashboard.test.tsx @@ -385,6 +385,33 @@ describe('SpoolBuddyDashboard', () => { }); }); + it('quick-adds a Spoolman spool without a tare, so it inherits the filament type\'s (#2908)', async () => { + // The quick-create has no tare input. The 250 it used to send was a + // placeholder, and since #2908 a sent value lands on the spool's own + // spool_weight -- every kiosk-created spool would stop inheriting. + const { api } = await import('../../api/client'); + (api.getSpoolmanSettings as ReturnType).mockResolvedValue({ + spoolman_enabled: 'true', + spoolman_url: 'http://localhost:7912', + spoolman_sync_mode: 'off', + spoolman_disable_weight_sync: 'false', + spoolman_report_partial_usage: 'false', + }); + + renderPage({ unknownTagUid: 'AABB1122334455FF' }); + + fireEvent.click(await waitFor(() => screen.getAllByText('Add to Inventory')[0])); + fireEvent.click(await waitFor(() => screen.getByText('Add Anyway'))); + + await waitFor(() => { + expect(api.createSpoolmanInventorySpool).toHaveBeenCalledTimes(1); + }); + const payload = (api.createSpoolmanInventorySpool as ReturnType).mock.calls[0][0]; + expect(payload).not.toHaveProperty('core_weight'); + expect(payload).not.toHaveProperty('core_weight_catalog_id'); + expect(api.createSpool).not.toHaveBeenCalled(); + }); + it('switches to SpoolInfoCard and hides UnknownTagCard after successful Spoolman link', async () => { const { api } = await import('../../api/client'); (api.getSpoolmanSettings as ReturnType).mockResolvedValue({ diff --git a/frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx b/frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx index f5fad8b83..987b656e6 100644 --- a/frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx +++ b/frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx @@ -30,6 +30,14 @@ vi.mock('../../api/client', () => ({ }), getSpoolmanInventorySpools: vi.fn().mockResolvedValue([]), createSpoolmanInventorySpool: vi.fn().mockResolvedValue({ id: 1, material: 'PLA' }), + // Read by the new-spool form's own data load; empty unless a test says otherwise. + getSpoolCatalog: vi.fn().mockResolvedValue([]), + getColorCatalog: vi.fn().mockResolvedValue([]), + getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }), + getBuiltinFilaments: vi.fn().mockResolvedValue([]), + getPrinters: vi.fn().mockResolvedValue([]), + getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }), + orcaCloudStatus: vi.fn().mockResolvedValue({ connected: false }), }, spoolbuddyApi: { getDevices: vi.fn().mockResolvedValue([]), @@ -41,7 +49,9 @@ vi.mock('../../api/client', () => ({ // Mock i18n vi.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (key: string, fallback: string) => fallback, + // The second argument is an options object on some calls (the full + // form's sections); only a string is a fallback. + t: (key: string, fallback?: unknown) => (typeof fallback === 'string' ? fallback : key), i18n: { language: 'en', changeLanguage: vi.fn() }, }), })); @@ -280,4 +290,71 @@ describe('SpoolBuddyWriteTagPage', () => { mockOutletContext.sbState.deviceOnline = false; }); + + describe('per-spool tare in Spoolman mode (#2908)', () => { + beforeEach(() => { + vi.mocked(mockedApi.getSpoolmanSettings).mockResolvedValue({ + spoolman_enabled: 'true', + spoolman_url: 'http://spoolman.test', + spoolman_sync_mode: '', + spoolman_disable_weight_sync: '', + spoolman_report_partial_usage: '', + }); + vi.mocked(mockedApi.getSpoolCatalog).mockResolvedValue([ + { id: 3, name: 'Standard 300g', weight: 300 }, + ] as never); + }); + + async function openNewSpoolForm() { + renderPage(); + await waitFor(() => { + expect(vi.mocked(mockedApi.getSpoolmanInventorySpools)).toHaveBeenCalled(); + }); + fireEvent.click(screen.getByText('New Spool')); + await waitFor(() => screen.getByText('Create Spool')); + // The one field the simple view requires. + const material = screen.getAllByRole('combobox').find((el) => el.querySelector('option[value="PLA"]')); + fireEvent.change(material!, { target: { value: 'PLA' } }); + } + + async function createdPayload() { + fireEvent.click(screen.getAllByText('Create Spool').at(-1)!); + await waitFor(() => { + expect(vi.mocked(mockedApi.createSpoolmanInventorySpool)).toHaveBeenCalledTimes(1); + }); + return vi.mocked(mockedApi.createSpoolmanInventorySpool).mock.calls[0][0] as unknown as Record; + } + + it('does not send the form default when the tare was left alone', async () => { + await openNewSpoolForm(); + + const payload = await createdPayload(); + + expect(payload).not.toHaveProperty('core_weight'); + expect(payload).not.toHaveProperty('core_weight_catalog_id'); + }); + + it('sends the tare the user picked, without the catalogue id', async () => { + await openNewSpoolForm(); + fireEvent.click(screen.getByText('Full')); + const picker = await waitFor(() => { + const input = screen + .getAllByPlaceholderText(/search/i) + .find((el) => el.getAttribute('placeholder')?.toLowerCase().includes('spool')); + expect(input).toBeTruthy(); + return input!; + }); + fireEvent.focus(picker); + fireEvent.click(await screen.findByText('Standard 300g')); + // Back to the simple view to submit: it validates on material alone, and + // the form state, touched flag included, is shared by both views. + fireEvent.click(screen.getByText('Simple')); + + const payload = await createdPayload(); + + expect(payload).toHaveProperty('core_weight', 300); + // No field for it on the Spoolman create schema; it would be dropped. + expect(payload).not.toHaveProperty('core_weight_catalog_id'); + }); + }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index b662212af..bae4cc986 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -3626,6 +3626,9 @@ export interface InventorySpool { brand: string | null; label_weight: number; core_weight: number; + // Spoolman-backed inventory only: true when the spool has no tare of its + // own and core_weight is the filament type's. Absent for local spools (#2908). + core_weight_is_inherited?: boolean; core_weight_catalog_id: number | null; weight_used: number; // Anchor for the resettable "Total Consumed" display (#1390). The diff --git a/frontend/src/components/SpoolFormModal.tsx b/frontend/src/components/SpoolFormModal.tsx index d1edd6eef..807ecb6df 100644 --- a/frontend/src/components/SpoolFormModal.tsx +++ b/frontend/src/components/SpoolFormModal.tsx @@ -75,6 +75,11 @@ export function SpoolFormModal({ const [errors, setErrors] = useState>>({}); const [activeTab, setActiveTab] = useState('filament'); const [weightTouched, setWeightTouched] = useState(false); + // Keyed on core_weight, not core_weight_catalog_id: SpoolWeightPicker selects + // a catalogue entry by itself on mount when one matches the current weight, + // so the id changes on forms nobody has touched. Both real user actions go + // through core_weight. + const [coreWeightTouched, setCoreWeightTouched] = useState(false); const [locationIdTouched, setLocationIdTouched] = useState(false); const [quickAdd, setQuickAdd] = useState(false); const [quantity, setQuantity] = useState(1); @@ -206,9 +211,9 @@ export function SpoolFormModal({ } }; fetchData(); - if (!spoolmanMode) { - api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error); - } + // Fetched in Spoolman mode too: the empty spool weight picker is shown + // there now, and its catalogue is Bambuddy's own either way (#2908). + api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error); api.getColorCatalog().then(setColorCatalog).catch(console.error); api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(console.error); api.getBuiltinFilaments().then(setBuiltinFilaments).catch(console.error); @@ -255,16 +260,14 @@ export function SpoolFormModal({ })(); } } - // The effect intentionally depends only on `isOpen` (and the prop-side - // calibration count) — re-running on every spoolmanMode toggle would - // race the in-flight async fetches with unmount/teardown and emit - // "test environment was torn down" errors in vitest. spoolmanMode only - // gates a single fetch (getSpoolCatalog) which is cheap enough to skip - // when the modal opens in Spoolman mode. + // Depends only on `isOpen` (and the prop-side calibration count). It used + // to read spoolmanMode for the catalogue fetch and left it out of the deps + // on purpose -- re-running on every toggle raced the in-flight fetches with + // unmount and emitted "test environment was torn down" errors in vitest. + // It no longer reads it, so the deps are complete as written. return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen, printersWithCalibrations.length]); // Build filament options: cloud → local → fallback @@ -402,7 +405,8 @@ export function SpoolFormModal({ extra_colors: spool.extra_colors || '', effect_type: spool.effect_type || '', label_weight: spool.label_weight || 1000, - core_weight: spool.core_weight || 250, + // ?? not ||: 0 g is a real tare (a spool-less coil) and must load as 0. + core_weight: spool.core_weight ?? 250, core_weight_catalog_id: spool.core_weight_catalog_id ?? null, weight_used: isCopying ? 0 : spool.weight_used || 0, slicer_filament: spool.slicer_filament || '', @@ -462,6 +466,10 @@ export function SpoolFormModal({ // save) A's per-model overrides on B. Refilled by the fetch below. setModelPresets(new Map()); setWeightTouched(false); + // A copy of a Spoolman spool with its own tare carries that tare, as it + // would any other field shown in the form; one that inherits keeps + // inheriting. Only Spoolman spools report the flag (#2908). + setCoreWeightTouched(isCopying && spool?.core_weight_is_inherited === false); setLocationIdTouched(false); } }, [isOpen, spool, mode, isCopying]); @@ -527,6 +535,7 @@ export function SpoolFormModal({ : {}), })); if (key === 'weight_used') setWeightTouched(true); + if (key === 'core_weight') setCoreWeightTouched(true); if (key === 'location_id') setLocationIdTouched(true); if (errors[key]) { setErrors(prev => ({ ...prev, [key]: undefined })); @@ -864,7 +873,16 @@ export function SpoolFormModal({ extra_colors: formData.extra_colors || null, effect_type: formData.effect_type || null, label_weight: formData.label_weight, - ...(spoolmanMode ? {} : { core_weight: formData.core_weight, core_weight_catalog_id: formData.core_weight_catalog_id }), + // In Spoolman mode the picker opens on the tare the spool resolves to, + // which is the filament type's unless the spool has its own. Sending it + // untouched would copy that inherited value onto the spool and stop it + // following the filament, so only a value the user set goes out (#2908). + // The catalogue id has no field on the Spoolman side. + ...(spoolmanMode + ? coreWeightTouched + ? { core_weight: formData.core_weight } + : {} + : { core_weight: formData.core_weight, core_weight_catalog_id: formData.core_weight_catalog_id }), slicer_filament: formData.slicer_filament || null, slicer_filament_name: presetName, nozzle_temp_min: null, @@ -1096,7 +1114,6 @@ export function SpoolFormModal({ } }} globalLowStockThreshold={globalLowStockThreshold} - spoolmanMode={spoolmanMode} /> diff --git a/frontend/src/components/spool-form/AdditionalSection.tsx b/frontend/src/components/spool-form/AdditionalSection.tsx index b1e75187f..c171ea343 100644 --- a/frontend/src/components/spool-form/AdditionalSection.tsx +++ b/frontend/src/components/spool-form/AdditionalSection.tsx @@ -177,7 +177,6 @@ export function AdditionalSection({ availableLocations = [], onCreateLocation, globalLowStockThreshold, - spoolmanMode = false, }: AdditionalSectionProps) { const { t } = useTranslation(); const { showToast } = useToast(); @@ -210,19 +209,17 @@ export function AdditionalSection({ // catalogue picker carries a long product name beside its own number // input, and the note is a textarea.
- {/* Empty Spool Weight — hidden in Spoolman mode (managed per filament type in Spoolman) */} + {/* Empty Spool Weight. Shown in Spoolman mode too: Spoolman keeps a + per-spool spool_weight that overrides the filament type's, and it is + the tare every weigh-in subtracts (#2908). */}
- {spoolmanMode ? ( -

{t('inventory.spoolWeightManagedBySpoolman')}

- ) : ( - updateField('core_weight', weight)} - catalogId={formData.core_weight_catalog_id} - onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)} - /> - )} + updateField('core_weight', weight)} + catalogId={formData.core_weight_catalog_id} + onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)} + />
{/* Current Weight (remaining filament) */} diff --git a/frontend/src/components/spool-form/types.ts b/frontend/src/components/spool-form/types.ts index 0455da63c..d30388a14 100644 --- a/frontend/src/components/spool-form/types.ts +++ b/frontend/src/components/spool-form/types.ts @@ -212,9 +212,6 @@ export interface AdditionalSectionProps extends SectionProps { globalLowStockThreshold: number; availableLocations?: { id: number; name: string }[]; onCreateLocation?: (name: string) => Promise<{ id: number; name: string } | null>; - // When true the empty-spool weight is managed by Spoolman on the filament - // object, so SpoolWeightPicker is hidden and an info notice is shown instead. - spoolmanMode?: boolean; } // PA Profile section props diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 58318e219..68f6fd348 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -5027,7 +5027,6 @@ export default { spoolmanFilamentUnlinked: 'Verknüpfung mit Filamentkatalog aufgehoben', noSpoolmanFilaments: 'Keine Filamente im Spoolman-Katalog gefunden', spoolmanFilamentColorSwatch: 'Filamentfarbe', - spoolWeightManagedBySpoolman: 'Das Leerspulengewicht wird pro Filamenttyp in Spoolman verwaltet', spoolmanCatalogLoadFailed: 'Spoolman-Filamentkatalog konnte nicht geladen werden', }, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index a4b781fca..17e066df4 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5072,7 +5072,6 @@ export default { spoolmanFilamentUnlinked: 'Filament catalog link cleared', noSpoolmanFilaments: 'No filaments found in Spoolman catalog', spoolmanFilamentColorSwatch: 'Filament color', - spoolWeightManagedBySpoolman: 'Empty spool weight is managed per filament type in Spoolman', spoolmanCatalogLoadFailed: 'Failed to load Spoolman filament catalog', }, diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index b6a34be64..3a262eba4 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -5034,7 +5034,6 @@ export default { spoolmanFilamentUnlinked: 'Vínculo del catálogo de filamentos borrado', noSpoolmanFilaments: 'No se encontraron filamentos en el catálogo de Spoolman', spoolmanFilamentColorSwatch: 'Color del filamento', - spoolWeightManagedBySpoolman: 'El peso de la bobina vacía se gestiona por tipo de filamento en Spoolman', spoolmanCatalogLoadFailed: 'Error al cargar el catálogo de filamentos de Spoolman', }, diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index bd0104008..369a81c9b 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -5016,7 +5016,6 @@ export default { spoolmanFilamentUnlinked: 'Lien avec le catalogue de filaments supprimé', noSpoolmanFilaments: 'Aucun filament trouvé dans le catalogue Spoolman', spoolmanFilamentColorSwatch: 'Couleur du filament', - spoolWeightManagedBySpoolman: 'Le poids de la bobine vide est géré par type de filament dans Spoolman', spoolmanCatalogLoadFailed: 'Impossible de charger le catalogue Spoolman', }, diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 3cc6e0363..050fdefc3 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -5015,7 +5015,6 @@ export default { spoolmanFilamentUnlinked: 'Collegamento al catalogo filamenti rimosso', noSpoolmanFilaments: 'Nessun filamento trovato nel catalogo Spoolman', spoolmanFilamentColorSwatch: 'Colore del filamento', - spoolWeightManagedBySpoolman: 'Il peso della bobina vuota è gestito per tipo di filamento in Spoolman', spoolmanCatalogLoadFailed: 'Impossibile caricare il catalogo Spoolman', }, diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index fd4edc72a..8da8d71ab 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -5027,7 +5027,6 @@ export default { spoolmanFilamentUnlinked: 'フィラメントカタログのリンクを解除しました', noSpoolmanFilaments: 'Spoolmanカタログにフィラメントが見つかりません', spoolmanFilamentColorSwatch: 'フィラメントの色', - spoolWeightManagedBySpoolman: '空スプールの重量はSpoolmanでフィラメントタイプごとに管理されています', spoolmanCatalogLoadFailed: 'Spoolmanのフィラメントカタログを読み込めませんでした', }, diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index eacc0fce4..95bc872ac 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -4792,7 +4792,6 @@ export default { spoolmanFilamentUnlinked: '필라멘트 카탈로그 연결 해제됨', noSpoolmanFilaments: 'Spoolman 카탈로그에서 필라멘트를 찾을 수 없음', spoolmanFilamentColorSwatch: '필라멘트 색상', - spoolWeightManagedBySpoolman: '빈 스풀 무게는 Spoolman에서 필라멘트 유형별로 관리됩니다', spoolmanCatalogLoadFailed: 'Spoolman 필라멘트 카탈로그 불러오기 실패', subtitle: '스풀 관리', storageLocationNone: '위치 미설정', diff --git a/frontend/src/i18n/locales/nl.ts b/frontend/src/i18n/locales/nl.ts index 0b6d78722..ac156b2ab 100644 --- a/frontend/src/i18n/locales/nl.ts +++ b/frontend/src/i18n/locales/nl.ts @@ -5072,7 +5072,6 @@ export default { spoolmanFilamentUnlinked: 'Koppeling met filamentcatalogus gewist', noSpoolmanFilaments: 'Geen filamenten gevonden in Spoolman-catalogus', spoolmanFilamentColorSwatch: 'Filamentkleur', - spoolWeightManagedBySpoolman: 'Leeg spoelgewicht wordt per filamenttype beheerd in Spoolman', spoolmanCatalogLoadFailed: 'Spoolman-filamentcatalogus laden mislukt', }, diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index d6a6cb8ab..8ffb3c445 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -5015,7 +5015,6 @@ export default { spoolmanFilamentUnlinked: 'Vínculo com o catálogo de filamentos removido', noSpoolmanFilaments: 'Nenhum filamento encontrado no catálogo Spoolman', spoolmanFilamentColorSwatch: 'Cor do filamento', - spoolWeightManagedBySpoolman: 'O peso do carretel vazio é gerenciado por tipo de filamento no Spoolman', spoolmanCatalogLoadFailed: 'Falha ao carregar catálogo de filamentos do Spoolman', }, diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index 3703d70a3..1017c2dda 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/frontend/src/i18n/locales/ru.ts @@ -4792,7 +4792,6 @@ export default { spoolmanFilamentUnlinked: "Связь с каталогом филаментов удалена", noSpoolmanFilaments: "В каталоге Spoolman филаменты не найдены", spoolmanFilamentColorSwatch: "Цвет филамента", - spoolWeightManagedBySpoolman: "Масса пустой катушки задаётся в Spoolman для каждого типа филамента", spoolmanCatalogLoadFailed: "Не удалось загрузить каталог филаментов Spoolman", }, timelapse: { diff --git a/frontend/src/i18n/locales/sv.ts b/frontend/src/i18n/locales/sv.ts index 242e979cf..19acfda2a 100644 --- a/frontend/src/i18n/locales/sv.ts +++ b/frontend/src/i18n/locales/sv.ts @@ -5072,7 +5072,6 @@ errors: { spoolmanFilamentUnlinked: 'Filamentkataloglänk rensad', noSpoolmanFilaments: 'Inga filament hittade i Spoolman-katalog', spoolmanFilamentColorSwatch: 'Filamentfärg', - spoolWeightManagedBySpoolman: 'Tom rullvikt hanteras per filamenttyp i Spoolman', spoolmanCatalogLoadFailed: 'Kunde inte ladda Spoolman filamentkatalog', }, diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index be696ac83..d458b4da9 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -5004,7 +5004,6 @@ export default { spoolmanFilamentUnlinked: 'Filament katalog bağlantısı temizlendi', noSpoolmanFilaments: 'Spoolman kataloğunda filament bulunamadı', spoolmanFilamentColorSwatch: 'Filament rengi', - spoolWeightManagedBySpoolman: 'Boş makara ağırlığı Spoolman\'de filament türü başına yönetilir', spoolmanCatalogLoadFailed: 'Spoolman filament kataloğu yüklenemedi', }, diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts index 027383105..bba9b8423 100644 --- a/frontend/src/i18n/locales/uk.ts +++ b/frontend/src/i18n/locales/uk.ts @@ -5069,7 +5069,6 @@ export default { spoolmanFilamentUnlinked: "Посилання на каталог філаментів видалено", noSpoolmanFilaments: "У каталозі Spoolman не знайдено філаментів", spoolmanFilamentColorSwatch: "Колір філаменту", - spoolWeightManagedBySpoolman: "Вага порожньої котушки керується залежно від типу філаменту в Spoolman", spoolmanCatalogLoadFailed: "Не вдалося завантажити каталог філаментів Spoolman", }, diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 0459e99bb..0587dce8c 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -5015,7 +5015,6 @@ export default { spoolmanFilamentUnlinked: '已解除耗材目录关联', noSpoolmanFilaments: 'Spoolman 目录中未找到耗材', spoolmanFilamentColorSwatch: '耗材颜色', - spoolWeightManagedBySpoolman: '空线轴重量在 Spoolman 中按耗材类型管理', spoolmanCatalogLoadFailed: '无法加载 Spoolman 耗材目录', }, diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 1837075fc..67f3e9b53 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -5015,7 +5015,6 @@ export default { spoolmanFilamentUnlinked: '已解除耗材目錄關聯', noSpoolmanFilaments: 'Spoolman 目錄中未找到耗材', spoolmanFilamentColorSwatch: '耗材顏色', - spoolWeightManagedBySpoolman: '空線軸重量在 Spoolman 中按耗材類型管理', spoolmanCatalogLoadFailed: '無法載入 Spoolman 耗材目錄', }, diff --git a/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx b/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx index 469b3749f..1f9bf53e5 100644 --- a/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx +++ b/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx @@ -413,6 +413,11 @@ export function SpoolBuddyDashboard() { try { const weight = liveWeight ?? displayedWeight; if (spoolmanMode) { + // No tare is sent. The quick-create has no input for one, so the 250 + // that used to go here was a placeholder, not a user's choice -- and + // since #2908 a sent value is written to the spool's own spool_weight, + // which would stamp every spool the kiosk creates and stop it + // inheriting the filament type's. const created = await api.createSpoolmanInventorySpool({ material: 'PLA', subtype: null, @@ -422,8 +427,6 @@ export function SpoolBuddyDashboard() { effect_type: null, brand: null, label_weight: 1000, - core_weight: 250, - core_weight_catalog_id: null, weight_used: 0, slicer_filament: null, slicer_filament_name: null, @@ -442,7 +445,7 @@ export function SpoolBuddyDashboard() { last_weighed_at: weight !== null ? new Date().toISOString() : null, category: null, low_stock_threshold_pct: null, - }); + } as Parameters[0]); await api.linkTagToSpoolmanSpool(created.id, { tag_uid: sbState.unknownTagUid || undefined, tray_uuid: (!sbState.unknownTagUid && sbState.unknownTrayUuid) ? sbState.unknownTrayUuid : undefined, diff --git a/frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx b/frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx index 975f77f7b..296490622 100644 --- a/frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx +++ b/frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx @@ -470,6 +470,10 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM const [viewMode, setViewMode] = useState('simple'); const [activeSubTab, setActiveSubTab] = useState('filament'); const [formData, setFormData] = useState(defaultFormData); + // The empty spool weight picker is on screen in Spoolman mode too. Track + // whether the user reached for it, so an untouched form does not send its + // default (issue #2908). + const [coreWeightTouched, setCoreWeightTouched] = useState(false); const [errors, setErrors] = useState>>({}); const [quickAdd, setQuickAdd] = useState(false); const [quantity, setQuantity] = useState(1); @@ -671,6 +675,9 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM const updateField = (key: K, value: SpoolFormData[K]) => { setFormData(prev => ({ ...prev, [key]: value })); + if (key === 'core_weight') { + setCoreWeightTouched(true); + } if (errors[key]) { setErrors(prev => ({ ...prev, [key]: undefined })); } @@ -736,7 +743,7 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM } const presetName = selectedPresetOption?.displayName || presetInputValue || null; - const payload = { + const payload: Record = { material: formData.material, subtype: formData.subtype || null, brand: formData.brand || null, @@ -745,8 +752,15 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM extra_colors: formData.extra_colors || null, effect_type: formData.effect_type || null, label_weight: formData.label_weight, - core_weight: formData.core_weight, - core_weight_catalog_id: formData.core_weight_catalog_id, + // Only send a per-spool tare in Spoolman mode when the user actually set + // one here; otherwise let it keep inheriting from the filament type. + // The catalogue id has no field on the Spoolman side, so a catalogue + // selection does not round-trip there; only the weight does. + ...(spoolmanMode + ? coreWeightTouched + ? { core_weight: formData.core_weight } + : {} + : { core_weight: formData.core_weight, core_weight_catalog_id: formData.core_weight_catalog_id }), weight_used: formData.weight_used, slicer_filament: formData.slicer_filament || null, slicer_filament_name: presetName, @@ -774,8 +788,8 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM // internal bulk returns InventorySpool[]. Mirrors SpoolFormModal's // duck-typed handling so partial failures surface as a warning toast. const raw = spoolmanMode - ? await api.bulkCreateSpoolmanInventorySpools(payload, quantity) - : await api.bulkCreateSpools(payload, quantity); + ? await api.bulkCreateSpoolmanInventorySpools(payload as Parameters[0], quantity) + : await api.bulkCreateSpools(payload as Parameters[0], quantity); const created: InventorySpool[] = spoolmanMode && raw && typeof raw === 'object' && 'created' in raw ? (raw as { created: InventorySpool[] }).created @@ -786,8 +800,8 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM if (created.length > 0) onCreated(created[0]); } else { const created = spoolmanMode - ? await api.createSpoolmanInventorySpool(payload) - : await api.createSpool(payload); + ? await api.createSpoolmanInventorySpool(payload as Parameters[0]) + : await api.createSpool(payload as Parameters[0]); await saveKProfiles(created.id); onCreated(created); }