Add units of measure and fractional quantities to rate lines
Quantities can now be time, count, distance, area, weight or volume, or a custom unit, instead of only
second/minute/hour/session/unit.
- Registry in src/lib/units.ts (mirrored in src-tauri/src/units.rs): grouped built-in units plus a custom unit of 1-12
characters. A rate line's unit is validated when an invoice or a preset is saved; migration M12 drops the old unit
allow-list from item_presets and keeps every row.
- Time is typed as h:mm: "4:30" hours counts as 4.5, "4:20" as 4.333... (kept exact, so rate x quantity rounds once, the
same in TypeScript and Rust). Other units take plain decimals up to 3 places. A bad quantity blocks issuing.
- The PDF quantity column shows the unit ("4.5 hr", "12.75 km"); the rate keeps "per hour" / "/hr". RenderItem carries
priceText, perText and rateShort, so no template parses the rate text with a regex over five hard-coded units.
- Quantity columns are sized from the widest word once a quantity with its unit is wider than 72 pt, so one long
custom unit cannot squeeze the description (the serenity and citrus-split tables overflowed in the new fixture).
Classic's quantity column is now content-sized.
- ERPNext: the default UOM map comes from the registry, with fractional-capable UOMs for time, distance, area, weight
and volume. A custom unit is sent as Nos. A quantity that is not exact to 3 decimals (4:20 hours) is still refused for
push, as before.
- Goldens and template thumbnails regenerated; the template harness has a "units" fixture with the longest texts.
Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
This commit is contained in:
+1
-1
@@ -23,7 +23,7 @@ export interface ErpnextConfig {
|
||||
paymentBankAccount: string;
|
||||
tdsAccount: string;
|
||||
defaultItemCode: string;
|
||||
/** Voiced unit (second, minute, hour, session, unit) to ERPNext UOM. */
|
||||
/** Voiced unit id (see lib/units.ts) to ERPNext UOM. */
|
||||
uomMap: Record<string, string>;
|
||||
customerGroup: string;
|
||||
territory: string;
|
||||
|
||||
@@ -232,7 +232,7 @@ describe("settings form", () => {
|
||||
expect(input).not.toHaveProperty("apiSecretSet");
|
||||
expect(input).not.toHaveProperty("lastDetectResult");
|
||||
expect(input.apiSecret).toBe("");
|
||||
expect(input.uomMap).toEqual({ second: "Second", minute: "Minute", hour: "Hr", session: "Nos", unit: "Nos" });
|
||||
expect(input.uomMap).toMatchObject({ second: "Second", minute: "Minute", hour: "Hr", session: "Nos", unit: "Nos", km: "Kilometer", sqft: "Square Foot" });
|
||||
expect(withDefaultUoms({ hour: " " }).hour).toBe("Hour");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Pure helpers behind the ERPNext screens (Settings, History, invoice detail, auto-push). No UI, no Tauri. */
|
||||
import { UNITS } from "./units";
|
||||
import type {
|
||||
ErpnextConfig,
|
||||
ErpnextConfigInput,
|
||||
@@ -259,14 +260,8 @@ export function versionsLine(test: ErpnextConnectionTest): string {
|
||||
|
||||
// ---- settings form ----
|
||||
|
||||
export const UOM_UNITS = ["second", "minute", "hour", "session", "unit"] as const;
|
||||
export const DEFAULT_UOM_MAP: Record<string, string> = {
|
||||
second: "Second",
|
||||
minute: "Minute",
|
||||
hour: "Hour",
|
||||
session: "Nos",
|
||||
unit: "Nos",
|
||||
};
|
||||
export const UOM_UNITS: readonly string[] = UNITS.map((u) => u.id);
|
||||
export const DEFAULT_UOM_MAP: Record<string, string> = Object.fromEntries(UNITS.map((u) => [u.id, u.uom]));
|
||||
|
||||
export function withDefaultUoms(map: Record<string, string> | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = { ...DEFAULT_UOM_MAP };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeLineAmount, computeTotals } from "./invoice";
|
||||
import { EMPTY_ITEM, type InvoiceItem } from "./types";
|
||||
import { parseQuantity } from "./units";
|
||||
|
||||
// These cases mirror the unit tests in src-tauri/src/gst.rs, so the on-screen preview
|
||||
// and the stored totals stay identical.
|
||||
@@ -40,6 +41,19 @@ describe("computeTotals", () => {
|
||||
expect(computeLineAmount(rate(99.99, 2.5))).toBe(249.98);
|
||||
});
|
||||
|
||||
it("counts a typed time as its decimal and rounds once, like Rust (units test: lines_can_use_any_unit...)", () => {
|
||||
const q = (text: string, unit: string) => {
|
||||
const r = parseQuantity(text, unit);
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return r.value;
|
||||
};
|
||||
expect(computeLineAmount(rate(1200, q("4:30", "hour")))).toBe(5400);
|
||||
// 4 h 20 min is 4.333...; 1200 x that is 5200.00 only if the exact value is kept, not 4.33.
|
||||
expect(computeLineAmount(rate(1200, q("4:20", "hour")))).toBe(5200);
|
||||
expect(computeLineAmount({ ...rate(18, q("12.75", "km")), unit: "km" })).toBe(229.5);
|
||||
expect(computeLineAmount(rate(99.5, 3))).toBe(298.5);
|
||||
});
|
||||
|
||||
it("clamps the discount to the subtotal", () => {
|
||||
const items = [fixed(100), fixed(50.5)];
|
||||
const over = computeTotals(items, 999.99, "igst", 18);
|
||||
|
||||
+2
-8
@@ -1,6 +1,7 @@
|
||||
export type TaxType = "none" | "cgst_sgst" | "igst";
|
||||
export type LineMode = "fixed" | "rate";
|
||||
export type LineUnit = "second" | "minute" | "hour" | "session" | "unit";
|
||||
/** A built-in unit id (see lib/units.ts) or, for a custom unit, the text the vendor typed. */
|
||||
export type LineUnit = string;
|
||||
|
||||
export interface Settings {
|
||||
vendorName: string;
|
||||
@@ -302,10 +303,3 @@ export const INDIAN_STATES: { code: string; name: string }[] = [
|
||||
{ code: "97", name: "Other Territory" },
|
||||
];
|
||||
|
||||
export const UNIT_LABELS: Record<LineUnit, string> = {
|
||||
second: "per second",
|
||||
minute: "per minute",
|
||||
hour: "per hour",
|
||||
session: "per session",
|
||||
unit: "per unit",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
UNITS,
|
||||
formatQuantity,
|
||||
normalizeUnit,
|
||||
parseQuantity,
|
||||
quantityText,
|
||||
ratePerText,
|
||||
unitShort,
|
||||
validateCustomUnit,
|
||||
} from "./units";
|
||||
|
||||
const value = (text: string, unit: string) => {
|
||||
const r = parseQuantity(text, unit);
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return r.value;
|
||||
};
|
||||
|
||||
describe("parseQuantity", () => {
|
||||
it("reads h:mm as hours for hour units", () => {
|
||||
expect(value("4:30", "hour")).toBe(4.5);
|
||||
expect(value("0:15", "hour")).toBe(0.25);
|
||||
expect(value("4:20", "hour")).toBeCloseTo(4 + 1 / 3, 12);
|
||||
expect(value("1:00:30", "hour")).toBeCloseTo(1 + 30 / 3600, 12);
|
||||
});
|
||||
|
||||
it("reads m:ss as minutes for minute units", () => {
|
||||
expect(value("4:30", "minute")).toBe(4.5);
|
||||
expect(parseQuantity("1:00:30", "minute").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a clock quantity for other units and out-of-range parts", () => {
|
||||
expect(parseQuantity("4:30", "km").ok).toBe(false);
|
||||
expect(parseQuantity("4:75", "hour").ok).toBe(false);
|
||||
expect(parseQuantity("4:", "hour").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("reads plain decimals for every unit", () => {
|
||||
expect(value("12.75", "km")).toBe(12.75);
|
||||
expect(value("120.5", "sqft")).toBe(120.5);
|
||||
expect(value("3", "unit")).toBe(3);
|
||||
expect(value(".5", "hour")).toBe(0.5);
|
||||
expect(value("2.", "kg")).toBe(2);
|
||||
expect(value("", "hour")).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects text, negatives, more than 3 decimals and huge values", () => {
|
||||
for (const bad of ["abc", "-1", "1,5", "1.2345", ".", "1e3", "99999999"]) {
|
||||
expect(parseQuantity(bad, "hour").ok, bad).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatQuantity", () => {
|
||||
it("trims zeros", () => {
|
||||
expect(formatQuantity(2, "unit")).toBe("2");
|
||||
expect(formatQuantity(1.5, "km")).toBe("1.5");
|
||||
expect(formatQuantity(12.75, "km")).toBe("12.75");
|
||||
expect(formatQuantity(0.125, "kg")).toBe("0.125");
|
||||
});
|
||||
|
||||
it("writes a repeating time as h:mm", () => {
|
||||
expect(formatQuantity(value("4:20", "hour"), "hour")).toBe("4:20");
|
||||
expect(formatQuantity(value("0:20", "hour"), "hour")).toBe("0:20");
|
||||
expect(formatQuantity(value("1:00:30", "hour"), "hour")).toBe("1:00:30");
|
||||
expect(formatQuantity(value("4:30", "hour"), "hour")).toBe("4.5");
|
||||
});
|
||||
|
||||
it("round-trips whatever parseQuantity accepts", () => {
|
||||
for (const [text, unit] of [["4:20", "hour"], ["7:45", "hour"], ["12.75", "km"], ["0:05", "hour"], ["3:10", "minute"]] as const) {
|
||||
const v = value(text, unit);
|
||||
expect(value(formatQuantity(v, unit), unit)).toBeCloseTo(v, 9);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("unit text", () => {
|
||||
it("keeps the old rate wording for the original five units", () => {
|
||||
expect(["second", "minute", "hour", "session", "unit"].map(ratePerText)).toEqual([
|
||||
"per second",
|
||||
"per minute",
|
||||
"per hour",
|
||||
"per session",
|
||||
"per unit",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the unit beside the quantity, but not for the generic unit", () => {
|
||||
expect(quantityText(4.5, "hour")).toBe("4.5 hr");
|
||||
expect(quantityText(12.75, "km")).toBe("12.75 km");
|
||||
expect(quantityText(3, "unit")).toBe("3");
|
||||
expect(quantityText(2, "bundle")).toBe("2 bundle");
|
||||
expect(unitShort("sqft")).toBe("sq ft");
|
||||
expect(ratePerText("bundle")).toBe("per bundle");
|
||||
});
|
||||
|
||||
it("validates custom units", () => {
|
||||
expect(validateCustomUnit("bundle")).toBeNull();
|
||||
expect(validateCustomUnit("sq.ft/day")).toBeNull();
|
||||
expect(validateCustomUnit("")).not.toBeNull();
|
||||
expect(validateCustomUnit("a".repeat(13))).not.toBeNull();
|
||||
expect(validateCustomUnit("₹")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("normalises stored values", () => {
|
||||
expect(normalizeUnit("HOUR")).toBe("hour");
|
||||
expect(normalizeUnit(" bundle ")).toBe("bundle");
|
||||
expect(normalizeUnit("")).toBe("unit");
|
||||
expect(normalizeUnit(undefined)).toBe("unit");
|
||||
});
|
||||
|
||||
it("has unique ids and a short for every unit except the generic one", () => {
|
||||
expect(new Set(UNITS.map((u) => u.id)).size).toBe(UNITS.length);
|
||||
for (const u of UNITS) if (u.id !== "unit") expect(u.short).not.toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Units of measure for rate × quantity lines. The ids of the first five are stored in saved invoices
|
||||
* and must never change. Any other text is a custom unit and is stored as typed (see `validateCustomUnit`).
|
||||
* The Rust side keeps the same rules in src-tauri/src/units.rs.
|
||||
*/
|
||||
|
||||
export type UnitGroup = "Count" | "Time" | "Distance" | "Area" | "Weight" | "Volume";
|
||||
|
||||
/** How `h:mm` style quantities are read for a unit: "4:30" is 4 h 30 min for hours, 4 min 30 s for minutes. */
|
||||
export type ClockKind = "hours" | "minutes";
|
||||
|
||||
export interface UnitDef {
|
||||
id: string;
|
||||
/** Shown in pickers: "Hour". */
|
||||
label: string;
|
||||
/** Lower-case word used after "per": "per hour". */
|
||||
noun: string;
|
||||
/** Short form beside a quantity: "4.5 hr". Empty for the generic "unit", which stays a bare number. */
|
||||
short: string;
|
||||
group: UnitGroup;
|
||||
clock?: ClockKind;
|
||||
/** ERPNext UOM used when the integration has no mapping of its own for this unit. */
|
||||
uom: string;
|
||||
}
|
||||
|
||||
export const UNITS: readonly UnitDef[] = [
|
||||
{ id: "unit", label: "Unit", noun: "unit", short: "", group: "Count", uom: "Nos" },
|
||||
{ id: "piece", label: "Piece", noun: "piece", short: "pc", group: "Count", uom: "Nos" },
|
||||
{ id: "set", label: "Set", noun: "set", short: "set", group: "Count", uom: "Set" },
|
||||
{ id: "session", label: "Session", noun: "session", short: "session", group: "Time", uom: "Nos" },
|
||||
{ id: "second", label: "Second", noun: "second", short: "sec", group: "Time", uom: "Second" },
|
||||
{ id: "minute", label: "Minute", noun: "minute", short: "min", group: "Time", clock: "minutes", uom: "Minute" },
|
||||
{ id: "hour", label: "Hour", noun: "hour", short: "hr", group: "Time", clock: "hours", uom: "Hour" },
|
||||
{ id: "day", label: "Day", noun: "day", short: "day", group: "Time", uom: "Day" },
|
||||
{ id: "week", label: "Week", noun: "week", short: "wk", group: "Time", uom: "Week" },
|
||||
{ id: "month", label: "Month", noun: "month", short: "mo", group: "Time", uom: "Month" },
|
||||
{ id: "km", label: "Kilometre", noun: "km", short: "km", group: "Distance", uom: "Kilometer" },
|
||||
{ id: "m", label: "Metre", noun: "m", short: "m", group: "Distance", uom: "Meter" },
|
||||
{ id: "sqft", label: "Square foot", noun: "sq ft", short: "sq ft", group: "Area", uom: "Square Foot" },
|
||||
{ id: "sqm", label: "Square metre", noun: "sq m", short: "sq m", group: "Area", uom: "Square Meter" },
|
||||
{ id: "kg", label: "Kilogram", noun: "kg", short: "kg", group: "Weight", uom: "Kg" },
|
||||
{ id: "litre", label: "Litre", noun: "litre", short: "L", group: "Volume", uom: "Litre" },
|
||||
];
|
||||
|
||||
export const UNIT_GROUPS: readonly UnitGroup[] = ["Count", "Time", "Distance", "Area", "Weight", "Volume"];
|
||||
|
||||
const BY_ID = new Map(UNITS.map((u) => [u.id, u]));
|
||||
|
||||
export const DEFAULT_UNIT = "unit";
|
||||
export const MAX_CUSTOM_UNIT_LENGTH = 12;
|
||||
|
||||
/** The built-in unit with this id, or undefined for a custom unit. */
|
||||
export const knownUnit = (unit: string): UnitDef | undefined => BY_ID.get(unit.trim().toLowerCase());
|
||||
|
||||
export const isKnownUnit = (unit: string): boolean => BY_ID.has(unit.trim().toLowerCase());
|
||||
|
||||
/** Problem with a custom unit text, or null when it is fine. */
|
||||
export function validateCustomUnit(text: string): string | null {
|
||||
const t = text.trim();
|
||||
if (!t) return "Enter a unit";
|
||||
if (t.length > MAX_CUSTOM_UNIT_LENGTH) return `Use at most ${MAX_CUSTOM_UNIT_LENGTH} characters`;
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9 ./-]*$/.test(t)) return "Use letters, digits, spaces, . / or -";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether a stored unit can be issued: a built-in id or a valid custom text. */
|
||||
export const isValidUnit = (unit: string): boolean => isKnownUnit(unit) || validateCustomUnit(unit) === null;
|
||||
|
||||
/** A unit value as stored: the id for a built-in unit, the trimmed text otherwise. Falls back to the default. */
|
||||
export function normalizeUnit(unit: string | null | undefined): string {
|
||||
const t = (unit ?? "").trim();
|
||||
if (!t) return DEFAULT_UNIT;
|
||||
const lower = t.toLowerCase();
|
||||
if (BY_ID.has(lower)) return lower;
|
||||
return t;
|
||||
}
|
||||
|
||||
export const unitLabel = (unit: string): string => knownUnit(unit)?.label ?? unit.trim();
|
||||
|
||||
/** "hr" for hours; the text itself for a custom unit; "" for the generic unit. */
|
||||
export const unitShort = (unit: string): string => {
|
||||
const def = knownUnit(unit);
|
||||
return def ? def.short : unit.trim();
|
||||
};
|
||||
|
||||
/** "per hour", "per sq ft", "per bundle". */
|
||||
export const ratePerText = (unit: string): string => `per ${knownUnit(unit)?.noun ?? unit.trim()}`;
|
||||
|
||||
/** The ERPNext UOM that suits a built-in unit; undefined for a custom unit. */
|
||||
export const defaultUom = (unit: string): string | undefined => knownUnit(unit)?.uom;
|
||||
|
||||
export type QuantityParse = { ok: true; value: number } | { ok: false; error: string };
|
||||
|
||||
const MAX_QUANTITY = 10_000_000;
|
||||
|
||||
/**
|
||||
* Reads what the user typed into a quantity box. Plain decimals (up to 3 places) work for every unit.
|
||||
* Hour units also take `h:mm` and `h:mm:ss`, minute units take `m:ss`, so "4:30" hours is 4.5.
|
||||
* An empty box is 0, the same as before this existed.
|
||||
*/
|
||||
export function parseQuantity(text: string, unit: string): QuantityParse {
|
||||
const t = text.trim();
|
||||
if (!t) return { ok: true, value: 0 };
|
||||
const clock = knownUnit(unit)?.clock;
|
||||
|
||||
if (t.includes(":")) {
|
||||
if (!clock) return { ok: false, error: "Only time units take h:mm" };
|
||||
const m = /^(\d{1,7}):(\d{1,2})(?::(\d{1,2}))?$/.exec(t);
|
||||
if (!m) return { ok: false, error: clock === "hours" ? "Use h:mm, for example 4:30" : "Use m:ss, for example 4:30" };
|
||||
const first = Number(m[1]);
|
||||
const second = Number(m[2]);
|
||||
const third = m[3] === undefined ? null : Number(m[3]);
|
||||
if (second > 59 || (third !== null && third > 59)) return { ok: false, error: "Minutes and seconds go up to 59" };
|
||||
if (clock === "minutes" && third !== null) return { ok: false, error: "Use m:ss, for example 4:30" };
|
||||
const value = clock === "hours" ? first + second / 60 + (third ?? 0) / 3600 : first + second / 60;
|
||||
return value > MAX_QUANTITY ? { ok: false, error: "That quantity is too large" } : { ok: true, value };
|
||||
}
|
||||
|
||||
const m = /^(\d{0,8})(?:\.(\d{0,3}))?$/.exec(t);
|
||||
if (!m || (m[1] === "" && !m[2])) {
|
||||
return { ok: false, error: /^\d*\.\d{4,}$/.test(t) ? "Use at most 3 decimal places" : "Enter a number" };
|
||||
}
|
||||
const value = Number(t);
|
||||
return value > MAX_QUANTITY ? { ok: false, error: "That quantity is too large" } : { ok: true, value };
|
||||
}
|
||||
|
||||
const EPS = 1e-9;
|
||||
|
||||
function trimmed(value: number, places: number): string {
|
||||
return value.toFixed(places).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* A quantity as text, without the unit. Exact for anything `parseQuantity` accepts: a time that is not a
|
||||
* whole number of hundredths (4 h 20 min) is written as h:mm so that quantity × rate can be checked by eye.
|
||||
*/
|
||||
export function formatQuantity(value: number, unit: string): string {
|
||||
if (!Number.isFinite(value)) return "0";
|
||||
const clock = knownUnit(unit)?.clock;
|
||||
if (!clock) return trimmed(value, 3);
|
||||
if (Math.abs(value * 100 - Math.round(value * 100)) < EPS * 100) return trimmed(value, 2);
|
||||
const whole = Math.floor(value + EPS);
|
||||
const rest = (value - whole) * 60;
|
||||
const min = Math.floor(rest + EPS);
|
||||
const sec = Math.round((rest - min) * 60);
|
||||
const head = clock === "hours" ? `${whole}:${String(min).padStart(2, "0")}` : null;
|
||||
if (clock === "hours" && sec < 60 && Math.abs(whole + min / 60 + sec / 3600 - value) < EPS) {
|
||||
return sec === 0 ? head! : `${head}:${String(sec).padStart(2, "0")}`;
|
||||
}
|
||||
if (clock === "minutes") {
|
||||
const s = Math.round((value - whole) * 60);
|
||||
if (s < 60 && Math.abs(whole + s / 60 - value) < EPS) return `${whole}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
return trimmed(value, 4);
|
||||
}
|
||||
|
||||
/** The quantity as printed on an invoice: "4:20 hr", "12.75 km", or a bare "3" for the generic unit. */
|
||||
export function quantityText(value: number, unit: string): string {
|
||||
const q = formatQuantity(value, unit);
|
||||
const short = unitShort(unit);
|
||||
return short ? `${q} ${short}` : q;
|
||||
}
|
||||
Reference in New Issue
Block a user