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
@@ -7,11 +7,14 @@ use chrono::{Datelike, NaiveDate};
|
||||
use ledger_config::BooksSettings;
|
||||
use ledger_core::analysis::{balance_history, balance_series};
|
||||
use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period};
|
||||
use ledger_core::{AccountId, AccountType, EntryId, EntryStatus, Ledger, Side};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, AssetId, AssetRegister, AssetStatus, EntryId, EntryStatus, Ledger,
|
||||
Side, fixed_asset_register,
|
||||
};
|
||||
use ledger_store::{Attachment, JournalLine, Snapshot};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::Value;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// One entry, indexed for listing and detail.
|
||||
@@ -45,6 +48,63 @@ pub struct EntryInfo {
|
||||
pub haystack: String,
|
||||
}
|
||||
|
||||
/// One fixed asset's trail through the journal and the entries.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct AssetTrail {
|
||||
/// Depreciation the books carry, by month ("YYYY-MM"): the entry and
|
||||
/// its amount, from `asset.depreciation_posted`.
|
||||
pub depreciation: BTreeMap<String, (EntryId, Decimal)>,
|
||||
/// The disposal entry, from `asset.disposed`.
|
||||
pub disposal_entry: Option<EntryId>,
|
||||
/// The entry that put it on the books, when one can be told: posted,
|
||||
/// debiting its cost account exactly its cost, dated its in-service
|
||||
/// date (else the nearest such entry within 31 days), and not already
|
||||
/// claimed by an earlier asset.
|
||||
pub purchase_entry: Option<EntryId>,
|
||||
/// Indexes into [`Books::journal`] of every `asset.*` event about it.
|
||||
pub events: Vec<usize>,
|
||||
}
|
||||
|
||||
impl AssetTrail {
|
||||
/// Every entry that touches the asset: purchase, depreciation (by
|
||||
/// month), disposal.
|
||||
pub fn entries(&self) -> Vec<EntryId> {
|
||||
let mut out: Vec<EntryId> = self.purchase_entry.into_iter().collect();
|
||||
out.extend(self.depreciation.values().map(|(e, _)| *e));
|
||||
out.extend(self.disposal_entry);
|
||||
out
|
||||
}
|
||||
|
||||
/// Depreciation the books carry for it, summed exactly.
|
||||
pub fn posted(&self) -> Decimal {
|
||||
self.depreciation.values().map(|(_, a)| *a).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Depreciation due and not yet in the books.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: say when monthly depreciation hasn't been run, without crying
|
||||
/// wolf over the month still under way.
|
||||
/// SOURCE: docs/spec/rules.md B-15 (monthly straight-line depreciation);
|
||||
/// CONVENTION (a month's depreciation is posted when the month
|
||||
/// ends: ledger-store dates it the month's last day).
|
||||
/// INVARIANTS:
|
||||
/// 1. through is as_of when as_of is a month's last day, else the last
|
||||
/// day of the month before as_of's.
|
||||
/// 2. amount is fixed_asset_register(ledger, through)
|
||||
/// .unposted_depreciation(), exactly.
|
||||
/// 3. periods: the months ("YYYY-MM", ascending, distinct) that some
|
||||
/// asset held on `through` has in its schedule through `through`
|
||||
/// and no depreciation posted for.
|
||||
/// ```
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DepreciationDue {
|
||||
pub through: NaiveDate,
|
||||
pub amount: Decimal,
|
||||
pub periods: Vec<String>,
|
||||
}
|
||||
|
||||
/// Something the books need, with how to fix it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Attention {
|
||||
@@ -90,6 +150,10 @@ pub struct Books {
|
||||
pub attention: Vec<Attention>,
|
||||
pub kpis: Kpis,
|
||||
pub integrity: Integrity,
|
||||
/// Each registered asset's trail, by asset id.
|
||||
pub asset_trails: HashMap<AssetId, AssetTrail>,
|
||||
/// The asset each depreciation, disposal, or purchase entry is about.
|
||||
pub entry_assets: HashMap<EntryId, AssetId>,
|
||||
}
|
||||
|
||||
/// Whether what's on disk is what the journal says.
|
||||
@@ -319,6 +383,13 @@ impl Books {
|
||||
}
|
||||
entries.sort_by(|a, b| b.date.cmp(&a.date).then(b.id.cmp(&a.id)));
|
||||
let entry_index = entries.iter().enumerate().map(|(i, e)| (e.id, i)).collect();
|
||||
let asset_trails = asset_trails(&ledger, &journal);
|
||||
let mut entry_assets = HashMap::new();
|
||||
for (&a, t) in &asset_trails {
|
||||
for e in t.entries() {
|
||||
entry_assets.insert(e, a);
|
||||
}
|
||||
}
|
||||
let mut books = Books {
|
||||
ledger,
|
||||
settings,
|
||||
@@ -340,6 +411,8 @@ impl Books {
|
||||
attention: Vec::new(),
|
||||
kpis: Kpis::default(),
|
||||
integrity,
|
||||
asset_trails,
|
||||
entry_assets,
|
||||
};
|
||||
books.kpis = books.compute_kpis();
|
||||
books.attention = crate::attention::find(&books);
|
||||
@@ -483,6 +556,61 @@ impl Books {
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixed asset register on `as_of`, exactly as ledger-core
|
||||
/// computes it.
|
||||
pub fn asset_register(&self, as_of: NaiveDate) -> AssetRegister {
|
||||
fixed_asset_register(&self.ledger, as_of)
|
||||
}
|
||||
|
||||
/// An asset's trail (empty for an unknown asset).
|
||||
pub fn asset_trail(&self, id: AssetId) -> AssetTrail {
|
||||
self.asset_trails.get(&id).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// "FL-0001 MacBook Pro" (or just the description, untagged).
|
||||
pub fn asset_label(&self, id: AssetId) -> String {
|
||||
match self.ledger.get_asset(id) {
|
||||
Some(a) => match &a.tag {
|
||||
Some(t) => format!("{t} {}", a.description),
|
||||
None => a.description.clone(),
|
||||
},
|
||||
None => format!("asset {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Depreciation due through the last finished month on or before
|
||||
/// `as_of` and not yet posted (see [`DepreciationDue`]).
|
||||
pub fn depreciation_due(&self, as_of: NaiveDate) -> DepreciationDue {
|
||||
let through = if as_of.succ_opt().is_some_and(|d| d.day() == 1) {
|
||||
as_of
|
||||
} else {
|
||||
NaiveDate::from_ymd_opt(as_of.year(), as_of.month(), 1).expect("valid")
|
||||
- chrono::Days::new(1)
|
||||
};
|
||||
let amount = self.asset_register(through).unposted_depreciation();
|
||||
let mut periods = std::collections::BTreeSet::new();
|
||||
for a in self
|
||||
.ledger
|
||||
.registered_assets()
|
||||
.filter(|a| a.purchase_date <= through)
|
||||
{
|
||||
if matches!(a.status, AssetStatus::Disposed { date, .. } if date <= through) {
|
||||
continue;
|
||||
}
|
||||
let posted = self.asset_trails.get(&a.id);
|
||||
for l in ledger_core::depreciation_schedule(a, through) {
|
||||
if !posted.is_some_and(|t| t.depreciation.contains_key(&l.period)) {
|
||||
periods.insert(l.period);
|
||||
}
|
||||
}
|
||||
}
|
||||
DepreciationDue {
|
||||
through,
|
||||
amount,
|
||||
periods: periods.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Blob store path for an attachment.
|
||||
pub fn blob_root(&self) -> PathBuf {
|
||||
self.data_dir.join("blobs")
|
||||
@@ -520,6 +648,92 @@ fn parties(ledger: &Ledger, journal: &[JournalLine]) -> HashMap<EntryId, String>
|
||||
out
|
||||
}
|
||||
|
||||
/// Every registered asset's trail, from the journal (depreciation,
|
||||
/// disposal, its own events) and the entries (the purchase).
|
||||
fn asset_trails(ledger: &Ledger, journal: &[JournalLine]) -> HashMap<AssetId, AssetTrail> {
|
||||
let mut out: HashMap<AssetId, AssetTrail> = ledger
|
||||
.registered_assets()
|
||||
.map(|a| (a.id, AssetTrail::default()))
|
||||
.collect();
|
||||
let u = |p: &Value, k: &str| p.get(k).and_then(|v| v.as_u64());
|
||||
for (i, line) in journal.iter().enumerate() {
|
||||
let p = &line.payload;
|
||||
let asset = match line.line_type.as_str() {
|
||||
"asset.registered" => u(p, "id"),
|
||||
t if t.starts_with("asset.") => u(p, "asset_id"),
|
||||
_ => None,
|
||||
};
|
||||
let Some(t) = asset.and_then(|a| out.get_mut(&a)) else {
|
||||
continue;
|
||||
};
|
||||
t.events.push(i);
|
||||
match line.line_type.as_str() {
|
||||
"asset.depreciation_posted" => {
|
||||
if let (Some(period), Some(entry), Some(amount)) = (
|
||||
p.get("period").and_then(|v| v.as_str()),
|
||||
u(p, "entry_id"),
|
||||
p.get("amount")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<Decimal>().ok()),
|
||||
) {
|
||||
t.depreciation.insert(period.to_string(), (entry, amount));
|
||||
}
|
||||
}
|
||||
"asset.disposed" => t.disposal_entry = u(p, "entry_id"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// The purchase: an entry that debits the cost account exactly the cost,
|
||||
// on the in-service date or as near it as can be (within 31 days).
|
||||
// A linked acquisition is the purchase; otherwise match it.
|
||||
for a in ledger.registered_assets() {
|
||||
if let (Some(id), Some(t)) = (a.acquired_by, out.get_mut(&a.id)) {
|
||||
t.purchase_entry = Some(id);
|
||||
}
|
||||
}
|
||||
let reversed: HashSet<EntryId> = (1..ledger.next_entry_id())
|
||||
.filter_map(|id| ledger.get_entry(id).and_then(|e| e.reversal_of()))
|
||||
.collect();
|
||||
let mut claimed: HashSet<EntryId> = out.values().flat_map(|t| t.entries()).collect();
|
||||
for a in ledger
|
||||
.registered_assets()
|
||||
.filter(|a| a.acquired_by.is_none())
|
||||
{
|
||||
let mut best: Option<(i64, EntryId)> = None;
|
||||
for id in 1..ledger.next_entry_id() {
|
||||
let Some(e) = ledger.get_entry(id) else {
|
||||
continue;
|
||||
};
|
||||
if e.status() != EntryStatus::Posted
|
||||
|| e.reversal_of().is_some()
|
||||
|| reversed.contains(&id)
|
||||
|| claimed.contains(&id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let days = (e.date() - a.purchase_date).num_days().abs();
|
||||
if days > 31 {
|
||||
continue;
|
||||
}
|
||||
let buys = e.lines().iter().any(|l| {
|
||||
l.account == a.cost_account
|
||||
&& l.side == Side::Debit
|
||||
&& l.amount.amount == a.cost.amount
|
||||
});
|
||||
if buys && best.is_none_or(|(d, _)| days < d) {
|
||||
best = Some((days, id));
|
||||
}
|
||||
}
|
||||
if let Some((_, id)) = best {
|
||||
claimed.insert(id);
|
||||
if let Some(t) = out.get_mut(&a.id) {
|
||||
t.purchase_entry = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Entry ids a journal line refers to.
|
||||
pub fn entry_refs(line: &JournalLine) -> Vec<EntryId> {
|
||||
let p = &line.payload;
|
||||
|
||||
Reference in new issue
Block a user