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
+49 -1
View File
@@ -7,6 +7,7 @@ use super::payments::{derive_summary, summary_for, today_ist};
use crate::models::{
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary, Settings,
};
use crate::units;
use crate::AppState;
use chrono::NaiveDate;
use rusqlite::{named_params, params, Connection, OptionalExtension, Row};
@@ -130,6 +131,15 @@ fn parse_date(label: &str, value: &str) -> Result<(), String> {
/// overflow integer paise arithmetic.
const MAX_LINE_RUPEES: f64 = 1e11;
/// The unit written to the database. A fixed-amount line has no unit of its own, so whatever the form held is not kept.
fn stored_unit(item: &InvoiceItem) -> String {
if item.mode == "rate" {
units::normalize(&item.unit)
} else {
"unit".to_string()
}
}
fn validate_items(items: &[InvoiceItem]) -> Result<(), String> {
if items.is_empty() {
return Err("Add at least one line item".into());
@@ -145,6 +155,7 @@ fn validate_items(items: &[InvoiceItem]) -> Result<(), String> {
if item.quantity > MAX_LINE_RUPEES {
return Err(format!("Line {n}: quantity is too large"));
}
units::validate(&item.unit).map_err(|e| format!("Line {n}: {e}"))?;
let amount = gst::line_amount_paise(item);
if amount <= 0 {
return Err(format!("Line {n}: amount must be greater than zero"));
@@ -427,7 +438,7 @@ pub fn issue_invoice_impl(
item.description,
item.mode,
item.rate,
item.unit,
stored_unit(item),
item.quantity,
paise_to_rupees(gst::line_amount_paise(item)),
i as i64,
@@ -854,6 +865,43 @@ mod tests {
assert_eq!(inv.items[1].hsn_sac, "");
}
#[test]
fn lines_can_use_any_unit_and_fractional_quantities() {
let mut conn = registered();
let inv = issue(
&mut conn,
input(json!({
"items": [
{"description": "Travel", "mode": "rate", "rate": 18.0, "quantity": 12.75, "unit": "km"},
{"description": "Studio", "mode": "rate", "rate": 1200.0, "quantity": 4.0 + 1.0 / 3.0, "unit": "HOUR"},
{"description": "Parcels", "mode": "rate", "rate": 99.5, "quantity": 3.0, "unit": " bundle "},
{"description": "Fee", "mode": "fixed", "amount": 10.0, "unit": "not a unit;"},
],
})),
)
.unwrap();
let units: Vec<&str> = inv.items.iter().map(|i| i.unit.as_str()).collect();
assert_eq!(units, ["km", "hour", "bundle", "unit"]);
let amounts: Vec<f64> = inv.items.iter().map(|i| i.amount).collect();
// 18 x 12.75 = 229.50; 1200 x 4h20m = 5200.00 (one rounding, on the exact quantity); 99.50 x 3 = 298.50.
assert_eq!(amounts, [229.5, 5200.0, 298.5, 10.0]);
}
#[test]
fn a_rate_line_with_a_bad_unit_is_refused_and_the_counter_stays() {
let mut conn = registered();
for bad in ["", " ", "thirteen char", "x;y", "\u{20b9}"] {
let err = issue(
&mut conn,
input(json!({"items": [{"description": "Work", "mode": "rate", "rate": 10.0, "quantity": 1.0, "unit": bad}]})),
)
.unwrap_err();
assert!(err.starts_with("Line 1: "), "{bad:?}: {err}");
}
assert_eq!(next_number(&conn), 1);
assert_eq!(invoice_count(&conn), 0);
}
#[test]
fn tax_type_mismatch_is_rejected_and_the_counter_stays() {
let mut conn = registered();