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
@@ -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(),
|
||||
)
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user