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

+68 -9
View File
@@ -23,6 +23,8 @@ pub struct Event {
pub icon: &'static str,
pub text: String,
pub route: Option<Route>,
/// A second place it's about (the asset of a depreciation entry).
pub also: Option<Route>,
}
fn s<'a>(p: &'a Value, k: &str) -> &'a str {
@@ -58,7 +60,32 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
let id = p.get("id").and_then(|v| v.as_u64()).unwrap_or(0);
let entry_id = p.get("entry_id").and_then(|v| v.as_u64());
let att = p.get("attachment_id").and_then(|v| v.as_u64()).unwrap_or(0);
let ev = |icon, text: String, route: Option<Route>| Some(Event { icon, text, route });
let ev = |icon, text: String, route: Option<Route>| {
Some(Event {
icon,
text,
route,
also: None,
})
};
let asset_id = p.get("asset_id").and_then(|v| v.as_u64());
let asset = |id: Option<u64>| {
id.map(|a| {
b.ledger
.get_asset(a)
.map(|x| x.description.clone())
.unwrap_or_else(|| format!("asset {a}"))
})
.unwrap_or_default()
};
let with_asset = |icon, text: String| {
Some(Event {
icon,
text,
route: entry_id.map(Route::Entry),
also: asset_id.map(Route::Asset),
})
};
match line.line_type.as_str() {
"entry.created" | "entry.line_added" | "entry.line_class_set" => None,
"store.created" => ev(icon::BOOK_OPEN, "Books created".into(), None),
@@ -223,17 +250,45 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
"asset.registered" => ev(
icon::PACKAGE,
format!("Registered asset {}", s(p, "description")),
Some(Route::Company),
Some(Route::Asset(id)),
),
"asset.depreciation_posted" => ev(
"asset.tagged" => {
let tag = s(p, "tag");
let text = match p.get("previous_tag").and_then(|v| v.as_str()) {
Some(old) => format!("Retagged {} from {old} to {tag}", asset(asset_id)),
None => format!("Tagged {} {tag}", asset(asset_id)),
};
ev(icon::TAG, text, asset_id.map(Route::Asset))
}
"asset.serial_set" => {
let text = match p.get("serial").and_then(|v| v.as_str()) {
Some(serial) => format!("Serial number of {} set to {serial}", asset(asset_id)),
None => format!("Serial number of {} cleared", asset(asset_id)),
};
ev(icon::BARCODE, text, asset_id.map(Route::Asset))
}
"asset.depreciation_posted" => with_asset(
icon::TREND_DOWN,
"Depreciation posted".into(),
entry_id.map(Route::Entry),
format!(
"Depreciation posted: {} {} ({})",
asset(asset_id),
s(p, "period"),
amount(p)
),
),
"asset.disposed" => ev(
"asset.disposed" => with_asset(
icon::TRASH,
"Asset disposed".into(),
entry_id.map(Route::Entry),
format!(
"Disposed {} on {} for {}",
asset(asset_id),
s(p, "date"),
p.get("proceeds")
.and_then(|v| v.get("amount"))
.and_then(|v| v.as_str())
.and_then(|a| a.parse().ok())
.map(fmt::dollars)
.unwrap_or_default()
),
),
"schedule.registered" => ev(
icon::CALENDAR,
@@ -310,6 +365,7 @@ pub fn describe_any(b: &Books, line: &JournalLine) -> Event {
icon: icon::PENCIL_SIMPLE,
text: format!("Drafted #{id} {}", s(p, "description")),
route: Some(Route::Entry(id)),
also: None,
},
"entry.line_added" => {
let amount = p
@@ -332,6 +388,7 @@ pub fn describe_any(b: &Books, line: &JournalLine) -> Event {
),
),
route: Some(Route::Entry(id)),
also: None,
}
}
"entry.line_class_set" => {
@@ -350,12 +407,14 @@ pub fn describe_any(b: &Books, line: &JournalLine) -> Event {
.map_or(0, |i| i + 1)
),
route: Some(Route::Entry(id)),
also: None,
}
}
t => Event {
icon: icon::DOT,
text: t.to_string(),
route: None,
also: None,
},
}
}
@@ -510,7 +569,7 @@ fn detail(ui: &mut Ui, b: &Books, line: &JournalLine, shell: &mut Shell<'_>) {
fact(ui, "Previous", &line.prev);
let own = ledger_store::journal::sha256_hex(line.to_canonical().as_bytes());
fact(ui, "This hash", &own);
if let Some(r) = &ev.route {
for r in ev.route.iter().chain(&ev.also) {
ui.add_space(6.0);
if ui
.button(format!(