mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 23:45:40 +02:00
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
|
||||
@@ -5027,7 +5027,6 @@ export default {
|
||||
spoolmanFilamentUnlinked: 'フィラメントカタログのリンクを解除しました',
|
||||
noSpoolmanFilaments: 'Spoolmanカタログにフィラメントが見つかりません',
|
||||
spoolmanFilamentColorSwatch: 'フィラメントの色',
|
||||
spoolWeightManagedBySpoolman: '空スプールの重量はSpoolmanでフィラメントタイプごとに管理されています',
|
||||
spoolmanCatalogLoadFailed: 'Spoolmanのフィラメントカタログを読み込めませんでした',
|
||||
},
|
||||
|
||||
|
||||
@@ -4792,7 +4792,6 @@ export default {
|
||||
spoolmanFilamentUnlinked: '필라멘트 카탈로그 연결 해제됨',
|
||||
noSpoolmanFilaments: 'Spoolman 카탈로그에서 필라멘트를 찾을 수 없음',
|
||||
spoolmanFilamentColorSwatch: '필라멘트 색상',
|
||||
spoolWeightManagedBySpoolman: '빈 스풀 무게는 Spoolman에서 필라멘트 유형별로 관리됩니다',
|
||||
spoolmanCatalogLoadFailed: 'Spoolman 필라멘트 카탈로그 불러오기 실패',
|
||||
subtitle: '스풀 관리',
|
||||
storageLocationNone: '위치 미설정',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
|
||||
@@ -4792,7 +4792,6 @@ export default {
|
||||
spoolmanFilamentUnlinked: "Связь с каталогом филаментов удалена",
|
||||
noSpoolmanFilaments: "В каталоге Spoolman филаменты не найдены",
|
||||
spoolmanFilamentColorSwatch: "Цвет филамента",
|
||||
spoolWeightManagedBySpoolman: "Масса пустой катушки задаётся в Spoolman для каждого типа филамента",
|
||||
spoolmanCatalogLoadFailed: "Не удалось загрузить каталог филаментов Spoolman",
|
||||
},
|
||||
timelapse: {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
|
||||
@@ -5069,7 +5069,6 @@ export default {
|
||||
spoolmanFilamentUnlinked: "Посилання на каталог філаментів видалено",
|
||||
noSpoolmanFilaments: "У каталозі Spoolman не знайдено філаментів",
|
||||
spoolmanFilamentColorSwatch: "Колір філаменту",
|
||||
spoolWeightManagedBySpoolman: "Вага порожньої котушки керується залежно від типу філаменту в Spoolman",
|
||||
spoolmanCatalogLoadFailed: "Не вдалося завантажити каталог філаментів Spoolman",
|
||||
},
|
||||
|
||||
|
||||
@@ -5015,7 +5015,6 @@ export default {
|
||||
spoolmanFilamentUnlinked: '已解除耗材目录关联',
|
||||
noSpoolmanFilaments: 'Spoolman 目录中未找到耗材',
|
||||
spoolmanFilamentColorSwatch: '耗材颜色',
|
||||
spoolWeightManagedBySpoolman: '空线轴重量在 Spoolman 中按耗材类型管理',
|
||||
spoolmanCatalogLoadFailed: '无法加载 Spoolman 耗材目录',
|
||||
},
|
||||
|
||||
|
||||
@@ -5015,7 +5015,6 @@ export default {
|
||||
spoolmanFilamentUnlinked: '已解除耗材目錄關聯',
|
||||
noSpoolmanFilaments: 'Spoolman 目錄中未找到耗材',
|
||||
spoolmanFilamentColorSwatch: '耗材顏色',
|
||||
spoolWeightManagedBySpoolman: '空線軸重量在 Spoolman 中按耗材類型管理',
|
||||
spoolmanCatalogLoadFailed: '無法載入 Spoolman 耗材目錄',
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user