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
check / check (push) Canceled after 0s

- 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 767fd469cd
commit ee7e205f5c
53 files changed
+7872 -104

No files matched your search

+14 -1
View File
@@ -5,7 +5,7 @@
//! is the current version.
/// Current schema version.
pub const SCHEMA_VERSION: u32 = 8;
pub const SCHEMA_VERSION: u32 = 9;
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
pub const MIGRATIONS: &[&str] = &[
@@ -207,5 +207,18 @@ ALTER TABLE attachments ADD COLUMN mime_type TEXT;
UPDATE attachments SET
file_name = (SELECT original_name FROM blobs WHERE sha256 = attachments.blob_sha),
mime_type = (SELECT mime_type FROM blobs WHERE sha256 = attachments.blob_sha);
"#,
r#"
-- v9: asset tags, serial numbers, and each asset's acquisition entry. assets.tag is the current tag;
-- asset_tags_log keeps every tagging in journal order, because a tag is
-- never reused by another asset even after a retag.
ALTER TABLE assets ADD COLUMN tag TEXT;
ALTER TABLE assets ADD COLUMN serial TEXT;
ALTER TABLE assets ADD COLUMN acquisition_entry INTEGER;
CREATE TABLE IF NOT EXISTS asset_tags_log(
seq INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL,
tag TEXT NOT NULL
);
"#,
];
+113
View File
@@ -711,6 +711,51 @@ impl Store {
Ok(live)
}
/// Give an asset a tag (see `Ledger::set_asset_tag`). Journaled as
/// `asset.tagged`, with the tag it had.
pub fn set_asset_tag(&mut self, id: AssetId, tag: &str) -> Result<(), StoreError> {
self.ledger.check_asset_tag_for(Some(id), tag)?;
let previous = self.ledger.get_asset(id).and_then(|a| a.tag.clone());
let line = self.append_journal(
"asset.tagged",
json!({"asset_id": id, "tag": tag, "previous_tag": previous}),
)?;
self.ledger.set_asset_tag(id, tag)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Link an asset to the entry that put it on the books (see
/// `Ledger::set_asset_acquisition`). Journaled as
/// `asset.acquisition_linked`.
pub fn set_asset_acquisition(&mut self, id: AssetId, entry: EntryId) -> Result<(), StoreError> {
self.ledger.check_asset_acquisition(id, entry)?;
let line = self.append_journal(
"asset.acquisition_linked",
json!({"asset_id": id, "entry_id": entry}),
)?;
self.ledger.set_asset_acquisition(id, entry)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Record or clear an asset's serial number (see
/// `Ledger::set_asset_serial`). Journaled as `asset.serial_set`.
pub fn set_asset_serial(
&mut self,
id: AssetId,
serial: Option<&str>,
) -> Result<(), StoreError> {
self.ledger.check_asset_serial(id, serial)?;
let line = self.append_journal(
"asset.serial_set",
json!({"asset_id": id, "serial": serial}),
)?;
self.ledger.set_asset_serial(id, serial)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Was depreciation already posted for this asset and period?
fn depreciation_posted_entry(
&self,
@@ -2812,6 +2857,32 @@ impl Store {
)?;
}
}
// Tags in the order they were given (a retag keeps the old tag
// reserved), then serial numbers.
let mut tstmt = self
.conn
.prepare("SELECT asset_id, tag FROM asset_tags_log ORDER BY seq")?;
let tags = tstmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
for t in tags {
let (id, tag) = t?;
ledger.set_asset_tag(id as u64, &tag)?;
}
let mut sstmt = self
.conn
.prepare("SELECT id, serial FROM assets WHERE serial IS NOT NULL ORDER BY id")?;
let serials = sstmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
for row in serials {
let (id, serial) = row?;
ledger.set_asset_serial(id as u64, Some(&serial))?;
}
let mut qstmt = self.conn.prepare(
"SELECT id, acquisition_entry FROM assets WHERE acquisition_entry IS NOT NULL ORDER BY id",
)?;
let links = qstmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
for row in links {
let (id, entry) = row?;
ledger.set_asset_acquisition(id as u64, entry as u64)?;
}
// Payroll runs: id order; lines in insertion order. Amounts are
// TEXT in SQLite, so they parse here with exact Decimal math.
let parse_payroll_amount = |s: String| {
@@ -3356,6 +3427,22 @@ pub(crate) fn replay_line_into_ledger(
)));
}
}
"asset.tagged" => {
ledger.set_asset_tag(get_u64(p, "asset_id")?, get_str(p, "tag")?)?;
}
"asset.acquisition_linked" => {
ledger.set_asset_acquisition(get_u64(p, "asset_id")?, get_u64(p, "entry_id")?)?;
}
"asset.serial_set" => {
let serial =
match p.get("serial") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_str().ok_or_else(|| {
StoreError::JournalCorrupt("asset.serial_set serial".into())
})?),
};
ledger.set_asset_serial(get_u64(p, "asset_id")?, serial)?;
}
"asset.depreciation_posted" => {
// The entry itself was journaled as entry.created/entry.posted;
// this line is idempotency bookkeeping for the projection.
@@ -3809,6 +3896,32 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
// Roles live in the ledger (rebuilt by replay); the events
// mirror below keeps the history.
}
"asset.tagged" => {
let (id, tag) = (get_u64(p, "asset_id")? as i64, get_str(p, "tag")?);
conn.execute("UPDATE assets SET tag = ? WHERE id = ?", params![tag, id])?;
conn.execute(
"INSERT INTO asset_tags_log(asset_id, tag) VALUES (?, ?)",
params![id, tag],
)?;
}
"asset.acquisition_linked" => {
conn.execute(
"UPDATE assets SET acquisition_entry = ? WHERE id = ?",
params![
get_u64(p, "entry_id")? as i64,
get_u64(p, "asset_id")? as i64
],
)?;
}
"asset.serial_set" => {
conn.execute(
"UPDATE assets SET serial = ? WHERE id = ?",
params![
p.get("serial").and_then(|v| v.as_str()),
get_u64(p, "asset_id")? as i64
],
)?;
}
"receipt.attached" | "document.filed" => {
conn.execute(
"INSERT OR IGNORE INTO blobs(sha256, size_bytes, mime_type, original_name, created_at) VALUES (?,?,?,?,?)",
+7 -2
View File
@@ -16,7 +16,7 @@ use std::path::{Path, PathBuf};
#[serde(tag = "show", rename_all = "snake_case")]
pub enum ViewerTarget {
/// A top-level page: overview, cash, profit, transactions, reports,
/// accounts, documents, company, journal.
/// accounts, assets, documents, company, journal.
Page {
/// Page name.
page: String,
@@ -39,7 +39,7 @@ pub enum ViewerTarget {
Report {
/// profit_and_loss, balance_sheet, cash_flow, members_equity,
/// trial_balance, general_ledger, tax_summary, vendor_1099,
/// ar_aging, ap_aging.
/// ar_aging, ap_aging, fixed_asset_register.
report: String,
/// First date, YYYY-MM-DD.
start: Option<String>,
@@ -57,6 +57,11 @@ pub enum ViewerTarget {
/// Attachment id.
attachment_id: u64,
},
/// One fixed asset: its identity, schedule, entries, and documents.
Asset {
/// Asset id.
asset_id: u64,
},
/// The transactions list, searched.
Search {
/// Search text: description, account, vendor, amount.