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();
|
||||
|
||||
Reference in New Issue
Block a user