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:
1 parent
4e96b5f54d
commit
cd085108c1
53 files changed
+7872
-104
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user