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:
@@ -133,7 +133,10 @@ describe("unregistered invoice", () => {
|
||||
descriptionLines: ["Voice-over", "session A"],
|
||||
hsnSac: "998397",
|
||||
rateText: "₹1,200.00 per hour",
|
||||
qtyText: "2.5",
|
||||
priceText: "₹1,200.00",
|
||||
perText: "per hour",
|
||||
rateShort: "₹1,200.00/hr",
|
||||
qtyText: "2.5 hr",
|
||||
amountText: "₹3,000.00",
|
||||
});
|
||||
expect(m.items[1]).toEqual({
|
||||
@@ -141,10 +144,22 @@ describe("unregistered invoice", () => {
|
||||
descriptionLines: ["—"],
|
||||
hsnSac: null,
|
||||
rateText: null,
|
||||
priceText: null,
|
||||
perText: null,
|
||||
rateShort: null,
|
||||
qtyText: "1",
|
||||
amountText: "₹2,500.50",
|
||||
});
|
||||
});
|
||||
it("words the unit and the quantity for every kind of unit", () => {
|
||||
const line = (unit: string, quantity: number, rate = 18.5) =>
|
||||
make({ items: [{ ...base.items[0], mode: "rate", unit, quantity, rate }] }).items[0];
|
||||
expect(line("km", 12.75)).toMatchObject({ qtyText: "12.75 km", rateText: "₹18.50 per km", rateShort: "₹18.50/km", perText: "per km" });
|
||||
expect(line("sqft", 1250.5)).toMatchObject({ qtyText: "1250.5 sq ft", rateShort: "₹18.50/sq ft" });
|
||||
expect(line("hour", 4 + 1 / 3)).toMatchObject({ qtyText: "4:20 hr" });
|
||||
expect(line("unit", 3)).toMatchObject({ qtyText: "3", rateText: "₹18.50 per unit", rateShort: "₹18.50/unit" });
|
||||
expect(line(" bundle ", 2)).toMatchObject({ qtyText: "2 bundle", rateText: "₹18.50 per bundle", rateShort: "₹18.50/bundle" });
|
||||
});
|
||||
it("labels the bank lines and defaults the signature to the vendor name", () => {
|
||||
expect(m.bank).toEqual({
|
||||
accountName: "Test Vendor",
|
||||
|
||||
+20
-3
@@ -1,7 +1,7 @@
|
||||
import { computeLineAmount } from "../../lib/invoice";
|
||||
import { quantityText, ratePerText, unitShort, knownUnit } from "../../lib/units";
|
||||
import {
|
||||
INDIAN_STATES,
|
||||
UNIT_LABELS,
|
||||
type BankAccount,
|
||||
type Invoice,
|
||||
type Settings,
|
||||
@@ -48,7 +48,15 @@ export interface RenderItem {
|
||||
sno: number;
|
||||
descriptionLines: string[];
|
||||
hsnSac: string | null;
|
||||
/** "₹1,200.00 per hour"; null for a fixed-amount line. */
|
||||
rateText: string | null;
|
||||
/** The amount alone: "₹1,200.00". */
|
||||
priceText: string | null;
|
||||
/** The unit wording alone: "per hour". */
|
||||
perText: string | null;
|
||||
/** The one-line form: "₹1,200.00/hr". */
|
||||
rateShort: string | null;
|
||||
/** "4.5 hr", "12.75 km", or a bare "3" for the generic unit; "1" for a fixed-amount line. */
|
||||
qtyText: string;
|
||||
amountText: string;
|
||||
}
|
||||
@@ -140,6 +148,15 @@ function taxRowsFor(invoice: Invoice, supplierStateCode: string): TaxRow[] {
|
||||
}
|
||||
|
||||
/** Pure: turns the stored invoice into pre-formatted, sanitised data. Totals are the stored values. */
|
||||
function rateFields(item: Invoice["items"][number]): Pick<RenderItem, "rateText" | "priceText" | "perText" | "rateShort"> {
|
||||
if (item.mode !== "rate") return { rateText: null, priceText: null, perText: null, rateShort: null };
|
||||
const priceText = formatRupees(item.rate);
|
||||
const perText = ratePerText(item.unit);
|
||||
// The generic "unit" has no short form beside a quantity, but its rate still reads "/unit".
|
||||
const suffix = unitShort(item.unit) || knownUnit(item.unit)?.noun || item.unit.trim();
|
||||
return { rateText: `${priceText} ${perText}`, priceText, perText, rateShort: `${priceText}/${suffix}` };
|
||||
}
|
||||
|
||||
export function buildRenderModel(input: BuildInput): RenderModel {
|
||||
const { invoice, logo, signatureDataUri, appVersion } = input;
|
||||
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, input.vendor);
|
||||
@@ -164,8 +181,8 @@ export function buildRenderModel(input: BuildInput): RenderModel {
|
||||
return lines.length ? lines : ["—"];
|
||||
})(),
|
||||
hsnSac: orNull(item.hsnSac),
|
||||
rateText: item.mode === "rate" ? `${formatRupees(item.rate)} ${UNIT_LABELS[item.unit]}` : null,
|
||||
qtyText: item.mode === "rate" ? String(item.quantity) : "1",
|
||||
...rateFields(item),
|
||||
qtyText: item.mode === "rate" ? quantityText(item.quantity, item.unit) : "1",
|
||||
amountText: formatRupees(computeLineAmount(item)),
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user