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
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:
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
123
client/src/pages/home/components/UsageAnalytics.tsx
Normal file
123
client/src/pages/home/components/UsageAnalytics.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user