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

@@ -0,0 +1,149 @@
// The fixed asset register: every asset, its cost, depreciation, and book
// value, then the totals and the reconciliation to the general ledger.
//
// Everything arrives as data in sys.inputs.data (JSON), laid out by
// ledger-import::asset_register, with amounts already split into exact
// parts. The template only typesets: no arithmetic, and no text is ever
// parsed as markup.
//
// Shape: { company, title, period, footer, columns: [10 headings], empty,
// lines: [{ tag, description, serial, in_service, cost, salvage,
// life, accumulated, book_value, status, disposed }],
// totals: [cost, accumulated, book_value],
// reconciliation: [{ label, amount, difference }] }
// where every amount is { digits: "1,234.56", negative }.
#let d = json(bytes(sys.inputs.data))
#let ink = luma(25)
#let quiet = luma(110)
#let hair = 0.5pt + luma(160)
#let firm = 0.6pt + ink
#set document(title: d.company + " — " + d.title, author: d.company)
#set text(font: "Inter", size: 8pt, fill: ink, number-width: "tabular", hyphenate: false)
#set page(
paper: "us-letter",
flipped: true,
margin: (x: 0.6in, top: 0.7in, bottom: 0.7in),
header: context {
if counter(page).get().first() > 1 {
set text(7.5pt, fill: quiet)
[#d.company · #d.title #h(1fr) #d.period]
}
},
footer: context {
set text(7.5pt, fill: quiet)
grid(
columns: (1fr, auto, 1fr),
[],
d.footer,
align(right)[Page #counter(page).display() of #counter(page).final().first()],
)
},
)
#align(center)[
#text(15pt, fill: luma(45), d.company) \
#v(4pt)
#text(10.5pt, weight: "bold", d.title) \
#v(2pt)
#d.period
]
#v(12pt)
// An amount: negatives in parentheses, the closing one's width reserved on
// every row so decimal points line up; "$" on totals.
#let amount(a, currency: false) = {
let open = if a.negative { [(] } else { [] }
let close = if a.negative { [)] } else { hide[)] }
let sign = if currency [\$] else []
[#open#sign#a.digits#close]
}
// Columns: 4-8 are numbers (right-aligned).
#let numeric = (4, 5, 6, 7, 8)
#let ncols = d.columns.len()
#let full(body, ..args) = table.cell(colspan: ncols, ..args, body)
#table(
// Number columns are fixed widths (headings wrap); text takes the rest.
columns: (auto, 1.4fr, auto, auto, 6.4em, 5.6em, 6em, 7.4em, 6.4em, 1fr),
stroke: none,
inset: (x: 3.5pt, y: 3.4pt),
align: (col, row) => (if col in numeric { right } else { left }) + (if row == 0 { bottom } else { horizon }),
table.header(
..d.columns.map(c => table.cell(
stroke: (top: firm, bottom: hair),
text(6.5pt, weight: "bold", tracking: 0.04em, upper(c)),
)),
),
..if d.lines.len() == 0 {
(full(emph(d.empty)),)
} else {
d
.lines
.map(l => {
let t(s) = if l.disposed { text(fill: quiet, s) } else { s }
let a(x) = t(amount(x))
(
t(l.tag),
t(l.description),
t(l.serial),
t(l.in_service),
a(l.cost),
a(l.salvage),
t(l.life),
a(l.accumulated),
a(l.book_value),
t(l.status),
)
})
.flatten()
},
// Totals of the assets held: rule above, double rule beneath.
..{
let s = (top: firm)
let tot(x) = table.cell(stroke: s, text(weight: "bold", amount(x, currency: true)))
let blank = table.cell(stroke: s, [])
(
table.cell(stroke: s, colspan: 4, text(weight: "bold", "Total, assets held")),
tot(d.totals.at(0)),
blank,
blank,
tot(d.totals.at(1)),
tot(d.totals.at(2)),
blank,
)
},
full(
inset: (x: 0pt, y: 0pt),
stack(spacing: 1.6pt, line(length: 100%, stroke: firm), line(length: 100%, stroke: firm)),
),
)
#v(14pt)
#block(breakable: false, {
text(weight: "semibold", "Reconciliation to the books")
v(2pt)
table(
columns: (auto, 9em),
stroke: none,
inset: (x: 4pt, y: 3.4pt),
align: (left + horizon, right + horizon),
..d
.reconciliation
.map(x => {
if x.difference {
let s = (top: hair)
(
table.cell(stroke: s, text(weight: "bold", x.label)),
table.cell(stroke: s, text(weight: "bold", amount(x.amount, currency: true))),
table.cell(colspan: 2, inset: 2pt, []),
)
} else {
(pad(left: 1.4em, x.label), amount(x.amount))
}
})
.flatten(),
)
})
+91
View File
@@ -0,0 +1,91 @@
// Asset label sheets: one tag per label, with its QR code.
//
// Everything arrives as data in sys.inputs.data (JSON), built by
// ledger-import::labels: the sheet geometry (in points, from
// docs/spec/sources/avery-5160.md), and one entry per sheet position. The
// template only typesets: no label text is ever parsed as markup, and every
// label is drawn inside a clipped box the size of its cell, so nothing can
// print past its edge.
//
// Shape: { company, geometry: { page_width, page_height, label_width,
// label_height, left, top, pitch_x, pitch_y, columns, rows, inset },
// pages: [[null | { tag, description, serial: null | str, qr: svg }]] }
// Each inner list is one sheet, positions in reading order (left to right,
// then top to bottom); null is a position left blank.
#let d = json(bytes(sys.inputs.data))
#let g = d.geometry
#let pt1 = 1pt
#let ink = luma(15)
#let quiet = luma(90)
#set document(title: d.company + " — Asset labels", author: d.company)
#set text(font: "Inter", fill: ink, number-width: "tabular", hyphenate: false)
#set par(leading: 0.28em, spacing: 0pt)
#set page(width: g.page_width * pt1, height: g.page_height * pt1, margin: 0pt)
// The inner box every label's content lives in.
#let inset = g.inset * pt1
#let cell-w = g.label_width * pt1
#let cell-h = g.label_height * pt1
// The QR code (its 4-module quiet zone included) fills the label's height.
#let qr-size = cell-h - 2 * inset
#let gap = 4pt
#let text-w = cell-w - 2 * inset - qr-size - gap
// The largest of `sizes` at which `s` fits in `lines` lines of `width`;
// at the smallest size, the longest prefix that fits, ending in "…".
// Text is set as a string, so it is always literal.
#let fit(s, width, sizes, lines, weight: "regular", fill: ink) = context {
let set-at(size, body) = block(width: width, text(size: size, weight: weight, fill: fill, body))
let limit(size) = measure(set-at(size, ("X\n" * lines).trim())).height + 0.01pt
// Fits: wraps into `lines` lines, and no word is wider than the box
// (an unbreakable word would overflow sideways, not down).
let fits(size, body) = (
measure(set-at(size, body)).height <= limit(size)
and body.split(" ").all(w => measure(text(size: size, weight: weight, w)).width <= width)
)
let chosen = sizes.find(size => fits(size, s))
if chosen != none {
set-at(chosen, s)
} else {
let size = sizes.last()
let cs = s.clusters()
// Binary search for the longest prefix that fits with the ellipsis.
let lo = 0
let hi = cs.len()
while lo < hi {
let mid = calc.quo(lo + hi + 1, 2)
if fits(size, cs.slice(0, mid).join().trim() + "…") { lo = mid } else { hi = mid - 1 }
}
let prefix = if lo == 0 { "" } else { cs.slice(0, lo).join().trim() }
set-at(size, prefix + "…")
}
}
#let label(l) = box(width: cell-w, height: cell-h, clip: true, inset: inset, {
grid(
columns: (qr-size, gap, text-w),
rows: (qr-size,),
image(bytes(l.qr), format: "svg", width: qr-size, height: qr-size),
[],
align(horizon, stack(
spacing: 2.2pt,
fit(d.company, text-w, (6pt, 5.5pt, 5pt), 1, fill: quiet),
fit(l.tag, text-w, (15pt, 13pt, 11pt, 9pt, 8pt, 7pt, 6pt), 1, weight: "bold"),
fit(l.description, text-w, (7.5pt, 7pt, 6.5pt, 6pt), 2),
if l.serial != none { fit("S/N " + l.serial, text-w, (6.5pt, 6pt, 5.5pt, 5pt), 1, fill: quiet) },
)),
)
})
#for (i, sheet) in d.pages.enumerate() {
if i > 0 { pagebreak() }
for (p, l) in sheet.enumerate() {
if l != none {
let col = calc.rem(p, g.columns)
let row = calc.quo(p, g.columns)
place(top + left, dx: (g.left + col * g.pitch_x) * pt1, dy: (g.top + row * g.pitch_y) * pt1, label(l))
}
}
}