feat: Add extra weight, price tracking, print history, usage analytics
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 22:38:14 -06:00
parent 0556be9e3b
commit 18cafc4361
15 changed files with 514 additions and 18 deletions

View File

@@ -191,11 +191,13 @@
"weight_to_use": "Weight", "weight_to_use": "Weight",
"used_weight": "Used Weight", "used_weight": "Used Weight",
"remaining_weight": "Remaining Weight", "remaining_weight": "Remaining Weight",
"remaining_value": "Remaining Value",
"measured_weight": "Measured Weight", "measured_weight": "Measured Weight",
"used_length": "Used Length", "used_length": "Used Length",
"remaining_length": "Remaining Length", "remaining_length": "Remaining Length",
"initial_weight": "Initial Weight", "initial_weight": "Initial Weight",
"spool_weight": "Empty Weight", "spool_weight": "Empty Weight",
"extra_weight": "Extra Weight",
"location": "Location", "location": "Location",
"lot_nr": "Lot Nr", "lot_nr": "Lot Nr",
"first_used": "First Used", "first_used": "First Used",
@@ -205,6 +207,8 @@
"archived": "Archived", "archived": "Archived",
"adjustment_history": "Adjustment History", "adjustment_history": "Adjustment History",
"show_history": "Show History", "show_history": "Show History",
"print_history": "Print History",
"show_print_history": "Show Print History",
"adjustment_timestamp": "Date/Time", "adjustment_timestamp": "Date/Time",
"adjustment_type": "Type", "adjustment_type": "Type",
"adjustment_type_weight": "Weight", "adjustment_type_weight": "Weight",
@@ -220,6 +224,7 @@
"measured_weight": "How much the filament and spool weigh.", "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.", "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.", "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.", "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.", "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." "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" "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": "Home", "home": "Home",
"welcome": "Welcome to your Spoolman instance!", "welcome": "Welcome to your Spoolman instance!",
@@ -396,7 +415,17 @@
"total_spools": "Total Spools", "total_spools": "Total Spools",
"total_filaments": "Filament Types", "total_filaments": "Filament Types",
"total_vendors": "Vendors", "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": { "help": {

View File

@@ -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 { useList, useTranslate } from "@refinedev/core";
import { Card, Col, Row, Statistic, theme } from "antd"; import { Card, Col, Row, Statistic, theme } from "antd";
import { useMemo } from "react"; import { useMemo } from "react";
@@ -6,12 +6,14 @@ import { Link } from "react-router";
import { IFilament } from "../../filaments/model"; import { IFilament } from "../../filaments/model";
import { ISpool } from "../../spools/model"; import { ISpool } from "../../spools/model";
import { IVendor } from "../../vendors/model"; import { IVendor } from "../../vendors/model";
import { useCurrencyFormatter } from "../../../utils/settings";
const { useToken } = theme; const { useToken } = theme;
export function QuickStats() { export function QuickStats() {
const t = useTranslate(); const t = useTranslate();
const { token } = useToken(); const { token } = useToken();
const currencyFormatter = useCurrencyFormatter();
const { result: filamentsResult, query: filamentsQuery } = useList<IFilament>({ const { result: filamentsResult, query: filamentsQuery } = useList<IFilament>({
resource: "filament", resource: "filament",
@@ -40,6 +42,12 @@ export function QuickStats() {
return data.reduce((sum, spool) => sum + (spool.remaining_weight ?? 0), 0); return data.reduce((sum, spool) => sum + (spool.remaining_weight ?? 0), 0);
}, [spoolsResult?.data]); }, [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) => { const formatWeight = (grams: number) => {
if (grams >= 1000) { if (grams >= 1000) {
return `${(grams / 1000).toFixed(1)} kg`; return `${(grams / 1000).toFixed(1)} kg`;
@@ -49,7 +57,7 @@ export function QuickStats() {
return ( return (
<Row gutter={[12, 12]} style={{ marginTop: 16 }}> <Row gutter={[12, 12]} style={{ marginTop: 16 }}>
<Col xs={12} sm={6}> <Col xs={12} md={8} lg={4}>
<Link to="/spool"> <Link to="/spool">
<Card hoverable size="small"> <Card hoverable size="small">
<Statistic <Statistic
@@ -61,7 +69,7 @@ export function QuickStats() {
</Card> </Card>
</Link> </Link>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} md={8} lg={4}>
<Link to="/filament"> <Link to="/filament">
<Card hoverable size="small"> <Card hoverable size="small">
<Statistic <Statistic
@@ -73,7 +81,7 @@ export function QuickStats() {
</Card> </Card>
</Link> </Link>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} md={8} lg={4}>
<Link to="/vendor"> <Link to="/vendor">
<Card hoverable size="small"> <Card hoverable size="small">
<Statistic <Statistic
@@ -85,7 +93,7 @@ export function QuickStats() {
</Card> </Card>
</Link> </Link>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} md={12} lg={6}>
<Card size="small"> <Card size="small">
<Statistic <Statistic
title={t("home.quick_stats.total_weight")} title={t("home.quick_stats.total_weight")}
@@ -95,6 +103,16 @@ export function QuickStats() {
/> />
</Card> </Card>
</Col> </Col>
<Col xs={12} md={12} lg={6}>
<Card size="small">
<Statistic
title={t("home.quick_stats.total_value")}
value={currencyFormatter.format(totalValue)}
loading={spoolsQuery.isLoading}
prefix={<DollarOutlined />}
/>
</Card>
</Col>
</Row> </Row>
); );
} }

View File

@@ -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<number>(30);
const { data: usageData, isLoading: usageLoading } = useQuery<UsageDataPoint[]>({
queryKey: ["analytics", "usage", days],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/analytics/usage?days=${days}`);
return res.json();
},
});
const { data: materialData, isLoading: materialLoading } = useQuery<MaterialUsage[]>({
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 (
<Card
title={t("home.analytics.title")}
extra={
<Segmented
value={days}
onChange={(v) => 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 ? (
<div style={{ textAlign: "center", padding: 40 }}>
<Spin />
</div>
) : (
<>
<Typography.Text type="secondary">
{t("home.analytics.total_used", { weight: (totalUsage / 1000).toFixed(2) })}
</Typography.Text>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={usageData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorUsage" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#1890ff" stopOpacity={0.8} />
<stop offset="95%" stopColor="#1890ff" stopOpacity={0.1} />
</linearGradient>
</defs>
<XAxis
dataKey="date"
tickFormatter={formatDate}
tick={{ fontSize: 11 }}
interval="preserveStartEnd"
/>
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `${v}g`} width={50} />
<Tooltip
formatter={(value) => [`${(value as number).toFixed(1)} g`, t("home.analytics.used")]}
labelFormatter={(label) => new Date(label).toLocaleDateString()}
/>
<Area
type="monotone"
dataKey="weight_used"
stroke="#1890ff"
fillOpacity={1}
fill="url(#colorUsage)"
/>
</AreaChart>
</ResponsiveContainer>
{materialData && materialData.length > 0 && (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}>
{t("home.analytics.by_material")}
</Typography.Title>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{materialData.map((m) => (
<Card size="small" key={m.material} style={{ minWidth: 120 }}>
<Typography.Text strong>{m.material}</Typography.Text>
<br />
<Typography.Text type="secondary">
{m.weight_used >= 1000 ? `${(m.weight_used / 1000).toFixed(2)} kg` : `${m.weight_used.toFixed(0)} g`}
</Typography.Text>
</Card>
))}
</div>
</>
)}
</>
)}
</Card>
);
}

View File

@@ -14,6 +14,7 @@ import { ISpool } from "../spools/model";
import { AlertCards } from "./components/AlertCards"; import { AlertCards } from "./components/AlertCards";
import { MaterialDistributionChart } from "./components/MaterialDistributionChart"; import { MaterialDistributionChart } from "./components/MaterialDistributionChart";
import { QuickStats } from "./components/QuickStats"; import { QuickStats } from "./components/QuickStats";
import { UsageAnalytics } from "./components/UsageAnalytics";
dayjs.extend(utc); dayjs.extend(utc);
@@ -132,6 +133,9 @@ export const Home: React.FC<IResourceComponentsProps> = () => {
{/* Material distribution chart */} {/* Material distribution chart */}
<MaterialDistributionChart /> <MaterialDistributionChart />
{/* Usage analytics */}
<UsageAnalytics />
{/* Spool list */} {/* Spool list */}
<Card <Card
title={t("home.all_spools")} title={t("home.all_spools")}

View File

@@ -51,6 +51,7 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
const initialWeightValue = Form.useWatch("initial_weight", form); const initialWeightValue = Form.useWatch("initial_weight", form);
const spoolWeightValue = Form.useWatch("spool_weight", form); const spoolWeightValue = Form.useWatch("spool_weight", form);
const extraWeightValue = Form.useWatch("extra_weight", form);
if (props.mode === "clone") { if (props.mode === "clone") {
// Clear out the values that we don't want to clone // Clear out the values that we don't want to clone
@@ -208,10 +209,15 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
return initialWeightValue ?? selectedFilament?.weight ?? 0; return initialWeightValue ?? selectedFilament?.weight ?? 0;
}; };
const getExtraWeight = (): number => {
return extraWeightValue ?? 0;
};
const getGrossWeight = (): number => { const getGrossWeight = (): number => {
const net_weight = getFilamentWeight(); const net_weight = getFilamentWeight();
const spool_weight = getSpoolWeight(); const spool_weight = getSpoolWeight();
return net_weight + spool_weight; const extra_weight = getExtraWeight();
return net_weight + spool_weight + extra_weight;
}; };
const getMeasuredWeight = (): number => { const getMeasuredWeight = (): number => {
@@ -402,6 +408,21 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
<InputNumber addonAfter="g" precision={1} /> <InputNumber addonAfter="g" precision={1} />
</Form.Item> </Form.Item>
<Form.Item
label={t("spool.fields.extra_weight")}
help={t("spool.fields_help.extra_weight")}
name={["extra_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item hidden={true} name={["used_weight"]} initialValue={0}> <Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
<InputNumber value={usedWeight} /> <InputNumber value={usedWeight} />
</Form.Item> </Form.Item>

View File

@@ -67,6 +67,7 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
const initialWeightValue = Form.useWatch("initial_weight", form); const initialWeightValue = Form.useWatch("initial_weight", form);
const spoolWeightValue = Form.useWatch("spool_weight", form); const spoolWeightValue = Form.useWatch("spool_weight", form);
const extraWeightValue = Form.useWatch("extra_weight", form);
// Calculate prev/next IDs for navigation // Calculate prev/next IDs for navigation
const { prevId, nextId } = useMemo(() => { const { prevId, nextId } = useMemo(() => {
@@ -189,10 +190,15 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
return initialWeightValue ?? selectedFilament?.weight ?? 0; return initialWeightValue ?? selectedFilament?.weight ?? 0;
}; };
const getExtraWeight = (): number => {
return extraWeightValue ?? 0;
};
const getGrossWeight = (): number => { const getGrossWeight = (): number => {
const net_weight = getFilamentWeight(); const net_weight = getFilamentWeight();
const spool_weight = getSpoolWeight(); const spool_weight = getSpoolWeight();
return net_weight + spool_weight; const extra_weight = getExtraWeight();
return net_weight + spool_weight + extra_weight;
}; };
const getMeasuredWeight = (): number => { const getMeasuredWeight = (): number => {
@@ -394,6 +400,21 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
<InputNumber addonAfter="g" precision={1} /> <InputNumber addonAfter="g" precision={1} />
</Form.Item> </Form.Item>
<Form.Item
label={t("spool.fields.extra_weight")}
help={t("spool.fields_help.extra_weight")}
name={["extra_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item hidden={true} name={["used_weight"]} initialValue={0}> <Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
<InputNumber value={usedWeight} /> <InputNumber value={usedWeight} />
</Form.Item> </Form.Item>

View File

@@ -89,6 +89,7 @@ const allColumns: (keyof ISpoolCollapsed & string)[] = [
"filament.color_hex", "filament.color_hex",
"filament.material", "filament.material",
"price", "price",
"remaining_value",
"used_weight", "used_weight",
"remaining_weight", "remaining_weight",
"used_length", "used_length",
@@ -101,7 +102,8 @@ const allColumns: (keyof ISpoolCollapsed & string)[] = [
"comment", "comment",
]; ];
const defaultColumns = allColumns.filter( 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<IResourceComponentsProps> = () => { export const SpoolList: React.FC<IResourceComponentsProps> = () => {
@@ -462,6 +464,19 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
return currencyFormatter.format(obj.price); 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({ NumberColumn({
...commonProps, ...commonProps,
id: "used_weight", id: "used_weight",

View File

@@ -15,9 +15,11 @@ export interface ISpool {
price?: number; price?: number;
initial_weight?: number; initial_weight?: number;
spool_weight?: number; spool_weight?: number;
extra_weight?: number;
remaining_weight?: number; remaining_weight?: number;
used_weight: number; used_weight: number;
remaining_length?: number; remaining_length?: number;
remaining_value?: number;
used_length: number; used_length: number;
location?: string; location?: string;
lot_nr?: string; lot_nr?: string;

View File

@@ -15,7 +15,7 @@ import { useCurrencyFormatter } from "../../utils/settings";
import { getAPIURL, getBasePath } from "../../utils/url"; import { getAPIURL, getBasePath } from "../../utils/url";
import { IFilament } from "../filaments/model"; import { IFilament } from "../filaments/model";
import { setSpoolArchived, useSpoolAdjustModal } from "./functions"; import { setSpoolArchived, useSpoolAdjustModal } from "./functions";
import { ISpool, ISpoolAdjustment } from "./model"; import { IPrintJob, ISpool, ISpoolAdjustment } from "./model";
dayjs.extend(utc); dayjs.extend(utc);
@@ -45,6 +45,16 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
enabled: !!record?.id, 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 spoolPrice = (item?: ISpool) => {
const price = item?.price ?? item?.filament.price; const price = item?.price ?? item?.filament.price;
if (price === undefined) { if (price === undefined) {
@@ -288,6 +298,69 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
}, },
]} ]}
/> />
<Title level={4} style={{ marginTop: 24 }}>
{t("spool.fields.print_history")}
</Title>
<Collapse
items={[
{
key: "1",
label: t("spool.fields.show_print_history"),
children: (
<Table<IPrintJob>
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) => (
<span title={dayjs.utc(value).local().format()}>
{dayjs.utc(value).local().format("YYYY-MM-DD HH:mm:ss")}
</span>
),
},
{
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 ? (
<span title={dayjs.utc(value).local().format()}>
{dayjs.utc(value).local().format("YYYY-MM-DD HH:mm:ss")}
</span>
) : (
"-"
),
},
]}
/>
),
},
]}
/>
</Show> </Show>
); );
}; };

View File

@@ -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")

View File

@@ -311,6 +311,12 @@ class Spool(BaseModel):
description=("Weight of an empty spool (tare weight)."), description=("Weight of an empty spool (tare weight)."),
examples=[246], 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( used_weight: float = Field(
ge=0, ge=0,
description="Consumed weight of filament from the spool in grams.", description="Consumed weight of filament from the spool in grams.",
@@ -325,6 +331,12 @@ class Spool(BaseModel):
), ),
examples=[5612.4], 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( used_length: float = Field(
ge=0, ge=0,
description="Consumed length of filament from the spool in millimeters.", description="Consumed length of filament from the spool in millimeters.",
@@ -389,6 +401,13 @@ class Spool(BaseModel):
diameter=filament.diameter, 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( return Spool(
id=item.id, id=item.id,
registered=item.registered, registered=item.registered,
@@ -398,10 +417,12 @@ class Spool(BaseModel):
price=item.price, price=item.price,
initial_weight=item.initial_weight, initial_weight=item.initial_weight,
spool_weight=item.spool_weight, spool_weight=item.spool_weight,
extra_weight=item.extra_weight,
used_weight=item.used_weight, used_weight=item.used_weight,
used_length=used_length, used_length=used_length,
remaining_weight=remaining_weight, remaining_weight=remaining_weight,
remaining_length=remaining_length, remaining_length=remaining_length,
remaining_value=remaining_value,
location=item.location, location=item.location,
lot_nr=item.lot_nr, lot_nr=item.lot_nr,
comment=item.comment, comment=item.comment,

View File

@@ -1,13 +1,16 @@
"""Filament related endpoints.""" """Filament related endpoints."""
import logging 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 pydantic import BaseModel, Field, RootModel
from sqlalchemy import func
from sqlalchemy.ext.asyncio import AsyncSession 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 from spoolman.database.database import get_db_session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -173,3 +176,118 @@ async def rename_location(
logger.info("Renaming location %s to %s", location, body.name) logger.info("Renaming location %s to %s", location, body.name)
await spool.rename_location(db=db, current_name=location, new_name=body.name) await spool.rename_location(db=db, current_name=location, new_name=body.name)
return 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]

View File

@@ -51,6 +51,12 @@ class SpoolParameters(BaseModel):
description="The weight of an empty spool, in grams. (tare weight)", description="The weight of an empty spool, in grams. (tare weight)",
examples=[200], 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( remaining_weight: Optional[float] = Field(
None, None,
ge=0, ge=0,
@@ -449,6 +455,7 @@ async def create( # noqa: ANN201
price=body.price, price=body.price,
initial_weight=body.initial_weight, initial_weight=body.initial_weight,
spool_weight=body.spool_weight, spool_weight=body.spool_weight,
extra_weight=body.extra_weight,
remaining_weight=body.remaining_weight, remaining_weight=body.remaining_weight,
used_weight=body.used_weight, used_weight=body.used_weight,
first_used=body.first_used, first_used=body.first_used,

View File

@@ -100,6 +100,10 @@ class Spool(Base):
initial_weight: Mapped[Optional[float]] = mapped_column() initial_weight: Mapped[Optional[float]] = mapped_column()
spool_weight: Mapped[Optional[float]] = mapped_column() spool_weight: Mapped[Optional[float]] = mapped_column()
used_weight: Mapped[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: Mapped[Optional[str]] = mapped_column(String(64), comment="Legacy flat location string.")
location_id: Mapped[Optional[int]] = mapped_column( location_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("location.id", ondelete="SET NULL"), ForeignKey("location.id", ondelete="SET NULL"),

View File

@@ -41,6 +41,7 @@ async def create(
remaining_weight: Optional[float] = None, remaining_weight: Optional[float] = None,
initial_weight: Optional[float] = None, initial_weight: Optional[float] = None,
spool_weight: Optional[float] = None, spool_weight: Optional[float] = None,
extra_weight: Optional[float] = None,
used_weight: Optional[float] = None, used_weight: Optional[float] = None,
first_used: Optional[datetime] = None, first_used: Optional[datetime] = None,
last_used: Optional[datetime] = None, last_used: Optional[datetime] = None,
@@ -84,6 +85,7 @@ async def create(
registered=datetime.utcnow().replace(microsecond=0), registered=datetime.utcnow().replace(microsecond=0),
initial_weight=initial_weight, initial_weight=initial_weight,
spool_weight=spool_weight, spool_weight=spool_weight,
extra_weight=extra_weight,
used_weight=used_weight, used_weight=used_weight,
price=price, price=price,
first_used=first_used, first_used=first_used,
@@ -440,7 +442,12 @@ async def measure(
""" """
spool_result = await db.execute( 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, models.Spool.id == spool_id,
), ),
) )
@@ -452,6 +459,7 @@ async def measure(
initial_weight = spool_info[0] initial_weight = spool_info[0]
spool_weight = spool_info[2] 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: if initial_weight is None or initial_weight == 0 or spool_weight is None or spool_weight == 0:
# Get filament weight and spool_weight # Get filament weight and spool_weight
result = await db.execute( result = await db.execute(
@@ -473,7 +481,7 @@ async def measure(
if initial_weight is None or initial_weight == 0: if initial_weight is None or initial_weight == 0:
raise SpoolMeasureError("Initial weight is not set.") 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 the measurement is greater than the initial weight, set the initial weight to the measurement
if weight > initial_gross_weight: if weight > initial_gross_weight:
@@ -485,9 +493,10 @@ async def measure(
# Calculate the weight used since last measure # Calculate the weight used since last measure
weight_to_use = current_use - weight weight_to_use = current_use - weight
# If the measured weight is less than the empty weight, use the rest of the spool # If the measured weight is less than the empty weight (+ extra), use the rest of the spool
if (initial_gross_weight - weight_to_use) < spool_weight: empty_gross_weight = spool_weight + extra_weight
weight_to_use = current_use - spool_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) return await use_weight(db, spool_id, weight_to_use, comment=comment)