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
+120
View File
@@ -0,0 +1,120 @@
//! Units of measure for rate × quantity lines. Mirrors src/lib/units.ts: the ids below are stored in
//! invoices and presets, and any other text that passes `validate` is a custom unit stored as typed.
/// Built-in unit ids with the ERPNext UOM each one maps to by default. Whole-number UOMs ("Nos", "Set")
/// reject fractional quantities, so units that are usually fractional map to UOMs that allow them.
pub const KNOWN: &[(&str, &str)] = &[
("unit", "Nos"),
("piece", "Nos"),
("set", "Set"),
("session", "Nos"),
("second", "Second"),
("minute", "Minute"),
("hour", "Hour"),
("day", "Day"),
("week", "Week"),
("month", "Month"),
("km", "Kilometer"),
("m", "Meter"),
("sqft", "Square Foot"),
("sqm", "Square Meter"),
("kg", "Kg"),
("litre", "Litre"),
];
/// Longest custom unit; it lands in a measured PDF column.
pub const MAX_CUSTOM_LEN: usize = 12;
pub fn is_known(unit: &str) -> bool {
KNOWN.iter().any(|(id, _)| *id == unit)
}
/// The ERPNext UOM that suits a built-in unit.
pub fn default_uom(unit: &str) -> Option<&'static str> {
KNOWN.iter().find(|(id, _)| *id == unit).map(|(_, uom)| *uom)
}
fn custom_problem(text: &str) -> Option<String> {
let t = text.trim();
if t.is_empty() {
return Some("a unit is required".into());
}
if t.chars().count() > MAX_CUSTOM_LEN {
return Some(format!("a custom unit has at most {MAX_CUSTOM_LEN} characters"));
}
let mut chars = t.chars();
let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphanumeric());
if !first_ok || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '.' | '/' | '-')) {
return Some("a unit may use letters, digits, spaces, . / and - only".into());
}
None
}
/// The unit as it is stored: trimmed, and a built-in id in lower case. An empty value is the generic unit.
pub fn normalize(unit: &str) -> String {
let t = unit.trim();
if t.is_empty() {
return "unit".to_string();
}
let lower = t.to_ascii_lowercase();
if is_known(&lower) {
lower
} else {
t.to_string()
}
}
/// Checks a unit that is about to be stored. Errors read as `Unit "x": <problem>`.
pub fn validate(unit: &str) -> Result<(), String> {
if unit.trim().is_empty() {
return Err("A unit is required".to_string());
}
if is_known(&normalize(unit)) {
return Ok(());
}
match custom_problem(unit) {
None => Ok(()),
Some(p) => Err(format!("Unit \"{}\": {p}", unit.trim())),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_units_are_accepted_in_any_case() {
for (id, _) in KNOWN {
assert!(validate(id).is_ok(), "{id}");
}
assert_eq!(normalize(" HOUR "), "hour");
assert_eq!(normalize(""), "unit");
}
#[test]
fn custom_units_follow_the_same_rules_as_the_ui() {
assert!(validate("bundle").is_ok());
assert!(validate("sq.ft/day").is_ok());
assert_eq!(normalize(" bundle "), "bundle");
assert!(validate("a".repeat(13).as_str()).is_err());
assert!(validate("₹").is_err());
assert!(validate("-x").is_err());
assert!(validate("").is_err());
assert!(validate(" ").is_err());
assert!(validate("bad;drop").is_err());
}
#[test]
fn the_original_five_ids_are_unchanged() {
for id in ["second", "minute", "hour", "session", "unit"] {
assert!(is_known(id), "{id}");
}
}
#[test]
fn fractional_units_do_not_map_to_whole_number_uoms() {
for id in ["second", "minute", "hour", "day", "week", "month", "km", "m", "sqft", "sqm", "kg", "litre"] {
assert_ne!(default_uom(id), Some("Nos"), "{id}");
}
}
}