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:
2026-10-06 09:35:20 +05:30
parent cd067ad584
commit 3aa0d33a01
52 changed files with 910 additions and 148 deletions
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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");
});
+3 -8
View File
@@ -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 };
+14
View File
@@ -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
View File
@@ -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",
};
+116
View File
@@ -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("");
});
});
+162
View File
@@ -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;
}