From 18cafc436124af8d81da1f8fd4ab66c32a36be13 Mon Sep 17 00:00:00 2001 From: tonym Date: Wed, 21 Jan 2026 22:38:14 -0600 Subject: [PATCH] feat: Add extra weight, price tracking, print history, usage analytics - Extra Weight Field (#14): Track DryPods, custom holders in spool weight calculations. New extra_weight field on spool with DB migration. - Price/Cost Tracking: Compute remaining_value based on remaining weight and price. Added column to spool list, inventory value on dashboard. - Print History: Show print job history on spool detail page with collapsible table showing filename, filament used, status, dates. - Usage Analytics: New dashboard component with time-series chart showing daily consumption, period selector (7/30/90 days), and material breakdown. New API endpoints for analytics data. Co-Authored-By: Claude Opus 4.5 --- client/public/locales/en/common.json | 31 ++++- .../src/pages/home/components/QuickStats.tsx | 28 +++- .../pages/home/components/UsageAnalytics.tsx | 123 +++++++++++++++++ client/src/pages/home/index.tsx | 4 + client/src/pages/spools/create.tsx | 23 +++- client/src/pages/spools/edit.tsx | 23 +++- client/src/pages/spools/list.tsx | 17 ++- client/src/pages/spools/model.tsx | 2 + client/src/pages/spools/show.tsx | 75 ++++++++++- ...200-f6g7h8i9j0k1_add_spool_extra_weight.py | 31 +++++ spoolman/api/v1/models.py | 21 +++ spoolman/api/v1/other.py | 124 +++++++++++++++++- spoolman/api/v1/spool.py | 7 + spoolman/database/models.py | 4 + spoolman/database/spool.py | 19 ++- 15 files changed, 514 insertions(+), 18 deletions(-) create mode 100644 client/src/pages/home/components/UsageAnalytics.tsx create mode 100644 migrations/versions/2025_01_21_0200-f6g7h8i9j0k1_add_spool_extra_weight.py diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 789f871..bb8f805 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -191,11 +191,13 @@ "weight_to_use": "Weight", "used_weight": "Used Weight", "remaining_weight": "Remaining Weight", + "remaining_value": "Remaining Value", "measured_weight": "Measured Weight", "used_length": "Used Length", "remaining_length": "Remaining Length", "initial_weight": "Initial Weight", "spool_weight": "Empty Weight", + "extra_weight": "Extra Weight", "location": "Location", "lot_nr": "Lot Nr", "first_used": "First Used", @@ -205,6 +207,8 @@ "archived": "Archived", "adjustment_history": "Adjustment History", "show_history": "Show History", + "print_history": "Print History", + "show_print_history": "Show Print History", "adjustment_timestamp": "Date/Time", "adjustment_type": "Type", "adjustment_type_weight": "Weight", @@ -220,6 +224,7 @@ "measured_weight": "How much the filament and spool weigh.", "initial_weight": "The initial weight of filament on the spool (net weight). Will use the weight from the filament object if not set.", "spool_weight": "The weight of the spool when it is empty. Leave empty to use the value from the filament or the manufacturer.", + "extra_weight": "Additional weight to account for when measuring, such as DryPods, custom spool holders, or other accessories.", "location": "Where the spool is located if you have multiple locations where you store your spools.", "lot_nr": "Manufacturer's lot number. Can be used to ensure a print has consistent color if multiple spools are used.", "external_filament": "You have selected a filament from the external database. A filament object (and possibly a manufacturer object) will be created automatically when you create this spool. This can create duplicate filament objects if you have already created a filament object for this filament." @@ -377,6 +382,20 @@ "name_required": "Name is required" } }, + "print_job": { + "fields": { + "created": "Created", + "filename": "Filename", + "filament_used": "Filament Used", + "status": "Status", + "finished": "Finished" + }, + "status": { + "pending": "Pending", + "completed": "Completed", + "cancelled": "Cancelled" + } + }, "home": { "home": "Home", "welcome": "Welcome to your Spoolman instance!", @@ -396,7 +415,17 @@ "total_spools": "Total Spools", "total_filaments": "Filament Types", "total_vendors": "Vendors", - "total_weight": "Total Remaining" + "total_weight": "Total Remaining", + "total_value": "Inventory Value" + }, + "analytics": { + "title": "Usage Analytics", + "7_days": "7 Days", + "30_days": "30 Days", + "90_days": "90 Days", + "total_used": "Total used: {{weight}} kg", + "used": "Used", + "by_material": "Usage by Material" } }, "help": { diff --git a/client/src/pages/home/components/QuickStats.tsx b/client/src/pages/home/components/QuickStats.tsx index 5220f46..6344b35 100644 --- a/client/src/pages/home/components/QuickStats.tsx +++ b/client/src/pages/home/components/QuickStats.tsx @@ -1,4 +1,4 @@ -import { HighlightOutlined, InboxOutlined, DatabaseOutlined, UserOutlined } from "@ant-design/icons"; +import { DollarOutlined, HighlightOutlined, InboxOutlined, DatabaseOutlined, UserOutlined } from "@ant-design/icons"; import { useList, useTranslate } from "@refinedev/core"; import { Card, Col, Row, Statistic, theme } from "antd"; import { useMemo } from "react"; @@ -6,12 +6,14 @@ import { Link } from "react-router"; import { IFilament } from "../../filaments/model"; import { ISpool } from "../../spools/model"; import { IVendor } from "../../vendors/model"; +import { useCurrencyFormatter } from "../../../utils/settings"; const { useToken } = theme; export function QuickStats() { const t = useTranslate(); const { token } = useToken(); + const currencyFormatter = useCurrencyFormatter(); const { result: filamentsResult, query: filamentsQuery } = useList({ resource: "filament", @@ -40,6 +42,12 @@ export function QuickStats() { return data.reduce((sum, spool) => sum + (spool.remaining_weight ?? 0), 0); }, [spoolsResult?.data]); + // Calculate total inventory value + const totalValue = useMemo(() => { + const data = spoolsResult?.data || []; + return data.reduce((sum, spool) => sum + (spool.remaining_value ?? 0), 0); + }, [spoolsResult?.data]); + const formatWeight = (grams: number) => { if (grams >= 1000) { return `${(grams / 1000).toFixed(1)} kg`; @@ -49,7 +57,7 @@ export function QuickStats() { return ( - + - + - + - + + + + } + /> + + ); } diff --git a/client/src/pages/home/components/UsageAnalytics.tsx b/client/src/pages/home/components/UsageAnalytics.tsx new file mode 100644 index 0000000..5ad9c84 --- /dev/null +++ b/client/src/pages/home/components/UsageAnalytics.tsx @@ -0,0 +1,123 @@ +import { useTranslate } from "@refinedev/core"; +import { useQuery } from "@tanstack/react-query"; +import { Card, Segmented, Spin, Typography } from "antd"; +import { useState } from "react"; +import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import { getAPIURL } from "../../../utils/url"; + +interface UsageDataPoint { + date: string; + weight_used: number; +} + +interface MaterialUsage { + material: string; + weight_used: number; +} + +export function UsageAnalytics() { + const t = useTranslate(); + const [days, setDays] = useState(30); + + const { data: usageData, isLoading: usageLoading } = useQuery({ + queryKey: ["analytics", "usage", days], + queryFn: async () => { + const res = await fetch(`${getAPIURL()}/analytics/usage?days=${days}`); + return res.json(); + }, + }); + + const { data: materialData, isLoading: materialLoading } = useQuery({ + queryKey: ["analytics", "by-material", days], + queryFn: async () => { + const res = await fetch(`${getAPIURL()}/analytics/by-material?days=${days}`); + return res.json(); + }, + }); + + // Format date for display + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + }; + + // Calculate total usage + const totalUsage = usageData?.reduce((sum, d) => sum + d.weight_used, 0) ?? 0; + + return ( + setDays(v as number)} + options={[ + { label: t("home.analytics.7_days"), value: 7 }, + { label: t("home.analytics.30_days"), value: 30 }, + { label: t("home.analytics.90_days"), value: 90 }, + ]} + /> + } + style={{ marginTop: 16 }} + > + {usageLoading ? ( +
+ +
+ ) : ( + <> + + {t("home.analytics.total_used", { weight: (totalUsage / 1000).toFixed(2) })} + + + + + + + + + + + `${v}g`} width={50} /> + [`${(value as number).toFixed(1)} g`, t("home.analytics.used")]} + labelFormatter={(label) => new Date(label).toLocaleDateString()} + /> + + + + + {materialData && materialData.length > 0 && ( + <> + + {t("home.analytics.by_material")} + +
+ {materialData.map((m) => ( + + {m.material} +
+ + {m.weight_used >= 1000 ? `${(m.weight_used / 1000).toFixed(2)} kg` : `${m.weight_used.toFixed(0)} g`} + +
+ ))} +
+ + )} + + )} +
+ ); +} diff --git a/client/src/pages/home/index.tsx b/client/src/pages/home/index.tsx index a2cb0d9..67dd5b4 100644 --- a/client/src/pages/home/index.tsx +++ b/client/src/pages/home/index.tsx @@ -14,6 +14,7 @@ import { ISpool } from "../spools/model"; import { AlertCards } from "./components/AlertCards"; import { MaterialDistributionChart } from "./components/MaterialDistributionChart"; import { QuickStats } from "./components/QuickStats"; +import { UsageAnalytics } from "./components/UsageAnalytics"; dayjs.extend(utc); @@ -132,6 +133,9 @@ export const Home: React.FC = () => { {/* Material distribution chart */} + {/* Usage analytics */} + + {/* Spool list */} { + return extraWeightValue ?? 0; + }; + const getGrossWeight = (): number => { const net_weight = getFilamentWeight(); const spool_weight = getSpoolWeight(); - return net_weight + spool_weight; + const extra_weight = getExtraWeight(); + return net_weight + spool_weight + extra_weight; }; const getMeasuredWeight = (): number => { @@ -402,6 +408,21 @@ export const SpoolCreate: React.FC + + + + diff --git a/client/src/pages/spools/edit.tsx b/client/src/pages/spools/edit.tsx index b8526df..1d3353d 100644 --- a/client/src/pages/spools/edit.tsx +++ b/client/src/pages/spools/edit.tsx @@ -67,6 +67,7 @@ export const SpoolEdit: React.FC = () => { const initialWeightValue = Form.useWatch("initial_weight", form); const spoolWeightValue = Form.useWatch("spool_weight", form); + const extraWeightValue = Form.useWatch("extra_weight", form); // Calculate prev/next IDs for navigation const { prevId, nextId } = useMemo(() => { @@ -189,10 +190,15 @@ export const SpoolEdit: React.FC = () => { return initialWeightValue ?? selectedFilament?.weight ?? 0; }; + const getExtraWeight = (): number => { + return extraWeightValue ?? 0; + }; + const getGrossWeight = (): number => { const net_weight = getFilamentWeight(); const spool_weight = getSpoolWeight(); - return net_weight + spool_weight; + const extra_weight = getExtraWeight(); + return net_weight + spool_weight + extra_weight; }; const getMeasuredWeight = (): number => { @@ -394,6 +400,21 @@ export const SpoolEdit: React.FC = () => { + + + + diff --git a/client/src/pages/spools/list.tsx b/client/src/pages/spools/list.tsx index e819c3d..fe23745 100644 --- a/client/src/pages/spools/list.tsx +++ b/client/src/pages/spools/list.tsx @@ -89,6 +89,7 @@ const allColumns: (keyof ISpoolCollapsed & string)[] = [ "filament.color_hex", "filament.material", "price", + "remaining_value", "used_weight", "remaining_weight", "used_length", @@ -101,7 +102,8 @@ const allColumns: (keyof ISpoolCollapsed & string)[] = [ "comment", ]; const defaultColumns = allColumns.filter( - (column_id) => ["registered", "used_length", "remaining_length", "lot_nr", "filament.color_hex"].indexOf(column_id) === -1 + (column_id) => + ["registered", "used_length", "remaining_length", "lot_nr", "filament.color_hex", "remaining_value"].indexOf(column_id) === -1 ); export const SpoolList: React.FC = () => { @@ -462,6 +464,19 @@ export const SpoolList: React.FC = () => { return currencyFormatter.format(obj.price); }, }), + SortedColumn({ + ...commonProps, + id: "remaining_value", + i18ncat: "spool", + align: "right", + width: 100, + render: (_, obj: ISpoolCollapsed) => { + if (obj.remaining_value === undefined) { + return ""; + } + return currencyFormatter.format(obj.remaining_value); + }, + }), NumberColumn({ ...commonProps, id: "used_weight", diff --git a/client/src/pages/spools/model.tsx b/client/src/pages/spools/model.tsx index dbaba11..a307085 100644 --- a/client/src/pages/spools/model.tsx +++ b/client/src/pages/spools/model.tsx @@ -15,9 +15,11 @@ export interface ISpool { price?: number; initial_weight?: number; spool_weight?: number; + extra_weight?: number; remaining_weight?: number; used_weight: number; remaining_length?: number; + remaining_value?: number; used_length: number; location?: string; lot_nr?: string; diff --git a/client/src/pages/spools/show.tsx b/client/src/pages/spools/show.tsx index 9e0093d..4424483 100644 --- a/client/src/pages/spools/show.tsx +++ b/client/src/pages/spools/show.tsx @@ -15,7 +15,7 @@ import { useCurrencyFormatter } from "../../utils/settings"; import { getAPIURL, getBasePath } from "../../utils/url"; import { IFilament } from "../filaments/model"; import { setSpoolArchived, useSpoolAdjustModal } from "./functions"; -import { ISpool, ISpoolAdjustment } from "./model"; +import { IPrintJob, ISpool, ISpoolAdjustment } from "./model"; dayjs.extend(utc); @@ -45,6 +45,16 @@ export const SpoolShow: React.FC = () => { enabled: !!record?.id, }); + // Fetch print job history + const { data: printJobsData, isLoading: printJobsLoading } = useQuery({ + queryKey: ["spool-print-jobs", record?.id], + queryFn: async () => { + const res = await fetch(`${getAPIURL()}/print-job?spool_id=${record?.id}`); + return (await res.json()) as IPrintJob[]; + }, + enabled: !!record?.id, + }); + const spoolPrice = (item?: ISpool) => { const price = item?.price ?? item?.filament.price; if (price === undefined) { @@ -288,6 +298,69 @@ export const SpoolShow: React.FC = () => { }, ]} /> + + {t("spool.fields.print_history")} + + + dataSource={printJobsData} + loading={printJobsLoading} + rowKey="id" + size="small" + pagination={{ pageSize: 10 }} + columns={[ + { + title: t("print_job.fields.created"), + dataIndex: "created", + key: "created", + render: (value: string) => ( + + {dayjs.utc(value).local().format("YYYY-MM-DD HH:mm:ss")} + + ), + }, + { + title: t("print_job.fields.filename"), + dataIndex: "filename", + key: "filename", + ellipsis: true, + }, + { + title: t("print_job.fields.filament_used"), + dataIndex: "filament_used_g", + key: "filament_used_g", + render: (value: number) => `${value.toFixed(1)} g`, + }, + { + title: t("print_job.fields.status"), + dataIndex: "status", + key: "status", + render: (value: string) => t(`print_job.status.${value}`), + }, + { + title: t("print_job.fields.finished"), + dataIndex: "finished", + key: "finished", + render: (value: string | null) => + value ? ( + + {dayjs.utc(value).local().format("YYYY-MM-DD HH:mm:ss")} + + ) : ( + "-" + ), + }, + ]} + /> + ), + }, + ]} + /> ); }; diff --git a/migrations/versions/2025_01_21_0200-f6g7h8i9j0k1_add_spool_extra_weight.py b/migrations/versions/2025_01_21_0200-f6g7h8i9j0k1_add_spool_extra_weight.py new file mode 100644 index 0000000..21682b2 --- /dev/null +++ b/migrations/versions/2025_01_21_0200-f6g7h8i9j0k1_add_spool_extra_weight.py @@ -0,0 +1,31 @@ +"""Add extra_weight field to spool for DryPods, custom holders, etc. + +Revision ID: f6g7h8i9j0k1 +Revises: e5f6g7h8i9j0 +Create Date: 2025-01-21 22:00:00.000000 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f6g7h8i9j0k1" +down_revision = "e5f6g7h8i9j0" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "spool", + sa.Column( + "extra_weight", + sa.Float(), + nullable=True, + comment="Extra weight to account for (DryPods, custom holders, etc.).", + ), + ) + + +def downgrade() -> None: + op.drop_column("spool", "extra_weight") diff --git a/spoolman/api/v1/models.py b/spoolman/api/v1/models.py index f651f34..916de40 100644 --- a/spoolman/api/v1/models.py +++ b/spoolman/api/v1/models.py @@ -311,6 +311,12 @@ class Spool(BaseModel): description=("Weight of an empty spool (tare weight)."), examples=[246], ) + extra_weight: Optional[float] = Field( + default=None, + ge=0, + description=("Extra weight to account for, such as DryPods, custom spool holders, etc."), + examples=[50], + ) used_weight: float = Field( ge=0, description="Consumed weight of filament from the spool in grams.", @@ -325,6 +331,12 @@ class Spool(BaseModel): ), examples=[5612.4], ) + remaining_value: Optional[float] = Field( + default=None, + ge=0, + description="Estimated remaining value of filament on the spool based on remaining weight and price.", + examples=[12.50], + ) used_length: float = Field( ge=0, description="Consumed length of filament from the spool in millimeters.", @@ -389,6 +401,13 @@ class Spool(BaseModel): diameter=filament.diameter, ) + # Calculate remaining value based on remaining weight and price + remaining_value: Optional[float] = None + price = item.price if item.price is not None else filament.price + initial_weight = item.initial_weight if item.initial_weight is not None else filament.weight + if remaining_weight is not None and price is not None and initial_weight is not None and initial_weight > 0: + remaining_value = round((remaining_weight / initial_weight) * price, 2) + return Spool( id=item.id, registered=item.registered, @@ -398,10 +417,12 @@ class Spool(BaseModel): price=item.price, initial_weight=item.initial_weight, spool_weight=item.spool_weight, + extra_weight=item.extra_weight, used_weight=item.used_weight, used_length=used_length, remaining_weight=remaining_weight, remaining_length=remaining_length, + remaining_value=remaining_value, location=item.location, lot_nr=item.lot_nr, comment=item.comment, diff --git a/spoolman/api/v1/other.py b/spoolman/api/v1/other.py index 750da77..937267d 100644 --- a/spoolman/api/v1/other.py +++ b/spoolman/api/v1/other.py @@ -1,13 +1,16 @@ """Filament related endpoints.""" import logging -from typing import Annotated +from datetime import datetime, timedelta +from typing import Annotated, Optional -from fastapi import APIRouter, Depends +import sqlalchemy +from fastapi import APIRouter, Depends, Query from pydantic import BaseModel, Field, RootModel +from sqlalchemy import func from sqlalchemy.ext.asyncio import AsyncSession -from spoolman.database import filament, spool +from spoolman.database import filament, models, spool from spoolman.database.database import get_db_session logger = logging.getLogger(__name__) @@ -173,3 +176,118 @@ async def rename_location( logger.info("Renaming location %s to %s", location, body.name) await spool.rename_location(db=db, current_name=location, new_name=body.name) return body.name + + +class UsageDataPoint(BaseModel): + """A single data point for usage analytics.""" + + date: str = Field(description="Date in YYYY-MM-DD format.") + weight_used: float = Field(description="Total weight used in grams (positive value).") + + +@router.get( + "/analytics/usage", + name="Get usage analytics", + description="Get filament usage data aggregated by day for the specified period.", + response_model=list[UsageDataPoint], +) +async def get_usage_analytics( + *, + db: Annotated[AsyncSession, Depends(get_db_session)], + days: Annotated[ + int, + Query(description="Number of days to look back.", ge=1, le=365), + ] = 30, +) -> list[UsageDataPoint]: + """Get usage analytics aggregated by day.""" + # Calculate the start date + start_date = datetime.utcnow() - timedelta(days=days) + + # Query adjustments grouped by date + # Only include negative values (filament used, not added) + stmt = ( + sqlalchemy.select( + func.date(models.SpoolAdjustment.timestamp).label("date"), + func.sum( + sqlalchemy.case( + (models.SpoolAdjustment.value < 0, -models.SpoolAdjustment.value), + else_=0, + ) + ).label("weight_used"), + ) + .where(models.SpoolAdjustment.timestamp >= start_date) + .where(models.SpoolAdjustment.adjustment_type == "weight") + .group_by(func.date(models.SpoolAdjustment.timestamp)) + .order_by(func.date(models.SpoolAdjustment.timestamp)) + ) + + result = await db.execute(stmt) + rows = result.all() + + # Fill in missing dates with 0 + data_by_date = {str(row.date): row.weight_used for row in rows} + all_dates = [] + current_date = start_date.date() + end_date = datetime.utcnow().date() + + while current_date <= end_date: + date_str = current_date.strftime("%Y-%m-%d") + all_dates.append( + UsageDataPoint( + date=date_str, + weight_used=data_by_date.get(date_str, 0), + ) + ) + current_date += timedelta(days=1) + + return all_dates + + +class MaterialUsage(BaseModel): + """Usage breakdown by material.""" + + material: str = Field(description="Material name.") + weight_used: float = Field(description="Total weight used in grams.") + + +@router.get( + "/analytics/by-material", + name="Get usage by material", + description="Get filament usage breakdown by material type.", + response_model=list[MaterialUsage], +) +async def get_usage_by_material( + *, + db: Annotated[AsyncSession, Depends(get_db_session)], + days: Annotated[ + Optional[int], + Query(description="Number of days to look back. If not set, returns all-time usage.", ge=1, le=365), + ] = None, +) -> list[MaterialUsage]: + """Get usage breakdown by material.""" + # Join adjustments with spools and filaments to get material + stmt = ( + sqlalchemy.select( + func.coalesce(models.Filament.material, "Unknown").label("material"), + func.sum( + sqlalchemy.case( + (models.SpoolAdjustment.value < 0, -models.SpoolAdjustment.value), + else_=0, + ) + ).label("weight_used"), + ) + .join(models.Spool, models.SpoolAdjustment.spool_id == models.Spool.id) + .join(models.Filament, models.Spool.filament_id == models.Filament.id) + .where(models.SpoolAdjustment.adjustment_type == "weight") + ) + + if days is not None: + start_date = datetime.utcnow() - timedelta(days=days) + stmt = stmt.where(models.SpoolAdjustment.timestamp >= start_date) + + stmt = stmt.group_by(models.Filament.material).order_by(func.sum(-models.SpoolAdjustment.value).desc()) + + result = await db.execute(stmt) + rows = result.all() + + return [MaterialUsage(material=row.material or "Unknown", weight_used=row.weight_used) for row in rows] diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index 48d0170..d75c10e 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -51,6 +51,12 @@ class SpoolParameters(BaseModel): description="The weight of an empty spool, in grams. (tare weight)", examples=[200], ) + extra_weight: Optional[float] = Field( + None, + ge=0, + description="Extra weight to account for, such as DryPods, custom spool holders, etc., in grams.", + examples=[50], + ) remaining_weight: Optional[float] = Field( None, ge=0, @@ -449,6 +455,7 @@ async def create( # noqa: ANN201 price=body.price, initial_weight=body.initial_weight, spool_weight=body.spool_weight, + extra_weight=body.extra_weight, remaining_weight=body.remaining_weight, used_weight=body.used_weight, first_used=body.first_used, diff --git a/spoolman/database/models.py b/spoolman/database/models.py index ba5530a..7db4fbb 100644 --- a/spoolman/database/models.py +++ b/spoolman/database/models.py @@ -100,6 +100,10 @@ class Spool(Base): initial_weight: Mapped[Optional[float]] = mapped_column() spool_weight: Mapped[Optional[float]] = mapped_column() used_weight: Mapped[float] = mapped_column() + extra_weight: Mapped[Optional[float]] = mapped_column( + default=None, + comment="Extra weight to account for (DryPods, custom holders, etc.).", + ) location: Mapped[Optional[str]] = mapped_column(String(64), comment="Legacy flat location string.") location_id: Mapped[Optional[int]] = mapped_column( ForeignKey("location.id", ondelete="SET NULL"), diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index 9d949fe..ef7e5ab 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -41,6 +41,7 @@ async def create( remaining_weight: Optional[float] = None, initial_weight: Optional[float] = None, spool_weight: Optional[float] = None, + extra_weight: Optional[float] = None, used_weight: Optional[float] = None, first_used: Optional[datetime] = None, last_used: Optional[datetime] = None, @@ -84,6 +85,7 @@ async def create( registered=datetime.utcnow().replace(microsecond=0), initial_weight=initial_weight, spool_weight=spool_weight, + extra_weight=extra_weight, used_weight=used_weight, price=price, first_used=first_used, @@ -440,7 +442,12 @@ async def measure( """ spool_result = await db.execute( - sqlalchemy.select(models.Spool.initial_weight, models.Spool.used_weight, models.Spool.spool_weight).where( + sqlalchemy.select( + models.Spool.initial_weight, + models.Spool.used_weight, + models.Spool.spool_weight, + models.Spool.extra_weight, + ).where( models.Spool.id == spool_id, ), ) @@ -452,6 +459,7 @@ async def measure( initial_weight = spool_info[0] spool_weight = spool_info[2] + extra_weight = spool_info[3] or 0 # Default to 0 if not set if initial_weight is None or initial_weight == 0 or spool_weight is None or spool_weight == 0: # Get filament weight and spool_weight result = await db.execute( @@ -473,7 +481,7 @@ async def measure( if initial_weight is None or initial_weight == 0: raise SpoolMeasureError("Initial weight is not set.") - initial_gross_weight = initial_weight + spool_weight + initial_gross_weight = initial_weight + spool_weight + extra_weight # if the measurement is greater than the initial weight, set the initial weight to the measurement if weight > initial_gross_weight: @@ -485,9 +493,10 @@ async def measure( # Calculate the weight used since last measure weight_to_use = current_use - weight - # If the measured weight is less than the empty weight, use the rest of the spool - if (initial_gross_weight - weight_to_use) < spool_weight: - weight_to_use = current_use - spool_weight + # If the measured weight is less than the empty weight (+ extra), use the rest of the spool + empty_gross_weight = spool_weight + extra_weight + if (initial_gross_weight - weight_to_use) < empty_gross_weight: + weight_to_use = current_use - empty_gross_weight return await use_weight(db, spool_id, weight_to_use, comment=comment)