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
+67 -2
View File
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
const BACKUP_PREFIX: &str = "voiced-pre-v";
/// Highest schema version, i.e. the number of entries in `migrations()`.
pub(crate) const LATEST_VERSION: i64 = 11;
pub(crate) const LATEST_VERSION: i64 = 12;
#[derive(Debug, thiserror::Error)]
pub enum DbError {
@@ -345,6 +345,27 @@ DROP TABLE erpnext_sync;
ALTER TABLE erpnext_sync_new RENAME TO erpnext_sync;
"#;
/// Version 12: `item_presets.unit` no longer lists five units. Units of measure now cover time, distance, area, weight and
/// volume, and a preset may carry a custom unit, so the allow-list becomes a length check and src/units.rs does the rest.
/// SQLite cannot alter a CHECK constraint, so the table is rebuilt; every M7/M10 row is copied over with its id.
const M12: &str = r#"
CREATE TABLE item_presets_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
description TEXT NOT NULL,
hsn_sac TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'fixed' CHECK (mode IN ('fixed', 'rate')),
unit TEXT NOT NULL DEFAULT 'unit' CHECK (length(trim(unit)) BETWEEN 1 AND 12),
rate_paise INTEGER NOT NULL DEFAULT 0 CHECK (rate_paise >= 0),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
erpnext_item_code TEXT
);
INSERT INTO item_presets_new (id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code)
SELECT id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code FROM item_presets;
DROP TABLE item_presets;
ALTER TABLE item_presets_new RENAME TO item_presets;
"#;
fn migrations() -> Migrations<'static> {
Migrations::new(vec![
M::up(SCHEMA),
@@ -358,6 +379,7 @@ fn migrations() -> Migrations<'static> {
M::up(M9),
M::up(M10),
M::up(M11),
M::up(M12),
])
}
@@ -950,7 +972,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
assert!(conn.execute("UPDATE erpnext_sync SET status = 'conflict'", []).is_err());
migrations().to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 11);
assert_eq!(user_version(&conn), LATEST_VERSION);
let row: (String, i64, String, String, String, Option<String>) = conn
.query_row(
"SELECT remote_name, remote_docstatus, status, payload_hash, attachment_sha256, synced_at FROM erpnext_sync WHERE invoice_id = 1",
@@ -968,6 +990,49 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
assert!(conn.execute("INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'error')", []).is_err());
}
#[test]
fn m12_rebuilds_item_presets_keeping_rows_and_allowing_new_units() {
let mut conn = Connection::open_in_memory().unwrap();
let v11 = vec![
M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6), M::up(M7), M::up(M8), M::up(M9), M::up(M10), M::up(M11),
];
Migrations::new(v11).to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 11);
conn.execute(
"INSERT INTO item_presets (description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code)
VALUES ('Studio', '998397', 'rate', 'hour', 150050, 3, 'then', 'ITEM-1'), ('Fee', '', 'fixed', 'unit', 500000, 0, 'then', NULL)",
[],
)
.unwrap();
assert!(conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('X', 'km', 'now')", []).is_err());
migrations().to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 12);
let rows: Vec<(i64, String, String, String, String, i64, i64, String, Option<String>)> = conn
.prepare("SELECT id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code FROM item_presets ORDER BY id")
.unwrap()
.query_map([], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?, r.get(7)?, r.get(8)?))
})
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
assert_eq!(
rows,
vec![
(1, "Studio".into(), "998397".into(), "rate".into(), "hour".into(), 150050, 3, "then".into(), Some("ITEM-1".into())),
(2, "Fee".into(), "".into(), "fixed".into(), "unit".into(), 500000, 0, "then".into(), None),
]
);
// New units and custom text are allowed; an empty or over-long unit is not; ids keep counting up.
conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('Travel', 'km', 'now')", []).unwrap();
conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('Parcel', 'bundle', 'now')", []).unwrap();
assert!(conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('X', '', 'now')", []).is_err());
assert!(conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('X', 'thirteen char', 'now')", []).is_err());
let last: i64 = conn.query_row("SELECT MAX(id) FROM item_presets", [], |r| r.get(0)).unwrap();
assert_eq!(last, 4);
}
fn build_v1_database(path: &Path) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(V1_SCHEMA).unwrap();