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:
@@ -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();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use crate::models::ItemPreset;
|
||||
use crate::units;
|
||||
use crate::AppState;
|
||||
use rusqlite::{params, Connection, Row};
|
||||
use tauri::State;
|
||||
|
||||
const MODES: &[&str] = &["fixed", "rate"];
|
||||
const UNITS: &[&str] = &["second", "minute", "hour", "session", "unit"];
|
||||
/// Upper bound on a preset amount (paise); keeps later integer-paise arithmetic far from overflow.
|
||||
const MAX_RATE_PAISE: i64 = 100_000_000_000;
|
||||
|
||||
@@ -45,9 +45,8 @@ pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPre
|
||||
if !MODES.contains(&preset.mode.as_str()) {
|
||||
return Err(format!("Unknown line type \"{}\"", preset.mode));
|
||||
}
|
||||
if !UNITS.contains(&preset.unit.as_str()) {
|
||||
return Err(format!("Unknown unit \"{}\"", preset.unit));
|
||||
}
|
||||
units::validate(&preset.unit)?;
|
||||
let unit = units::normalize(&preset.unit);
|
||||
if !(0..=MAX_RATE_PAISE).contains(&preset.rate_paise) {
|
||||
return Err("Amount is out of range".into());
|
||||
}
|
||||
@@ -58,7 +57,7 @@ pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPre
|
||||
.execute(
|
||||
"UPDATE item_presets SET description=?1, hsn_sac=?2, mode=?3, unit=?4, rate_paise=?5, sort_order=?6
|
||||
WHERE id=?7",
|
||||
params![description, hsn, preset.mode, preset.unit, preset.rate_paise, preset.sort_order, id],
|
||||
params![description, hsn, preset.mode, unit, preset.rate_paise, preset.sort_order, id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
@@ -74,7 +73,7 @@ pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPre
|
||||
description,
|
||||
hsn,
|
||||
preset.mode,
|
||||
preset.unit,
|
||||
unit,
|
||||
preset.rate_paise,
|
||||
preset.sort_order,
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
@@ -156,6 +155,18 @@ mod tests {
|
||||
delete_preset_impl(&conn, 12345).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn units_beyond_the_original_five_are_stored_as_given() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
for (typed, stored) in [("km", "km"), ("SQFT", "sqft"), (" bundle ", "bundle"), ("sq.ft/day", "sq.ft/day")] {
|
||||
let mut p = preset(typed);
|
||||
p.mode = "rate".into();
|
||||
p.unit = typed.into();
|
||||
p.rate_paise = 5_000;
|
||||
assert_eq!(save_preset_impl(&conn, p).unwrap().unit, stored, "{typed}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_bad_presets() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
@@ -166,9 +177,11 @@ mod tests {
|
||||
let mut p = preset("X");
|
||||
p.mode = "weird".into();
|
||||
assert!(save_preset_impl(&conn, p).is_err());
|
||||
let mut p = preset("X");
|
||||
p.unit = "decade".into();
|
||||
assert!(save_preset_impl(&conn, p).is_err());
|
||||
for bad in ["", " ", "far too long a unit", "bad;unit", "₹"] {
|
||||
let mut p = preset("X");
|
||||
p.unit = bad.into();
|
||||
assert!(save_preset_impl(&conn, p).is_err(), "{bad:?}");
|
||||
}
|
||||
let mut ghost = preset("Ghost");
|
||||
ghost.id = Some(77);
|
||||
assert!(save_preset_impl(&conn, ghost).is_err());
|
||||
|
||||
+67
-2
@@ -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();
|
||||
|
||||
@@ -61,18 +61,9 @@ impl NamingMode {
|
||||
}
|
||||
|
||||
/// Voiced line unit to ERPNext UOM. Whole-number UOMs such as "Nos" reject fractional quantities,
|
||||
/// which is why hours and minutes map to their own UOMs.
|
||||
/// which is why hours, minutes, kilometres and the like map to their own UOMs (see `units::KNOWN`).
|
||||
pub fn default_uom_map() -> BTreeMap<String, String> {
|
||||
[
|
||||
("second", "Second"),
|
||||
("minute", "Minute"),
|
||||
("hour", "Hour"),
|
||||
("session", "Nos"),
|
||||
("unit", "Nos"),
|
||||
]
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
crate::units::KNOWN.iter().map(|(id, uom)| (id.to_string(), uom.to_string())).collect()
|
||||
}
|
||||
|
||||
/// Everything stored for the integration, secret included. Backend-only.
|
||||
|
||||
@@ -5,6 +5,7 @@ mod integrations;
|
||||
mod logo;
|
||||
mod models;
|
||||
mod selftest;
|
||||
mod units;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -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