Fixed assets, fully featured: tags, serials, acquisition links, label sheets, the fixed asset register, and an Assets section in the viewer; fix a flaky verify

- core: asset tags (generated PREFIX-0001 or the human's own; unique ignoring case; never reused by another asset) and serial numbers (rules.md B-24); a journaled link from an asset to the entry that acquired it (posted, unreversed, debiting the cost account); fixed_asset_register(as_of), reconciled to the GL (unposted depreciation, unregistered cost)
- store: asset.tagged, asset.serial_set, asset.acquisition_linked journaled, replayed, projected (schema v9); differs_from now also compares control roles and tag history
- config: [books] asset_tag_prefix, default the company's initials without legal-form words
- import: Avery 5160/8160 label sheets with QR codes (geometry vendored from Avery, docs/spec/sources/avery-5160.md); the register as XLSX and PDF
- mcp: register_asset tags (and links) automatically; books.label_asset, books.asset_labels; asset tools take a tag; books.show asset / assets page / register report; export fixed_asset_register (json, csv, xlsx, pdf) and in the CPA package
- viewer: Assets page (register, reconciliation), asset page (identity, schedule posted vs not, entries, documents), Fixed asset register report, global search by tag/serial/description, journal text, needs-attention items
- fix: verify compared snapshot amounts as strings; a zero total summed in hash-map order could print "0" or "0.00", so agreeing books failed verify at random. Now by value (regression test)
- tests: spec-blind suites for tags, register, prefix, store, MCP; label and register PDF/XLSX tests; viewer tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-15 12:18:00 -06:00
1 parent 4e96b5f54d
commit cd085108c1
53 files changed
+7872 -104

No files matched your search

+77
View File
@@ -68,6 +68,83 @@ pub struct Asset {
pub expense_account: AccountId,
/// Lifecycle state.
pub status: AssetStatus,
/// The label on the thing itself, e.g. "FL-0001" (see
/// [`check_asset_tag`]); `None` until one is set.
#[serde(default)]
pub tag: Option<String>,
/// The manufacturer's serial number, if recorded.
#[serde(default)]
pub serial: Option<String>,
/// The posted entry that put it on the books (its purchase, or a
/// member's contribution of it), if linked
/// ([`crate::Ledger::set_asset_acquisition`]).
#[serde(default)]
pub acquired_by: Option<crate::EntryId>,
}
/// The longest asset tag.
pub const MAX_ASSET_TAG_LEN: usize = 32;
/// The longest serial number.
pub const MAX_SERIAL_LEN: usize = 64;
/// Check an asset tag's form: 1 to [`MAX_ASSET_TAG_LEN`] characters, each
/// an ASCII letter or digit or one of `-` `_` `.` `/`, at least one of
/// them a letter or digit. Returns why not.
///
/// ```text
/// GOAL: a tag prints on a small label, reads aloud, scans from a QR code,
/// and types back in exactly.
/// SOURCE: DECISION (owner, 2026-10-15): generated tags ("FL-0001") or
/// the human's own.
/// INVARIANTS:
/// 1. Accepted tags contain no spaces, no control or non-ASCII
/// characters, and at least one letter or digit.
/// ```
pub fn check_asset_tag(tag: &str) -> Result<(), String> {
if tag.is_empty() {
return Err("a tag can't be empty".into());
}
if tag.len() > MAX_ASSET_TAG_LEN {
return Err(format!("a tag is at most {MAX_ASSET_TAG_LEN} characters"));
}
if let Some(c) = tag
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/')))
{
return Err(format!(
"{c:?} can't be in a tag (letters, digits, - _ . / only)"
));
}
if !tag.chars().any(|c| c.is_ascii_alphanumeric()) {
return Err("a tag needs a letter or digit".into());
}
Ok(())
}
/// Check a serial number's form: 1 to [`MAX_SERIAL_LEN`] characters, no
/// control characters, no leading or trailing space. Returns why not.
pub fn check_serial(serial: &str) -> Result<(), String> {
if serial.trim().is_empty() {
return Err("a serial number can't be empty".into());
}
if serial.chars().count() > MAX_SERIAL_LEN {
return Err(format!(
"a serial number is at most {MAX_SERIAL_LEN} characters"
));
}
if serial.trim() != serial {
return Err("a serial number can't start or end with a space".into());
}
if serial.chars().any(char::is_control) {
return Err("a serial number can't contain control characters".into());
}
Ok(())
}
/// The key tags are compared by: tags are unique ignoring case
/// ("fl-0001" is "FL-0001").
pub fn tag_key(tag: &str) -> String {
tag.to_ascii_uppercase()
}
/// Parameters for registering an asset. A struct (not eight arguments)