Explorar o código

Use the Spoolman spool's own initial_weight as its label weight (#3194)

Spoolman keeps a full spool's net weight on the spool (initial_weight)
and falls back to the filament's weight, but Bambuddy read only the
filament, so a 250 g spool of a 1000 g filament showed and synced as
1000 g. The list, weigh, AMS sync, SpoolBuddy scale, remain-% tracking,
fill bar and cost now share one lookup. Create writes initial_weight,
and a label-weight edit writes it instead of patching or duplicating the
filament. The spool form's cost per kg is converted at the spool's size
to and from Spoolman's per-spool price.
maziggy hai 2 días
pai
achega
2f7f17a891

+ 1 - 0
CHANGELOG.md

@@ -52,6 +52,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **In Spoolman mode, each spool's own size is used, not its filament's (#3194, reported by @worried-networking)** — Spoolman stores how much filament a full spool holds on the spool (**Initial Weight**) and uses the filament's **Weight** only when the spool has none, so one filament can have spools of different sizes. Bambuddy read only the filament, so a 250 g spool of a 1000 g filament showed as 1000 g with the wrong fill level, **Sync Weights from AMS** and AMS-percentage usage tracking charged it four times the real grams, and the AMS hover card's Spoolman fill level was off the same way. All of these, the weigh action and the SpoolBuddy scale now use the spool's own size. Creating a spool writes its **Label Weight** to the spool's Initial Weight, so a 250 g spool of a 1000 g catalogue filament is recorded as 250 g. Editing the Label Weight changes only that spool: it used to change the filament's weight while Spoolman kept the spool's old size, so the two disagreed from then on, and on a filament shared with other spools it linked this spool to a new duplicate filament instead. The spool form's **Cost per kg** is stored as Spoolman's spool **Price**, which Spoolman treats as the price of the whole spool; it is now converted at the spool's size both ways, and print cost divides a spool's own price by the spool's size. A catalogue price on the filament is still divided by the filament's weight. For 1000 g spools nothing changes. Saving a spool without changing its size or price no longer writes either, so a 250.7 g spool stays 250.7 g. Editing a spool that has no weight in Spoolman at all, on the spool or its filament, failed with an error; it now saves, and a Label Weight saved from the spool form becomes the spool's size.
 - **In Spoolman mode, weighing a spool now uses the vendor's empty spool weight (#3195, reported by @worried-networking)** — Spoolman finds a spool's empty weight in three places: the spool's own **Spool Weight**, then the filament's, then the vendor's **Empty Spool Weight**. Its own `/measure` endpoint uses that order. Bambuddy stopped after the filament and used 250 g instead of the vendor's value. So a spool whose tare was set only on its vendor weighed against the wrong core: a Sunlu spool (211.7 g) at 1177 g on the scale was saved as 927 g remaining instead of 965.3 g. The same error was in three places, and each is fixed: the weigh action in the inventory, the SpoolBuddy scale, and the **Empty Spool Weight** shown in the spool form and used for gross weight in the list. All three now share one lookup, so they can't drift apart again. The SpoolBuddy "no spool weight set, using 250 g" warning now appears only when the spool, the filament and the vendor all have no weight. The spool form still sends the empty weight to Spoolman only when you change it, so opening and saving a spool does not copy the vendor's value onto it. Changing a filament's spool weight with **Keep old weight for existing spools** now keeps a vendor-inherited tare too: when the filament had no weight of its own, the spools that inherited the vendor's value get that value, not the new filament weight.
 - **A P1P, P1S or X1E added by discovery is saved as the right printer** — Bambu printers announce themselves with an internal code, and the table that turns it into a model name had the P-series codes shifted: a discovered P1S was saved as a P1P, a P1P as a P1S, and an X1E as a P2S. The same mix-up sat in the backend: a file sliced for a P1P or P1S could say it was sliced for an X1C or X1, the X1E's code checked the P2S firmware line, and the P2S's code was missing. All of them now agree with what the printers and 3MF files actually report. Printers saved under a raw code by an older version also get the right ethernet, rod-type and storage handling. A printer that was already saved with the wrong model keeps it: pick the right one under **Model** in **Edit** from the printer card's menu.
 - **Generate Thumbnails works for users who can edit only their own files, and external folder scans no longer wait on PDF thumbnails** — The toolbar's **Generate Thumbnails** button and the file menu's **Generate Thumbnail** entry were offered to users who may edit only their own library files, but the server refused them. For those users both now cover the files they uploaded themselves; users who may edit all files still get everyone's. Scanning an external folder rendered each new PDF's thumbnail before the scan finished, which on a Raspberry Pi with a share full of PDFs kept the scan waiting for a long time. PDF thumbnails are now made in the background after the scan, like STL thumbnails already were.

+ 63 - 2
backend/app/api/routes/_spoolman_helpers.py

@@ -176,6 +176,66 @@ def spoolman_tare(spool: dict) -> tuple[float, str]:
     return SPOOLMAN_FALLBACK_TARE, "fallback"
 
 
+def spoolman_net_weight(spool: dict) -> float | None:
+    """The net filament weight of a full Spoolman spool (its label weight).
+
+    Spoolman keeps it on the spool as ``initial_weight`` and falls back to the
+    filament's catalogue ``weight``, so one filament can have spools of
+    different sizes. Reading only the filament made a 250 g spool of a 1000 g
+    filament look four times its size (#3194). Like Spoolman, an
+    ``initial_weight`` of 0 counts as unset.
+
+    Returns None when neither level has a finite number (a bool is not
+    one); the filament's value is otherwise returned as stored, 0 included,
+    so callers keep their own handling of a filament without a weight.
+    """
+    initial = _weight_or_none(spool.get("initial_weight"))
+    if initial is not None and initial > 0:
+        return initial
+    filament = spool.get("filament")
+    return _weight_or_none(filament.get("weight")) if isinstance(filament, dict) else None
+
+
+def _weight_or_none(value: object) -> float | None:
+    # A bool is an int in Python; True must not read as a 1 g spool.
+    return None if isinstance(value, bool) else _safe_optional_float(value)
+
+
+def spoolman_price_to_cost_per_kg(price: object, net_weight: float) -> float | None:
+    """A Spoolman spool price as a price per kilogram.
+
+    Spoolman's ``price`` on a spool is what that spool cost, not a rate; the
+    spool form edits a rate, so the two are converted at the spool's net
+    weight. They agree only for 1000 g spools (#3194). Rounded to 4 places so
+    a round trip through the form doesn't drift by float noise.
+    """
+    value = _safe_optional_float(price)
+    if value is None or net_weight <= 0:
+        return None
+    return round(value * 1000.0 / net_weight, 4)
+
+
+def cost_per_kg_to_spoolman_price(cost_per_kg: float, net_weight: float) -> float:
+    """Inverse of spoolman_price_to_cost_per_kg: the price of a spool of ``net_weight`` grams."""
+    return round(cost_per_kg * net_weight / 1000.0, 4)
+
+
+def spoolman_price_weight(spool: dict) -> float:
+    """The weight a Spoolman spool's price is converted at.
+
+    The spool's exact net weight, not the whole-gram label weight, so a
+    250.7 g spool is not re-priced by a no-op edit. A spool with no usable
+    weight keeps the old 1:1 reading of its price as a per-kg rate.
+    """
+    net = spoolman_net_weight(spool)
+    return net if net is not None and net > 0 else 1000.0
+
+
+def spoolman_cost_per_kg(spool: dict) -> float | None:
+    """The spool form's cost per kg for a Spoolman spool (#3194)."""
+    return spoolman_price_to_cost_per_kg(spool.get("price"), spoolman_price_weight(spool))
+
+
 def _extract_extra_str(extra: dict, key: str) -> str:
     """Extract a JSON-encoded string from a Spoolman extra dict.
 
@@ -322,7 +382,7 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
     extra_stops = parse_spoolman_multi_colors(filament)
     extra_colors: str | None = ",".join(extra_stops) if extra_stops else None
 
-    label_weight: int = _safe_int(filament.get("weight"), 1000)
+    label_weight: int = _safe_int(spoolman_net_weight(spool), 1000)
     real_used_weight: float = _safe_float(spool.get("used_weight"), 0.0)
     # Parity with internal mode (#1390): the InventorySpool shape lets the
     # frontend compute `remaining = label_weight - weight_used` and
@@ -447,7 +507,8 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
         "created_at": created_at,
         # Spoolman has no updated_at field; use registered timestamp as best available proxy
         "updated_at": created_at,
-        "cost_per_kg": _safe_optional_float(spool.get("price")),
+        # Spoolman's spool price is what the whole spool cost (#3194).
+        "cost_per_kg": spoolman_cost_per_kg(spool),
         # Spoolman's filament.article_number maps 1:1 onto the internal
         # material number (#2870): both identify the purchasable product.
         # Trimmed for the same reason the schema validator trims the internal

+ 2 - 3
backend/app/api/routes/spoolbuddy.py

@@ -891,7 +891,7 @@ async def update_spool_weight(
     update while the Spoolman row the user is actually looking at stayed
     unchanged (#1530). Mirrors the routing already used by ``nfc/tag-scanned``.
     """
-    from backend.app.api.routes._spoolman_helpers import _safe_float, spoolman_tare
+    from backend.app.api.routes._spoolman_helpers import _safe_float, spoolman_net_weight, spoolman_tare
     from backend.app.models.spool import Spool
 
     sm_client = await _get_spoolman_client_or_none(db)
@@ -920,7 +920,6 @@ async def update_spool_weight(
     async with _translate_spoolbuddy_errors():
         sm_spool = await sm_client.get_spool(req.spool_id)
 
-    filament = sm_spool.get("filament") or {}
     core_weight, tare_source = spoolman_tare(sm_spool)
     spool_weight_warning: str | None = None
     if tare_source == "fallback":
@@ -932,7 +931,7 @@ async def update_spool_weight(
             "spool_weight_not_set: Spoolman spool, filament and vendor have no empty-spool weight configured; "
             "weight estimate uses 250g fallback"
         )
-    label_weight = _safe_float(filament.get("weight"), 1000.0)
+    label_weight = _safe_float(spoolman_net_weight(sm_spool), 1000.0)
     remaining_weight = max(0.0, req.weight_grams - core_weight)
 
     async with _translate_spoolbuddy_errors():

+ 4 - 3
backend/app/api/routes/spoolman.py

@@ -10,7 +10,7 @@ from sqlalchemy import delete, select, text
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
-from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
+from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool, spoolman_net_weight
 from backend.app.api.routes.spoolman_inventory import _clear_stale_tag_links
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
@@ -804,11 +804,12 @@ async def get_linked_spools(
             # Remove quotes if present (JSON encoded string)
             clean_tag = tag.strip('"').upper()
             if clean_tag:
-                filament = spool.get("filament") or {}
                 linked[clean_tag] = {
                     "id": spool["id"],
                     "remaining_weight": spool.get("remaining_weight"),
-                    "filament_weight": filament.get("weight"),
+                    # The spool's own net weight, falling back to the
+                    # filament's; the key predates initial_weight (#3194).
+                    "filament_weight": spoolman_net_weight(spool),
                 }
 
     return {"linked": linked}

+ 59 - 10
backend/app/api/routes/spoolman_inventory.py

@@ -29,6 +29,10 @@ from backend.app.api.routes._spoolman_helpers import (
     _safe_int,
     _safe_optional_float,
     assert_safe_spoolman_url,
+    cost_per_kg_to_spoolman_price,
+    spoolman_cost_per_kg,
+    spoolman_net_weight,
+    spoolman_price_weight,
     spoolman_tare,
 )
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
@@ -258,6 +262,9 @@ def _raise_if_partial_failure(spools: list[dict], results: list, operation: str)
 async def _apply_price_if_set(client: SpoolmanClient, spool: dict, cost_per_kg: float | None) -> tuple[dict, list[str]]:
     """Patch the spool price; return (updated_spool, warnings).
 
+    Spoolman's spool price is what the whole spool cost, so the per-kg rate is
+    converted at the net weight of the spool Spoolman just created (#3194).
+
     Returns the original spool and a non-empty warnings list when the price
     update fails, so the caller can return HTTP 207 instead of silently
     discarding the price.
@@ -266,7 +273,9 @@ async def _apply_price_if_set(client: SpoolmanClient, spool: dict, cost_per_kg:
         return spool, []
     try:
         async with _translate_spoolman_errors():
-            updated = await client.update_spool_full(spool["id"], price=cost_per_kg)
+            updated = await client.update_spool_full(
+                spool["id"], price=cost_per_kg_to_spoolman_price(cost_per_kg, spoolman_price_weight(spool))
+            )
         return updated, []
     except HTTPException as exc:
         if exc.status_code >= 500:
@@ -580,6 +589,10 @@ async def create_spool(
                 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),
+                # Only a label weight the caller gave: left off, Spoolman uses
+                # the filament's weight, which is the right size for a caller
+                # that didn't say (#3194).
+                initial_weight=(float(data.label_weight) if "label_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:
@@ -670,6 +683,10 @@ async def bulk_create_spools(
                 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),
+                # Only a label weight the caller gave: left off, Spoolman uses
+                # the filament's weight, which is the right size for a caller
+                # that didn't say (#3194).
+                initial_weight=(float(data.label_weight) if "label_weight" in data.model_fields_set else None),
             )
         except (SpoolmanUnavailableError, SpoolmanClientError, SpoolmanNotFoundError) as exc:
             logger.warning("Bulk spool creation: one spool failed: %s", exc)
@@ -749,7 +766,17 @@ async def update_spool(
     # straight back off by `spoolman_color_hex` below, so the two paths landed on
     # the same string and the append only obscured which shape was in hand (#2912).
     rgba = data.rgba if data.rgba is not None else cur_color
-    label_weight = data.label_weight if data.label_weight is not None else int(cur_filament.get("weight") or 1000)
+    # The label weight is the spool's own net weight (initial_weight), not the
+    # filament's: one filament can have spools of different sizes (#3194).
+    # The form shows it in whole grams, so it only counts as changed when it
+    # differs from that; the exact stored value is otherwise left alone. A
+    # spool with no size at either level takes the one it is given: Spoolman
+    # refuses a remaining_weight until the spool has one.
+    cur_net_weight = spoolman_net_weight(current)
+    cur_has_size = cur_net_weight is not None and cur_net_weight > 0
+    cur_label_weight = _safe_int(cur_net_weight, 1000) or 1000
+    label_weight = data.label_weight if data.label_weight is not None else cur_label_weight
+    label_weight_changed = data.label_weight is not None and (not cur_has_size or data.label_weight != cur_label_weight)
     # Default weight_used from the synthetic mapping (label - remaining) so an
     # edit that doesn't touch the weight field preserves Spoolman's real
     # remaining_weight after a "Reset usage to 0" — the previous code read
@@ -795,14 +822,16 @@ async def update_spool(
     # alpha-only edit invisible so the change never lands (#2912).
     cur_color_norm = spoolman_color_hex(cur_filament.get("color_hex")) or ""
     cur_vendor_name = (cur_vendor.get("name") or "").strip()
-    cur_weight_int = int(cur_filament.get("weight") or 0)
+    # The label weight is not part of this: it is written to the spool's
+    # initial_weight below. Comparing it against the filament's weight PATCHed
+    # the filament for every spool of another size, or on a shared filament
+    # relinked the spool to a new duplicate one (#3194).
     metadata_unchanged = (
         cur_filament_id
         and (cur_filament.get("name") or "").strip() == desired_name
         and (cur_filament.get("material") or "").upper() == material.upper()
         and cur_color_norm == (color_hex or "").upper()
         and cur_vendor_name.lower() == ((brand or "").strip().lower())
-        and cur_weight_int == int(label_weight)
     )
 
     if metadata_unchanged:
@@ -820,7 +849,6 @@ async def update_spool(
                 "name": desired_name,
                 "material": material,
                 "color_hex": color_hex,
-                "weight": float(label_weight),
             }
             if brand:
                 vendor_id = await client.find_or_create_vendor(brand)
@@ -846,6 +874,25 @@ async def update_spool(
 
     remaining = max(0.0, label_weight - weight_used)
 
+    # The spool's size lives on the spool (initial_weight), never the filament
+    # (#3194). A relink also sends the current size: Spoolman stamps the new
+    # filament's weight on a spool without an initial_weight of its own.
+    new_initial_weight: float | None = None
+    if label_weight_changed:
+        new_initial_weight = float(label_weight)
+    elif filament_id != cur_filament_id and cur_has_size:
+        new_initial_weight = cur_net_weight
+
+    # Spoolman's price is per spool, the form's per kg (#3194). An unchanged
+    # rate on an unchanged size is not written, so a no-op edit cannot move the
+    # price by rounding. On a resize the rate is kept, as in internal mode.
+    cur_cost_per_kg = spoolman_cost_per_kg(current)
+    cost_per_kg = data.cost_per_kg if data.cost_per_kg is not None else cur_cost_per_kg
+    new_price: float | None = None
+    if cost_per_kg is not None and (label_weight_changed or cost_per_kg != cur_cost_per_kg):
+        price_weight = float(label_weight) if label_weight_changed else spoolman_price_weight(current)
+        new_price = cost_per_kg_to_spoolman_price(cost_per_kg, price_weight)
+
     # Tag removal: clear only the "tag" key so other custom Spoolman extra fields
     # set outside Bambuddy are preserved.
     tag_nulled = (
@@ -877,9 +924,12 @@ async def update_spool(
             updated = await client.update_spool_full(
                 spool_id=spool_id,
                 filament_id=filament_id,
-                remaining_weight=remaining,
+                # Without a size Spoolman rejects the whole PATCH (HTTP 400),
+                # so a spool that still has none gets no remaining_weight.
+                remaining_weight=(remaining if new_initial_weight is not None or cur_has_size else None),
                 comment=note or "",
-                price=data.cost_per_kg,
+                price=new_price,
+                initial_weight=new_initial_weight,
                 extra=extra,
                 location=storage_location or None,
                 clear_location=storage_location_changed and not storage_location,
@@ -1202,8 +1252,7 @@ async def sync_spool_weight(
     async with _translate_spoolman_errors():
         updated = await client.update_spool_full(spool_id=spool_id, remaining_weight=remaining)
 
-    upd_filament = updated.get("filament") or {}
-    label_weight = _safe_int(upd_filament.get("weight"), 1000)
+    label_weight = _safe_int(spoolman_net_weight(updated), 1000)
     weight_used = max(0.0, label_weight - remaining)
     await ws_manager.broadcast({"type": "inventory_changed"})
     return {"status": "ok", "weight_used": weight_used}
@@ -1409,7 +1458,7 @@ async def sync_spoolman_ams_weights(
             skipped += 1
             continue
 
-        label_weight = _safe_int((spool_dict.get("filament") or {}).get("weight"), 1000)
+        label_weight = _safe_int(spoolman_net_weight(spool_dict), 1000)
         if label_weight <= 0:
             logger.debug("Spoolman AMS sync: spool %d has no label_weight, skipping", assignment.spoolman_spool_id)
             skipped += 1

+ 7 - 7
backend/app/services/filament_deficit.py

@@ -38,7 +38,7 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
-from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
+from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool, spoolman_net_weight
 from backend.app.core.config import settings as app_settings
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.spool_assignment import SpoolAssignment
@@ -144,15 +144,15 @@ async def _spoolman_remaining_grams(spoolman_spool_id: int) -> float | None:
         return None
 
     # Spoolman exposes either an absolute remaining_weight, or used_weight +
-    # filament.weight. Either is sufficient — prefer remaining_weight when
-    # present (the user may have overridden it).
+    # the spool's net weight (initial_weight, else filament.weight; #3194).
+    # Either is sufficient — prefer remaining_weight when present (the user
+    # may have overridden it).
     remaining = spool.get("remaining_weight")
     if isinstance(remaining, (int, float)) and remaining >= 0:
         return float(remaining)
 
     used = spool.get("used_weight")
-    filament = spool.get("filament") or {}
-    total = filament.get("weight")
+    total = spoolman_net_weight(spool)
     if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
         return max(0.0, float(total) - float(used))
 
@@ -525,7 +525,7 @@ async def build_slot_materials(db: AsyncSession, printer_id: int) -> list[SlotMa
                 remaining = float(rw)
             else:
                 used = spool_dict.get("used_weight")
-                total = (spool_dict.get("filament") or {}).get("weight")
+                total = spoolman_net_weight(spool_dict)
                 if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
                     remaining = max(0.0, float(total) - float(used))
             if remaining is None:
@@ -704,7 +704,7 @@ async def compute_deficit_for_queue_item(
                     remaining = float(rw)
                 else:
                     used = spool_dict.get("used_weight")
-                    total = (spool_dict.get("filament") or {}).get("weight")
+                    total = spoolman_net_weight(spool_dict)
                     if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
                         remaining = max(0.0, float(total) - float(used))
         else:

+ 11 - 0
backend/app/services/spoolman.py

@@ -414,9 +414,14 @@ class SpoolmanClient:
         comment: str | None = None,
         extra: dict | None = None,
         spool_weight: float | None = None,
+        initial_weight: float | None = None,
     ) -> dict:
         """Create a new spool in Spoolman."""
         data: dict = {"filament_id": filament_id}
+        # The spool's own net weight. Left off, Spoolman copies the filament's
+        # weight, which is wrong for a spool of another size (#3194).
+        if initial_weight is not None:
+            data["initial_weight"] = initial_weight
         if remaining_weight is not None:
             data["remaining_weight"] = remaining_weight
         if location:
@@ -744,11 +749,17 @@ class SpoolmanClient:
         extra: dict | None = None,
         spool_weight: float | None = None,
         clear_spool_weight: bool = False,
+        initial_weight: float | None = None,
     ) -> dict:
         """Update a spool with full field support; unlike update_spool, does not auto-set last_used."""
         data: dict = {}
         if filament_id is not None:
             data["filament_id"] = filament_id
+        # Spoolman applies the fields in its own schema order, which puts
+        # initial_weight before remaining_weight, so a remaining_weight in the
+        # same PATCH is measured against the new net weight.
+        if initial_weight is not None:
+            data["initial_weight"] = initial_weight
         if remaining_weight is not None:
             data["remaining_weight"] = remaining_weight
         if comment is not None:

+ 17 - 15
backend/app/services/spoolman_tracking.py

@@ -12,6 +12,7 @@ from dataclasses import dataclass
 
 from sqlalchemy import delete, select
 
+from backend.app.api.routes._spoolman_helpers import spoolman_net_weight
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session
 from backend.app.services.spoolman import (
@@ -693,10 +694,11 @@ def _spool_cost_per_gram(spool: dict | None) -> float | None:
     different vendor, import duty. The spool's own value wins, which is the
     order the Spoolman UI presents them in.
 
-    The divisor is ``filament.weight``: net filament grams, excluding the core.
-    That is the same field the remain-delta path already divides by to turn a
-    remain%% drop into grams, so a spool that can be charged by percentage can
-    always be priced too.
+    Each price is divided by the net weight it was paid for, excluding the
+    core: the spool's price by the spool's own ``initial_weight`` (falling
+    back to the filament's), the catalogue price by ``filament.weight``. A
+    250 g spool of a 1000 g filament was otherwise charged at a quarter of
+    its rate (#3194).
 
     A missing or non-positive price is not a free spool, it is an unpriced one,
     and returns None so the caller can fall back to the global default rate
@@ -715,12 +717,12 @@ def _spool_cost_per_gram(spool: dict | None) -> float | None:
     # client that writes 0 instead is common enough that reading it as free
     # would price a whole print at the default rate while a perfectly good
     # catalogue price sat one level down.
-    raw_price = spool.get("price")
-    if _as_positive_number(raw_price) is None:
-        raw_price = filament.get("price")
-
-    price = _as_positive_number(raw_price)
-    weight = _as_positive_number(filament.get("weight"))
+    price = _as_positive_number(spool.get("price"))
+    if price is not None:
+        weight = _as_positive_number(spoolman_net_weight(spool))
+    else:
+        price = _as_positive_number(filament.get("price"))
+        weight = _as_positive_number(filament.get("weight"))
     if price is None or weight is None:
         return None
     # Both operands can be finite and the quotient still overflow. A non-finite
@@ -1657,7 +1659,7 @@ async def _report_remain_delta_for_slots(
             )
             continue
 
-        # Look up the spool's filament reference weight. Use a fresh GET so
+        # Look up the spool's net weight (#3194). Use a fresh GET so
         # we don't depend on a stale cached_spools list. Failure here is
         # silent-skip rather than fatal — other slots can still be written.
         try:
@@ -1666,10 +1668,10 @@ async def _report_remain_delta_for_slots(
             logger.debug("[SPOOLMAN] AMS%d-T%d: get_spool(%s) failed: %s", ams_id, tray_id, spool_id, exc)
             continue
         filament = spool.get("filament") or {}
-        ref_weight = filament.get("weight")
-        if not ref_weight or ref_weight <= 0:
+        ref_weight = _as_positive_number(spoolman_net_weight(spool))
+        if ref_weight is None:
             logger.debug(
-                "[SPOOLMAN] AMS%d-T%d: spool %s has no filament.weight, skipping remain-delta",
+                "[SPOOLMAN] AMS%d-T%d: spool %s has no initial_weight or filament.weight, skipping remain-delta",
                 ams_id,
                 tray_id,
                 spool_id,
@@ -1688,7 +1690,7 @@ async def _report_remain_delta_for_slots(
             continue
 
         spools_updated += 1
-        # ``spool`` here is the full row fetched above for its filament weight,
+        # ``spool`` here is the full row fetched above for its net weight,
         # so the price is already in hand (#2591).
         if cost_out is not None:
             cost_out.add(grams_used, spool, f"AMS{ams_id}-T{tray_id}")

+ 334 - 0
backend/tests/integration/test_spoolman_initial_weight_3194.py

@@ -0,0 +1,334 @@
+"""Spoolman mode reads a spool's size from its own initial_weight (#3194).
+
+Spoolman keeps the net weight of a full spool on the spool (``initial_weight``)
+and falls back to the filament's catalogue ``weight``, so one filament can have
+spools of different sizes. Bambuddy read only the filament, so a 250 g spool of
+a 1000 g filament showed as 1000 g everywhere, and editing its label weight
+PATCHed the filament instead of the spool.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+# A 250 g spool of a 1000 g filament with 200 g left.
+QUARTER_SPOOL = {
+    "id": 42,
+    "initial_weight": 250.0,
+    "spool_weight": 100.0,
+    "remaining_weight": 200.0,
+    "used_weight": 50.0,
+    "price": 6.25,
+    "filament": {
+        "id": 7,
+        "name": "PLA Basic",
+        "material": "PLA",
+        "color_hex": "FF0000",
+        "weight": 1000.0,
+        "vendor": {"id": 3, "name": "Bambu Lab"},
+    },
+    "location": None,
+    "comment": None,
+    "archived": False,
+    "registered": "2024-01-01T00:00:00+00:00",
+    "extra": {"tag": '"AABBCCDD"'},
+}
+
+
+@pytest.fixture
+async def spoolman_settings(db_session):
+    from backend.app.models.settings import Settings
+
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
+    await db_session.commit()
+
+
+@pytest.fixture
+def client():
+    mock = MagicMock()
+    mock.base_url = "http://localhost:7912"
+    mock.health_check = AsyncMock(return_value=True)
+    mock.get_all_spools = AsyncMock(return_value=[QUARTER_SPOOL])
+    mock.get_spools = AsyncMock(return_value=[QUARTER_SPOOL])
+    mock.get_spool = AsyncMock(return_value=QUARTER_SPOOL)
+    mock.create_spool = AsyncMock(return_value=QUARTER_SPOOL)
+    mock.update_spool = AsyncMock(return_value=None)
+    mock.update_spool_full = AsyncMock(return_value=QUARTER_SPOOL)
+    mock.merge_spool_extra = AsyncMock(return_value=QUARTER_SPOOL)
+    mock.find_or_create_filament = AsyncMock(return_value=7)
+    mock.find_or_create_vendor = AsyncMock(return_value=3)
+    mock.patch_filament = AsyncMock(return_value={"id": 7})
+    mock.is_filament_shared = AsyncMock(return_value=True)
+    mock.ensure_extra_field = AsyncMock(return_value=True)
+    mock.get_distinct_locations = AsyncMock(return_value=[])
+    with (
+        patch("backend.app.api.routes.spoolman_inventory.get_spoolman_client", AsyncMock(return_value=mock)),
+        patch("backend.app.api.routes.spoolman_inventory.init_spoolman_client", AsyncMock(return_value=mock)),
+        patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=mock)),
+        patch("backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none", AsyncMock(return_value=mock)),
+    ):
+        yield mock
+
+
+class TestReadsUseTheSpoolsWeight:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_inventory_shows_the_spools_own_weight(self, async_client: AsyncClient, spoolman_settings, client):
+        response = await async_client.get("/api/v1/spoolman/inventory/spools/42")
+        assert response.status_code == 200
+        body = response.json()
+        assert body["label_weight"] == 250
+        assert body["label_weight"] - body["weight_used"] == pytest.approx(200.0)
+        # 6.25 for 250 g is 25 per kilo
+        assert body["cost_per_kg"] == pytest.approx(25.0)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_weigh_reports_weight_used_against_the_spool(
+        self, async_client: AsyncClient, spoolman_settings, client
+    ):
+        client.update_spool_full = AsyncMock(return_value={**QUARTER_SPOOL, "remaining_weight": 150.0})
+        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42/weight", json={"weight_grams": 250.0})
+        assert response.status_code == 200
+        assert client.update_spool_full.call_args.kwargs["remaining_weight"] == pytest.approx(150.0)
+        assert response.json()["weight_used"] == pytest.approx(100.0)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_spoolbuddy_scale_reports_weight_used_against_the_spool(
+        self, async_client: AsyncClient, spoolman_settings, client
+    ):
+        response = await async_client.post(
+            "/api/v1/spoolbuddy/scale/update-spool-weight", json={"spool_id": 42, "weight_grams": 250.0}
+        )
+        assert response.status_code == 200
+        client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(150.0))
+        assert response.json()["weight_used"] == pytest.approx(100.0)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_linked_spools_fill_reference_is_the_spools_weight(
+        self, async_client: AsyncClient, spoolman_settings, client
+    ):
+        response = await async_client.get("/api/v1/spoolman/spools/linked")
+        assert response.status_code == 200
+        assert response.json()["linked"]["AABBCCDD"]["filament_weight"] == pytest.approx(250.0)
+
+
+class TestCreateWritesTheSpoolsWeight:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_sends_initial_weight_and_the_price_of_the_spool(
+        self, async_client: AsyncClient, spoolman_settings, client
+    ):
+        """A 250 g spool picked from a 1000 g catalogue filament is a 250 g spool."""
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/spools",
+            json={"spoolman_filament_id": 7, "label_weight": 250, "cost_per_kg": 25.0},
+        )
+        assert response.status_code == 200
+        assert client.create_spool.call_args.kwargs["initial_weight"] == 250.0
+        assert client.create_spool.call_args.kwargs["remaining_weight"] == 250.0
+        assert client.update_spool_full.call_args.kwargs["price"] == pytest.approx(6.25)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_create_sends_initial_weight(self, async_client: AsyncClient, spoolman_settings, client):
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk",
+            json={"spool": {"spoolman_filament_id": 7, "label_weight": 250}, "quantity": 2},
+        )
+        assert response.status_code == 200
+        assert client.create_spool.call_count == 2
+        for call in client.create_spool.call_args_list:
+            assert call.kwargs["initial_weight"] == 250.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_without_label_weight_leaves_the_size_to_spoolman(
+        self, async_client: AsyncClient, spoolman_settings, client
+    ):
+        """An API caller that gives no label weight gets the filament's, as
+        before: Spoolman fills initial_weight from the filament itself."""
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/spools", json={"spoolman_filament_id": 7, "cost_per_kg": 25.0}
+        )
+        assert response.status_code == 200
+        assert client.create_spool.call_args.kwargs["initial_weight"] is None
+        # priced at the size Spoolman gave the spool (QUARTER_SPOOL: 250 g)
+        assert client.update_spool_full.call_args.kwargs["price"] == pytest.approx(6.25)
+
+
+class TestEditWritesTheSpoolNotTheFilament:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("shared", [True, False])
+    async def test_label_weight_edit_goes_to_initial_weight(
+        self, async_client: AsyncClient, spoolman_settings, client, shared
+    ):
+        """Neither a shared nor a singleton filament is touched, and no
+        duplicate filament is created for a spool of another size."""
+        client.is_filament_shared = AsyncMock(return_value=shared)
+        response = await async_client.patch(
+            "/api/v1/spoolman/inventory/spools/42",
+            json={"label_weight": 500, "weight_used": 100.0},
+        )
+        assert response.status_code == 200
+        client.patch_filament.assert_not_called()
+        client.find_or_create_filament.assert_not_called()
+        kwargs = client.update_spool_full.call_args.kwargs
+        assert kwargs["filament_id"] == 7
+        assert kwargs["initial_weight"] == 500.0
+        assert kwargs["remaining_weight"] == pytest.approx(400.0)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_untouched_edit_writes_neither_size_nor_price(
+        self, async_client: AsyncClient, spoolman_settings, client
+    ):
+        """The form sends back the label weight and cost per kg it loaded; a
+        save that changes neither must not write them."""
+        response = await async_client.patch(
+            "/api/v1/spoolman/inventory/spools/42",
+            json={"label_weight": 250, "cost_per_kg": 25.0, "note": "moved"},
+        )
+        assert response.status_code == 200
+        client.patch_filament.assert_not_called()
+        kwargs = client.update_spool_full.call_args.kwargs
+        assert kwargs["initial_weight"] is None
+        assert kwargs["price"] is None
+        assert kwargs["remaining_weight"] == pytest.approx(200.0)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_fractional_size_survives_an_untouched_edit(
+        self, async_client: AsyncClient, spoolman_settings, client
+    ):
+        """The form shows 250.7 g as 250; saving it must not cut the spool to 250 g
+        or re-price it."""
+        spool = {**QUARTER_SPOOL, "initial_weight": 250.7, "price": 6.99}
+        client.get_spool = AsyncMock(return_value=spool)
+        loaded = (await async_client.get("/api/v1/spoolman/inventory/spools/42")).json()
+        assert loaded["label_weight"] == 250
+
+        response = await async_client.patch(
+            "/api/v1/spoolman/inventory/spools/42",
+            json={"label_weight": loaded["label_weight"], "cost_per_kg": loaded["cost_per_kg"]},
+        )
+        assert response.status_code == 200
+        kwargs = client.update_spool_full.call_args.kwargs
+        assert kwargs["initial_weight"] is None
+        assert kwargs["price"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_price_is_converted_at_the_spools_weight(self, async_client: AsyncClient, spoolman_settings, client):
+        response = await async_client.patch(
+            "/api/v1/spoolman/inventory/spools/42",
+            json={"label_weight": 250, "cost_per_kg": 30.0},
+        )
+        assert response.status_code == 200
+        assert client.update_spool_full.call_args.kwargs["price"] == pytest.approx(7.5)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("payload", [{"label_weight": 500}, {"label_weight": 500, "cost_per_kg": 25.0}])
+    async def test_resize_keeps_the_rate_per_kg(self, async_client: AsyncClient, spoolman_settings, client, payload):
+        """25 per kg stays 25 per kg when the spool is resized, as in internal
+        mode, whether or not the caller (e.g. Bulk Edit) sends the rate."""
+        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
+        assert response.status_code == 200
+        kwargs = client.update_spool_full.call_args.kwargs
+        assert kwargs["initial_weight"] == 500.0
+        assert kwargs["price"] == pytest.approx(12.5)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_colour_change_on_shared_filament_relinks_without_resizing(
+        self, async_client: AsyncClient, spoolman_settings, client
+    ):
+        """A relink to a filament of another weight keeps the spool's size:
+        Spoolman would otherwise stamp the new filament's weight on a spool
+        without an initial_weight of its own."""
+        client.find_or_create_filament = AsyncMock(return_value=8)
+        spool = {**QUARTER_SPOOL, "initial_weight": None, "remaining_weight": 800.0, "used_weight": 200.0}
+        client.get_spool = AsyncMock(return_value=spool)
+        response = await async_client.patch(
+            "/api/v1/spoolman/inventory/spools/42",
+            json={"rgba": "00FF00FF"},
+        )
+        assert response.status_code == 200
+        kwargs = client.update_spool_full.call_args.kwargs
+        assert kwargs["filament_id"] == 8
+        assert kwargs["initial_weight"] == 1000.0
+        assert kwargs["remaining_weight"] == pytest.approx(800.0)
+        assert kwargs["price"] is None
+
+
+class TestSpoolWithoutAnySize:
+    """Spoolman refuses a remaining_weight (HTTP 400, the whole PATCH) while a
+    spool has neither an initial_weight nor a filament weight."""
+
+    BARE = {**QUARTER_SPOOL, "initial_weight": None, "remaining_weight": None, "used_weight": 0.0, "price": None}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_edit_without_label_weight_sends_no_remaining(
+        self, async_client: AsyncClient, spoolman_settings, client
+    ):
+        client.get_spool = AsyncMock(
+            return_value={**self.BARE, "filament": {**QUARTER_SPOOL["filament"], "weight": None}}
+        )
+        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json={"note": "hi"})
+        assert response.status_code == 200
+        kwargs = client.update_spool_full.call_args.kwargs
+        assert kwargs["remaining_weight"] is None
+        assert kwargs["initial_weight"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_form_label_weight_becomes_the_size(self, async_client: AsyncClient, spoolman_settings, client):
+        """The form shows such a spool as 1000 g and sends that back."""
+        client.get_spool = AsyncMock(
+            return_value={**self.BARE, "filament": {**QUARTER_SPOOL["filament"], "weight": None}}
+        )
+        response = await async_client.patch(
+            "/api/v1/spoolman/inventory/spools/42", json={"label_weight": 1000, "weight_used": 100.0}
+        )
+        assert response.status_code == 200
+        kwargs = client.update_spool_full.call_args.kwargs
+        assert kwargs["initial_weight"] == 1000.0
+        assert kwargs["remaining_weight"] == pytest.approx(900.0)
+
+
+class TestAmsSyncUsesTheSpoolsWeight:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ams_percentage_is_of_the_spool(
+        self, async_client: AsyncClient, spoolman_settings, client, db_session
+    ):
+        from backend.app.models.printer import Printer
+        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+
+        printer = Printer(name="P", serial_number="SN3194", ip_address="192.168.1.9", access_code="12345678")
+        db_session.add(printer)
+        await db_session.commit()
+        await db_session.refresh(printer)
+        db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=0, spoolman_spool_id=42))
+        await db_session.commit()
+
+        state = MagicMock()
+        state.raw_data = {"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]}
+        with (
+            patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=client)),
+            patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm,
+        ):
+            pm.get_status = MagicMock(return_value=state)
+            response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
+
+        assert response.status_code == 200
+        assert response.json()["synced"] == 1
+        # 80 % of 250 g, not of the filament's 1000 g
+        client.update_spool_full.assert_called_once_with(42, remaining_weight=200.0)

+ 12 - 0
backend/tests/unit/services/test_spoolman_print_cost_2591.py

@@ -91,6 +91,18 @@ class TestSpoolCostPerGram:
         silently pass through into the archive."""
         assert _spool_cost_per_gram(spool) is None
 
+    def test_spool_price_is_divided_by_the_spools_own_weight(self):
+        """#3194: a spool's price is what that spool cost. 6.25 for a 250 g
+        spool of a 1000 g filament is 2.5 cents a gram, not 0.625."""
+        spool = {**_spool(1, price=6.25, weight=1000), "initial_weight": 250.0}
+        assert _spool_cost_per_gram(spool) == pytest.approx(0.025)
+
+    def test_catalogue_price_stays_on_the_filament_weight(self):
+        """filament.price is the catalogue price of a filament.weight spool,
+        whatever size this particular spool is."""
+        spool = {**_spool(1, filament_price=25.0, weight=1000), "initial_weight": 250.0}
+        assert _spool_cost_per_gram(spool) == pytest.approx(0.025)
+
 
 class TestPrintCostAccumulator:
     def test_sums_each_slot_at_its_own_rate(self):

+ 85 - 0
backend/tests/unit/test_spoolman_inventory_helpers.py

@@ -8,6 +8,9 @@ from backend.app.api.routes._spoolman_helpers import (
     _map_spoolman_spool,
     _safe_float,
     _safe_int,
+    cost_per_kg_to_spoolman_price,
+    spoolman_net_weight,
+    spoolman_price_to_cost_per_kg,
     spoolman_tare,
 )
 
@@ -683,3 +686,85 @@ class TestMapSpoolmanSpoolPrice:
 
         spool = {**MINIMAL_SPOOL, "price": math.inf}
         assert _map_spoolman_spool(spool)["cost_per_kg"] is None
+
+
+class TestSpoolmanNetWeight:
+    """The spool's initial_weight wins over the filament's weight, as in Spoolman (#3194)."""
+
+    @staticmethod
+    def _spool(initial=None, filament_weight=1000.0):
+        return {"id": 1, "initial_weight": initial, "filament": {"weight": filament_weight}}
+
+    def test_initial_weight_beats_filament_weight(self):
+        assert spoolman_net_weight(self._spool(250.0, 1000.0)) == 250.0
+
+    def test_falls_back_to_filament_weight(self):
+        assert spoolman_net_weight(self._spool(None, 1000.0)) == 1000.0
+
+    def test_zero_initial_weight_counts_as_unset(self):
+        """Spoolman's own /measure treats an initial_weight of 0 as missing."""
+        assert spoolman_net_weight(self._spool(0, 750.0)) == 750.0
+
+    @pytest.mark.parametrize("bad", [float("nan"), float("inf"), "abc", True])
+    def test_invalid_initial_weight_counts_as_unset(self, bad):
+        assert spoolman_net_weight(self._spool(bad, 750.0)) == 750.0
+
+    def test_filament_weight_returned_as_stored(self):
+        """A filament weight of 0 is left to the caller, which keeps its own handling."""
+        assert spoolman_net_weight(self._spool(None, 0)) == 0.0
+
+    @pytest.mark.parametrize("filament", [None, {}, "not a dict", {"weight": True}])
+    def test_nothing_usable_returns_none(self, filament):
+        assert spoolman_net_weight({"id": 1, "filament": filament}) is None
+
+
+class TestSpoolmanPriceConversion:
+    """A Spoolman spool price is the price of the whole spool (#3194)."""
+
+    def test_price_of_a_quarter_kilo_spool_as_a_rate(self):
+        assert spoolman_price_to_cost_per_kg(6.25, 250) == pytest.approx(25.0)
+
+    def test_rate_to_price_of_a_quarter_kilo_spool(self):
+        assert cost_per_kg_to_spoolman_price(25.0, 250) == pytest.approx(6.25)
+
+    def test_kilo_spool_price_equals_rate(self):
+        assert spoolman_price_to_cost_per_kg(24.99, 1000) == 24.99
+        assert cost_per_kg_to_spoolman_price(24.99, 1000) == 24.99
+
+    @pytest.mark.parametrize("weight", [250, 333, 750, 1000, 2500])
+    def test_round_trip_does_not_drift(self, weight):
+        price = cost_per_kg_to_spoolman_price(19.99, weight)
+        assert spoolman_price_to_cost_per_kg(price, weight) == pytest.approx(19.99, abs=1e-3)
+
+    @pytest.mark.parametrize(("price", "weight"), [(None, 1000), (math.nan, 1000), ("abc", 1000), (10.0, 0)])
+    def test_unusable_input_returns_none(self, price, weight):
+        assert spoolman_price_to_cost_per_kg(price, weight) is None
+
+
+class TestMapperUsesSpoolInitialWeight:
+    """#3194: a 250 g spool of a 1000 g filament maps as a 250 g spool."""
+
+    SPOOL = {
+        "id": 9,
+        "initial_weight": 250.0,
+        "remaining_weight": 200.0,
+        "used_weight": 50.0,
+        "price": 6.25,
+        "filament": {"id": 1, "material": "PLA", "name": "PLA Basic", "weight": 1000.0},
+        "extra": {},
+    }
+
+    def test_label_weight_is_the_spools(self):
+        mapped = _map_spoolman_spool(self.SPOOL)
+        assert mapped["label_weight"] == 250
+        # remaining = label - weight_used must match Spoolman's 200 g
+        assert mapped["label_weight"] - mapped["weight_used"] == pytest.approx(200.0)
+
+    def test_cost_per_kg_is_the_spool_price_over_its_weight(self):
+        assert _map_spoolman_spool(self.SPOOL)["cost_per_kg"] == pytest.approx(25.0)
+
+    def test_without_initial_weight_the_filament_weight_is_used(self):
+        spool = {**self.SPOOL, "initial_weight": None, "remaining_weight": 800.0, "used_weight": 200.0}
+        mapped = _map_spoolman_spool(spool)
+        assert mapped["label_weight"] == 1000
+        assert mapped["cost_per_kg"] == pytest.approx(6.25)

+ 47 - 0
backend/tests/unit/test_spoolman_no3mf_remain_fallback.py

@@ -490,6 +490,53 @@ class TestPartialUsageRemainDelta:
 
         client.use_spool.assert_awaited_once_with(7, 200.0)
 
+    @pytest.mark.asyncio
+    async def test_remain_delta_uses_the_spools_own_weight(self):
+        """#3194: the AMS percentage is of this spool, so a 20% drop on a
+        250 g spool of a 1000 g filament is 50 g, not 200 g."""
+        from backend.app.services.spoolman_tracking import _report_partial_usage
+
+        tracking = SimpleNamespace(
+            archive_id=99,
+            filament_usage=None,
+            layer_usage=None,
+            filament_properties=None,
+            ams_trays={"0": {"tray_uuid": "AAAA"}},
+            slot_to_tray=None,
+            tray_remain_start={"0-0": {"remain": 90, "tray_uuid": "AAAA"}},
+        )
+
+        client = AsyncMock()
+        client.get_spool = AsyncMock(return_value={"id": 7, "initial_weight": 250.0, "filament": {"weight": 1000.0}})
+        client.use_spool = AsyncMock()
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "remain": 70}]}]},
+            layer_num=42,
+            total_layers=100,
+        )
+
+        with (
+            patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
+            patch(
+                "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
+                AsyncMock(return_value=client),
+            ),
+            patch(
+                "backend.app.services.spoolman_tracking._get_printer_serial",
+                AsyncMock(return_value="serial"),
+            ),
+            patch(
+                "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
+                AsyncMock(return_value=7),
+            ),
+            patch("backend.app.services.printer_manager.printer_manager", printer_manager),
+        ):
+            await _report_partial_usage(printer_id=1, tracking=tracking)
+
+        client.use_spool.assert_awaited_once_with(7, 50.0)
+
 
 class _AsyncCtx:
     """Tiny async-context shim returning a pre-built db mock; mirrors