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
+13
View File
@@ -297,6 +297,19 @@ export const FIXTURES: FixtureSpec[] = [
{ description: "Audio editing and mastering of all deliverables, including noise reduction, levelling, and export to the three formats requested by the client in the brief of August", mode: "rate", rate: 400, unit: "minute", quantity: 25, amount: 0, sortOrder: 2, hsnSac: "998397" },
],
},
{
// Units of measure: a decimal distance, an area, a time that is not a whole number of hundredths (4 h 20 min),
// a 12-character custom unit and a month, so the quantity and rate columns see the longest texts they can.
name: "units",
items: [
{ description: "Travel to the client's studio", mode: "rate", rate: 18.5, unit: "km", quantity: 12.75, amount: 0, sortOrder: 0, hsnSac: "996601" },
{ description: "Acoustic treatment, floor area", mode: "rate", rate: 85, unit: "sqft", quantity: 1250.5, amount: 0, sortOrder: 1, hsnSac: "995411" },
{ description: "Evening studio session", mode: "rate", rate: 1500, unit: "hour", quantity: 4 + 1 / 3, amount: 0, sortOrder: 2, hsnSac: "998397" },
{ description: "Packed equipment cases", mode: "rate", rate: 99.5, unit: "wooden crate", quantity: 120, amount: 0, sortOrder: 3, hsnSac: "998397" },
{ description: "Retainer", mode: "rate", rate: 25000, unit: "month", quantity: 1, amount: 0, sortOrder: 4, hsnSac: "998397" },
{ description: "Setup fee", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 8000, sortOrder: 5, hsnSac: "997212" },
],
},
{
name: "long-notes",
items: 30,