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

Let a spool keep its own empty weight (issue #2908) (#3011)

Kouki Ojima 4 дней назад
Родитель
Сommit
b6521e93f8
31 измененных файлов с 559 добавлено и 82 удалено
  1. 6 0
      backend/app/api/routes/_spoolman_helpers.py
  2. 18 6
      backend/app/api/routes/spoolman_inventory.py
  3. 6 0
      backend/app/services/spoolman.py
  4. 129 3
      backend/tests/integration/test_spoolman_inventory_api.py
  5. 12 0
      backend/tests/unit/test_spoolman_inventory_helpers.py
  6. 40 0
      backend/tests/unit/test_spoolman_inventory_methods.py
  7. 2 18
      frontend/src/__tests__/components/AdditionalSection.test.tsx
  8. 171 0
      frontend/src/__tests__/components/SpoolFormModal.test.tsx
  9. 27 0
      frontend/src/__tests__/pages/SpoolBuddyDashboard.test.tsx
  10. 78 1
      frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx
  11. 3 0
      frontend/src/api/client.ts
  12. 30 13
      frontend/src/components/SpoolFormModal.tsx
  13. 10 13
      frontend/src/components/spool-form/AdditionalSection.tsx
  14. 0 3
      frontend/src/components/spool-form/types.ts
  15. 0 1
      frontend/src/i18n/locales/de.ts
  16. 0 1
      frontend/src/i18n/locales/en.ts
  17. 0 1
      frontend/src/i18n/locales/es.ts
  18. 0 1
      frontend/src/i18n/locales/fr.ts
  19. 0 1
      frontend/src/i18n/locales/it.ts
  20. 0 1
      frontend/src/i18n/locales/ja.ts
  21. 0 1
      frontend/src/i18n/locales/ko.ts
  22. 0 1
      frontend/src/i18n/locales/nl.ts
  23. 0 1
      frontend/src/i18n/locales/pt-BR.ts
  24. 0 1
      frontend/src/i18n/locales/ru.ts
  25. 0 1
      frontend/src/i18n/locales/sv.ts
  26. 0 1
      frontend/src/i18n/locales/tr.ts
  27. 0 1
      frontend/src/i18n/locales/uk.ts
  28. 0 1
      frontend/src/i18n/locales/zh-CN.ts
  29. 0 1
      frontend/src/i18n/locales/zh-TW.ts
  30. 6 3
      frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx
  31. 21 7
      frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

+ 6 - 0
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,

+ 18 - 6
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.

+ 6 - 0
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)

+ 129 - 3
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

+ 12 - 0
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

+ 40 - 0
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"]

+ 2 - 18
frontend/src/__tests__/components/AdditionalSection.test.tsx

@@ -21,25 +21,9 @@ const baseProps = {
 };
 
 describe('AdditionalSection', () => {
-  it('renders SpoolWeightPicker when spoolmanMode is false', () => {
-    render(<AdditionalSection {...baseProps} spoolmanMode={false} />);
-    // 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(<AdditionalSection {...baseProps} spoolmanMode={true} />);
-    // 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(<AdditionalSection {...baseProps} />);
-    // SpoolWeightPicker present by default
+    // SpoolWeightPicker renders the 'inventory.coreWeight' label
     expect(screen.getByText('inventory.coreWeight')).toBeTruthy();
   });
 });

+ 171 - 0
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(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={spoolmanSpool}
+        mode="edit"
+        currencySymbol="$"
+        spoolmanMode={true}
+      />
+    );
+    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<string, unknown>;
+  }
+
+  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(<SpoolFormModal isOpen={true} onClose={vi.fn()} currencySymbol="$" spoolmanMode={true} />);
+    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<string, unknown>;
+    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(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={{ ...spoolmanSpool, core_weight: 0, core_weight_is_inherited: false }}
+        mode="edit"
+        currencySymbol="$"
+        spoolmanMode={true}
+      />
+    );
+    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(
+      <SpoolFormModal isOpen={true} onClose={vi.fn()} spool={source} mode="copy" currencySymbol="$" spoolmanMode={true} />
+    );
+    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<string, unknown>;
+  }
+
+  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');
+  });
+});

+ 27 - 0
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({

+ 78 - 1
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<string, unknown>;
+    }
+
+    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');
+    });
+  });
 });

+ 3 - 0
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

+ 30 - 13
frontend/src/components/SpoolFormModal.tsx

@@ -75,6 +75,11 @@ export function SpoolFormModal({
   const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
   const [activeTab, setActiveTab] = useState<TabId>('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}
                 />
               </div>
 

+ 10 - 13
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.
     <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-4">
-      {/* 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). */}
       <div className="sm:col-span-2">
-        {spoolmanMode ? (
-          <p className="text-xs text-bambu-gray px-1">{t('inventory.spoolWeightManagedBySpoolman')}</p>
-        ) : (
-          <SpoolWeightPicker
-            catalog={spoolCatalog}
-            value={formData.core_weight}
-            onChange={(weight) => updateField('core_weight', weight)}
-            catalogId={formData.core_weight_catalog_id}
-            onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)}
-          />
-        )}
+        <SpoolWeightPicker
+          catalog={spoolCatalog}
+          value={formData.core_weight}
+          onChange={(weight) => updateField('core_weight', weight)}
+          catalogId={formData.core_weight_catalog_id}
+          onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)}
+        />
       </div>
 
       {/* Current Weight (remaining filament) */}

+ 0 - 3
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

+ 0 - 1
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',
   },
 

+ 0 - 1
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',
   },
 

+ 0 - 1
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',
   },
 

+ 0 - 1
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',
   },
 

+ 0 - 1
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',
   },
 

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

@@ -5027,7 +5027,6 @@ export default {
     spoolmanFilamentUnlinked: 'フィラメントカタログのリンクを解除しました',
     noSpoolmanFilaments: 'Spoolmanカタログにフィラメントが見つかりません',
     spoolmanFilamentColorSwatch: 'フィラメントの色',
-    spoolWeightManagedBySpoolman: '空スプールの重量はSpoolmanでフィラメントタイプごとに管理されています',
     spoolmanCatalogLoadFailed: 'Spoolmanのフィラメントカタログを読み込めませんでした',
   },
 

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

@@ -4792,7 +4792,6 @@ export default {
     spoolmanFilamentUnlinked: '필라멘트 카탈로그 연결 해제됨',
     noSpoolmanFilaments: 'Spoolman 카탈로그에서 필라멘트를 찾을 수 없음',
     spoolmanFilamentColorSwatch: '필라멘트 색상',
-    spoolWeightManagedBySpoolman: '빈 스풀 무게는 Spoolman에서 필라멘트 유형별로 관리됩니다',
     spoolmanCatalogLoadFailed: 'Spoolman 필라멘트 카탈로그 불러오기 실패',
     subtitle: '스풀 관리',
     storageLocationNone: '위치 미설정',

+ 0 - 1
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',
   },
 

+ 0 - 1
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',
   },
 

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

@@ -4792,7 +4792,6 @@ export default {
     spoolmanFilamentUnlinked: "Связь с каталогом филаментов удалена",
     noSpoolmanFilaments: "В каталоге Spoolman филаменты не найдены",
     spoolmanFilamentColorSwatch: "Цвет филамента",
-    spoolWeightManagedBySpoolman: "Масса пустой катушки задаётся в Spoolman для каждого типа филамента",
     spoolmanCatalogLoadFailed: "Не удалось загрузить каталог филаментов Spoolman",
   },
   timelapse: {

+ 0 - 1
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',
   },
 

+ 0 - 1
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',
   },
 

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

@@ -5069,7 +5069,6 @@ export default {
     spoolmanFilamentUnlinked: "Посилання на каталог філаментів видалено",
     noSpoolmanFilaments: "У каталозі Spoolman не знайдено філаментів",
     spoolmanFilamentColorSwatch: "Колір філаменту",
-    spoolWeightManagedBySpoolman: "Вага порожньої котушки керується залежно від типу філаменту в Spoolman",
     spoolmanCatalogLoadFailed: "Не вдалося завантажити каталог філаментів Spoolman",
   },
 

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

@@ -5015,7 +5015,6 @@ export default {
     spoolmanFilamentUnlinked: '已解除耗材目录关联',
     noSpoolmanFilaments: 'Spoolman 目录中未找到耗材',
     spoolmanFilamentColorSwatch: '耗材颜色',
-    spoolWeightManagedBySpoolman: '空线轴重量在 Spoolman 中按耗材类型管理',
     spoolmanCatalogLoadFailed: '无法加载 Spoolman 耗材目录',
   },
 

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

@@ -5015,7 +5015,6 @@ export default {
     spoolmanFilamentUnlinked: '已解除耗材目錄關聯',
     noSpoolmanFilaments: 'Spoolman 目錄中未找到耗材',
     spoolmanFilamentColorSwatch: '耗材顏色',
-    spoolWeightManagedBySpoolman: '空線軸重量在 Spoolman 中按耗材類型管理',
     spoolmanCatalogLoadFailed: '無法載入 Spoolman 耗材目錄',
   },
 

+ 6 - 3
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<typeof api.createSpoolmanInventorySpool>[0]);
         await api.linkTagToSpoolmanSpool(created.id, {
           tag_uid: sbState.unknownTagUid || undefined,
           tray_uuid: (!sbState.unknownTagUid && sbState.unknownTrayUuid) ? sbState.unknownTrayUuid : undefined,

+ 21 - 7
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -470,6 +470,10 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
   const [viewMode, setViewMode] = useState<NewSpoolViewMode>('simple');
   const [activeSubTab, setActiveSubTab] = useState<NewSpoolSubTab>('filament');
   const [formData, setFormData] = useState<SpoolFormData>(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<Partial<Record<keyof SpoolFormData, string>>>({});
   const [quickAdd, setQuickAdd] = useState(false);
   const [quantity, setQuantity] = useState(1);
@@ -671,6 +675,9 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
 
   const updateField = <K extends keyof SpoolFormData>(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<string, unknown> = {
       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<typeof api.bulkCreateSpoolmanInventorySpools>[0], quantity)
+          : await api.bulkCreateSpools(payload as Parameters<typeof api.bulkCreateSpools>[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<typeof api.createSpoolmanInventorySpool>[0])
+          : await api.createSpool(payload as Parameters<typeof api.createSpool>[0]);
         await saveKProfiles(created.id);
         onCreated(created);
       }