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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user