4 Commits

Author SHA1 Message Date
8b53169f14 docs: screenshots of the Prime Cost Report against restored production data
Rendered from the restored backup, so the figures are a real client's
rather than fixtures — including the weekly view's em dashes, which are
what a fortnightly pay cycle actually looks like through weekly columns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:50:58 -07:00
250bf0ea7f feat(ledger): make the Prime Cost Report readable against real ledger data
Rendering the report against a restored production database turned up
three things the tests could not have told me, and one they should have.

**Payroll posts on a pay run, not on a week.** A journal entry carries
the date it was posted, so a client paying every fortnight puts a
fortnight of labour on a single day. Against weekly columns the trend
read `—, 56.7%, —, 52.5%, -19.5%`, which is noise. Against fortnightly
columns the same client reads 18% to 27%. Hence the period control:
weekly, bi-weekly or four weeks, set to whatever the client's pay cycle
actually is.

**Zero is a claim, and it was the wrong one.** A period with no payroll
posted used to print 0.0% labour. No restaurant runs a week on no
labour; the pay run simply has not landed yet. That now reads as an em
dash, the trend line joins across the gap rather than diving to the
floor, and the footnote says what to do about it.

**A detail line printed larger than its own subtotal.** Averages
dropped empty periods from the denominator, so a band posting every
second period averaged over four periods while the total it rolled into
averaged over eight — $23,923 of "Payroll - General" inside $12,035 of
"Payroll". Money now averages across every period and ratios still
average only the periods they are known for, which is the distinction
that was missing.

The one the tests should have caught: the shared cell renderer calls
`dollars-0?` on a value before nil-punning it, so any nil cell was an
NPE at render time rather than a blank. Guarded, along with its
`:percent` branch, which also printed `%27.2` — sign on the wrong side.
Nothing else in the codebase reaches that branch.

Also here, in service of it being read rather than merely correct: a
summary strip with the latest labour percentage, what it usually runs
at, the change between them and a trend sparkline; a % of sales column
beside the average, as the workbook has; detail lines indented under
their subtotals; and an opt-in `height` on the shared table so a short
report stops reserving 70vh it does not use.

11 tests, 41 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:50:00 -07:00
359508ea9f feat(ledger): add a Prime Cost Report alongside the other ledger reports
Prime cost is what a restaurant is actually run on: what it sold against
what it paid people to sell it. Neither figure means much alone and the
ratio only reads as a trend, so this shows eight weeks rather than one
range — the same shape as the PCR Detail sheet of the Bi-Weekly workbook
it replaces.

The two halves come from deliberately different places. Payroll is read
from the ledger, because that is where payroll lands once it has been
coded to an account; taken from anywhere else it would not tie back to
the P&L sitting beside it. Sales are read from the daily sales summaries,
which are the reconciled books rather than the raw till feed.

That split is why it lives with the ledger reports despite only half of
it being ledger data. It has to agree with the P&L, so the payroll bands
come from `auto-ap.ledger.reports/groupings` rather than a second list
restated here — an earlier draft did restate them and silently dropped
the 60000-60999 general payroll band on the floor.

Details worth knowing:

- Sales are net: credits add, debits subtract, so discounts and returns
  reduce sales without special casing. Only the 40000 revenue block
  counts, so tax and tip (liabilities held for someone else) and the
  tender lines (the money side of the same transaction) are excluded.
- Payroll is debits less credits, so a correction posted as a credit
  reduces the week rather than counting as more labour.
- Week windows tile with an exclusive end, so no day is counted twice at
  a boundary.
- Sales categories are driven by what is in the data, so a restaurant
  that starts selling something new appears without editing this file.
- A week with no sales yields no ratio rather than dividing by zero.

Gated on the existing :profit-and-loss read permission, since it exposes
the same figures.

7 tests, 27 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:27:18 -07:00
62e96556e5 docs(reports): add a business-facing summary of the balancing work
The existing reconciliation report is written for someone who will run
the rollout and wants the evidence behind every figure. This one is for
someone deciding whether it ships and what it is worth, and assumes no
prior context.

Structured around four business requirements rather than four defects,
and states the operational cost in work rather than only in dollars:
1,191 restaurant-days needing manual investigation over ninety days
becomes 122, of which only 32 are worth more than ten cents.

Same measured figures throughout, three worked days, the duplicated-
restaurant choice and why replaying beat retiring a record, and the three
decisions still outstanding. About a third the length of the detailed
report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:44:25 -07:00
10 changed files with 1231 additions and 69 deletions

View File

@@ -0,0 +1,423 @@
<title>Restaurant Books That Balance</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400;6..72,600&family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap">
<style>
:root {
--paper:#F7F8FA; --card:#FFFFFF; --sunk:#EEF1F6;
--ink:#16171E; --ink-soft:#4C5566; --ink-faint:#7B8497;
--rule:#DDE2EA; --accent:#2F4A7A; --accent-soft:#E7ECF5;
--brass:#8A6A2F; --brass-soft:#F6EFDF;
--good:#1E6B4E; --bad:#A33A2E;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--paper:#12151C; --card:#1A1E28; --sunk:#161A23;
--ink:#E9ECF3; --ink-soft:#A7B0C2; --ink-faint:#79839A;
--rule:#2A303D; --accent:#8FAEE0; --accent-soft:#1D2634;
--brass:#D3AE68; --brass-soft:#262014;
--good:#5EC08F; --bad:#E38878;
}
}
:root[data-theme="dark"] {
--paper:#12151C; --card:#1A1E28; --sunk:#161A23;
--ink:#E9ECF3; --ink-soft:#A7B0C2; --ink-faint:#79839A;
--rule:#2A303D; --accent:#8FAEE0; --accent-soft:#1D2634;
--brass:#D3AE68; --brass-soft:#262014;
--good:#5EC08F; --bad:#E38878;
}
*{box-sizing:border-box}
body{background:var(--paper);color:var(--ink);margin:0;padding:0 20px 100px;
font-family:"IBM Plex Sans",system-ui,-apple-system,sans-serif;font-size:16.5px;line-height:1.62}
.wrap{max-width:900px;margin:0 auto}
.measure{max-width:64ch}
.n{font-family:"IBM Plex Mono",ui-monospace,monospace;font-variant-numeric:tabular-nums}
.mono{font-family:"IBM Plex Mono",ui-monospace,monospace;font-size:.88em}
header{padding:76px 0 34px;border-bottom:1px solid var(--ink)}
.kicker{font-size:11.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);font-weight:600}
h1{font-family:Newsreader,Georgia,serif;font-weight:600;font-size:clamp(36px,6vw,58px);
line-height:1.04;letter-spacing:-.02em;margin:16px 0 0;text-wrap:balance}
.lede{font-size:19px;color:var(--ink-soft);max-width:60ch;margin:16px 0 0}
.facts{display:flex;flex-wrap:wrap;gap:8px 26px;margin-top:22px;font-size:13px;color:var(--ink-faint)}
.facts b{color:var(--ink-soft);font-weight:600}
section{padding-top:60px;display:flex;flex-direction:column;gap:20px}
h2{font-family:Newsreader,Georgia,serif;font-weight:600;font-size:30px;letter-spacing:-.015em;
margin:0;text-wrap:balance}
h3{font-size:12.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--ink-faint);
font-weight:600;margin:0}
p{margin:0}
.measure p+p{margin-top:15px}
.strip{display:grid;grid-template-columns:1fr auto 1fr;background:var(--card);
border:1px solid var(--rule);border-radius:3px;overflow:hidden}
.strip>div{padding:28px 30px;display:flex;flex-direction:column;gap:5px}
.strip .mid{background:var(--accent-soft);border-left:1px solid var(--rule);
border-right:1px solid var(--rule);align-items:center;justify-content:center;color:var(--ink-faint);font-size:20px}
.lbl{font-size:11.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-faint);font-weight:600}
.big{font-family:"IBM Plex Mono",monospace;font-size:clamp(27px,4.2vw,38px);font-weight:600;
letter-spacing:-.02em;line-height:1.05;font-variant-numeric:tabular-nums}
.big.after{color:var(--good)}
.sub{font-size:13.5px;color:var(--ink-soft)}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(165px,1fr));gap:12px}
.card{background:var(--card);border:1px solid var(--rule);border-radius:3px;padding:18px 20px;
display:flex;flex-direction:column;gap:3px}
.card .v{font-family:"IBM Plex Mono",monospace;font-size:28px;font-weight:600;line-height:1;
letter-spacing:-.02em;font-variant-numeric:tabular-nums}
.card .k{font-size:12.5px;color:var(--ink-soft);line-height:1.4}
.card.ok .v{color:var(--good)}
.req{display:grid;grid-template-columns:auto 1fr;gap:14px 18px;align-items:baseline}
.req dt{font-family:"IBM Plex Mono",monospace;font-size:12.5px;font-weight:600;color:var(--accent);
letter-spacing:.04em;white-space:nowrap}
.req dd{margin:0;color:var(--ink-soft)}
.req dd b{color:var(--ink);font-weight:600}
.scroll{overflow-x:auto;border:1px solid var(--rule);border-radius:3px;background:var(--card)}
table{border-collapse:collapse;width:100%;font-size:14.5px}
th,td{padding:11px 16px;text-align:left;border-bottom:1px solid var(--rule);white-space:nowrap}
thead th{font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--ink-faint);
font-weight:600;background:var(--sunk)}
tbody tr:last-child td{border-bottom:none}
td.r,th.r{text-align:right;font-family:"IBM Plex Mono",monospace;font-variant-numeric:tabular-nums}
tr.sum td{font-weight:600;background:var(--accent-soft)}
tr.hl td{background:var(--brass-soft)}
.good{color:var(--good);font-weight:600}
.bad{color:var(--bad);font-weight:600}
.faint{color:var(--ink-faint)}
.note{background:var(--card);border:1px solid var(--rule);border-left:3px solid var(--accent);
border-radius:3px;padding:19px 22px;display:flex;flex-direction:column;gap:9px}
.note.warn{border-left-color:var(--brass)}
.note .h{font-weight:600}
.case{border-left:2px solid var(--rule);padding-left:22px;display:flex;flex-direction:column;gap:12px}
.case h4{margin:0;font-size:16.5px;font-weight:600}
footer{margin-top:80px;padding-top:22px;border-top:1px solid var(--rule);font-size:13px;
color:var(--ink-faint);display:flex;flex-direction:column;gap:7px;max-width:70ch}
a{color:var(--accent)}
a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
</style>
<div class="wrap">
<header>
<div class="kicker">Sales summaries &middot; measured on a copy of live data</div>
<h1>Restaurant Books That Balance</h1>
<p class="lede">Every trading day, each restaurant's books should show the money taken and the
money earned agreeing to the penny. On roughly one day in sixteen they did not. This is what was
wrong, what it was costing, and what the fix is worth &mdash; measured by running the real job over
ninety days of real trading.</p>
<div class="facts">
<span><b>Period</b> 10 May &ndash; 7 Aug 2026</span>
<span><b>Restaurants</b> <span class="n">210</span></span>
<span><b>Restaurant-days</b> <span class="n">18,900</span></span>
<span><b>Nothing live was changed</b></span>
</div>
</header>
<section>
<div class="strip">
<div>
<span class="lbl">Books as they stand</span>
<span class="big n">$70,276.50</span>
<span class="sub"><span class="n">1,191</span> days that don't balance</span>
</div>
<div class="mid" aria-hidden="true">&rarr;</div>
<div>
<span class="lbl">Same period, after the fix</span>
<span class="big after n">$2,379.45</span>
<span class="sub"><span class="n">122</span> days that don't balance</span>
</div>
</div>
<div class="cards">
<div class="card ok"><span class="v">1,069</span><span class="k">days brought into balance</span></div>
<div class="card ok"><span class="v">0</span><span class="k">days broken by the change</span></div>
<div class="card ok"><span class="v">0</span><span class="k">correct days altered</span></div>
<div class="card"><span class="v">96.6%</span><span class="k">of the discrepancy removed</span></div>
</div>
<div class="measure">
<p>Put plainly: <b>on nine days out of ten that were wrong, the books now close.</b> Of the 122
days still open, only 32 are worth more than ten cents &mdash; the rest are till rounding.</p>
</div>
</section>
<section>
<h2>What an unbalanced day costs</h2>
<div class="measure">
<p>A day that does not balance cannot be signed off. Someone has to open it, compare it against
the till, and work out which figure is missing &mdash; and until they do, that restaurant's
period is not closed.</p>
<p>Across these ninety days that was <b>1,191 restaurant-days needing investigation &mdash; about
thirteen every single day.</b> After the fix it is 122 across the whole period, and only 32 of
those are worth more than ten cents: roughly <b>one day every third day</b> that genuinely needs
a human.</p>
<p>The second cost is subtler and worse. Where the two records of a duplicated restaurant traded
figures between them, the same day could reconcile differently depending on when you looked. That
is not a number anyone can defend in a review, and it is the reason R2 is a requirement rather
than a nicety.</p>
</div>
<div class="cards">
<div class="card"><span class="v">~13</span><span class="k">restaurant-days a day needing investigation, before</span></div>
<div class="card ok"><span class="v">~1.4</span><span class="k">restaurant-days a day, after</span></div>
<div class="card ok"><span class="v">90%</span><span class="k">less manual reconciliation</span></div>
</div>
</section>
<section>
<h2>What the business needs from this</h2>
<div class="measure">
<p>Four requirements. Everything below is judged against them.</p>
</div>
<dl class="req">
<dt>R1</dt>
<dd><b>A day's books must balance.</b> Money taken must equal money earned, to the penny. A day
that doesn't balance can't be signed off, and someone has to work out why by hand.</dd>
<dt>R2</dt>
<dd><b>Each restaurant's records must stay its own.</b> A payment or refund must belong to one
restaurant and stop moving. Figures that change owner between reports can't be reconciled.</dd>
<dt>R3</dt>
<dd><b>Cancelling an order must not delete another restaurant's money.</b> Removing a voided
order should affect that order only.</dd>
<dt>R4</dt>
<dd><b>When the numbers can't be trusted, that must show.</b> A silently plausible figure is
worse than a visibly wrong one.</dd>
</dl>
</section>
<section>
<h2>What was going wrong</h2>
<div class="case">
<h4>Two records for one restaurant, fighting over the same money <span class="faint">&mdash; R2, R3</span></h4>
<p class="measure">Ten restaurants were set up twice in the system, as two separate customer
records, and both were importing from the same till. Because the two records competed for the
same payments and refunds, a refund could belong to one record in the morning and the other by
the afternoon. Over time this had moved <b>3,387 refunds, 4,069 payouts and 2,628 cash-drawer
shifts</b> between records. Nineteen pairs have been affected historically; ten still share a
till today.</p>
<p class="measure">It also meant a single card payment could be attached to both records' copies
of an order. That is the R3 hazard: the nightly job removes orders the till reports as voided,
and removing an order removes its payments &mdash; so cancelling one restaurant's order could
quietly delete the other's payment. In a sample of 20,000 orders, <b>11,469 payments had two
owners.</b></p>
</div>
<div class="case">
<h4>Refunded tips still counted as income <span class="faint">&mdash; R1</span></h4>
<p class="measure">When a guest was refunded, the tip came back too. The summary kept crediting
the original tip, so the day showed income the restaurant no longer had.</p>
</div>
<div class="case">
<h4>Service charges collected but never recorded as earned <span class="faint">&mdash; R1</span></h4>
<p class="measure">A catering fee or auto-gratuity is charged inside the customer's card payment,
so it arrived as money taken &mdash; but nothing recorded it as money earned. Every order
carrying one left the day short by exactly that amount. This was the single largest cause.</p>
</div>
<div class="case">
<h4>Refunds arriving for restaurants whose sales were never imported <span class="faint">&mdash; R4</span></h4>
<p class="measure">A handful of records receive refunds while none of their sales reach the
system. Those days cannot balance, because half the transaction is not there. This one is
<b>deliberately left visible</b> rather than fixed &mdash; see below.</p>
</div>
</section>
<section>
<h2>What the fix is worth</h2>
<div class="measure">
<p>The real nightly job was run over the same ninety days twice &mdash; once as it behaves today,
once with the fixes on &mdash; against the same copy of live data. These are measured outcomes,
not estimates.</p>
</div>
<div class="scroll">
<table>
<thead><tr><th>Stage</th><th class="r">Days not balancing</th><th class="r">Clean</th><th class="r">Discrepancy</th></tr></thead>
<tbody>
<tr><td>Books as they stand</td><td class="r">1,191</td><td class="r">93.70%</td><td class="r">$70,276.50</td></tr>
<tr><td>Counting refunded tips correctly</td><td class="r">890</td><td class="r">95.29%</td><td class="r">$67,032.09</td></tr>
<tr class="sum"><td>Recording service charges as earned</td><td class="r good">122</td><td class="r good">99.35%</td><td class="r good">$2,379.45</td></tr>
</tbody>
</table>
</div>
<div class="scroll">
<table>
<thead><tr><th>Change</th><th class="r">Days fixed</th><th class="r">Days broken</th><th class="r">Correct days altered</th><th class="r">Money involved</th></tr></thead>
<tbody>
<tr><td>Refunded tips</td><td class="r good">301</td><td class="r good">0</td><td class="r good">0</td><td class="r">$4,027.21</td></tr>
<tr><td>Service charges</td><td class="r good">768</td><td class="r good">0</td><td class="r good">0</td><td class="r">$64,752.64</td></tr>
<tr class="sum"><td>Both together</td><td class="r good">1,069</td><td class="r good">0</td><td class="r good">0</td><td class="r">$67,897.05</td></tr>
</tbody>
</table>
</div>
<div class="note">
<span class="h">Nothing that was already right was touched</span>
<p>Of 18,900 restaurant-days, <b>17,827 came out identical</b> &mdash; not just still balancing,
but line for line the same figures. Every day that changed was already wrong. This was checked at
the level of individual lines (category, amount to the cent, and which account it posts to), not
just each day's bottom line, because a day can keep balancing while the figures inside it move.</p>
</div>
</section>
<section>
<h2>What it looks like on a real day</h2>
<div class="measure">
<p>Three days from the period. In each case the correction equals the discrepancy exactly &mdash;
which is what you would expect if the fix is recording something real that was recorded nowhere.</p>
</div>
<div class="scroll">
<table>
<thead><tr><th>A catering fee &mdash; NGPA, 4 June</th><th class="r">Before</th><th class="r">After</th></tr></thead>
<tbody>
<tr class="hl"><td><b>Service charges</b></td><td class="r bad">not recorded</td><td class="r">1,344.86</td></tr>
<tr><td class="faint">Card payments</td><td class="r faint">16,914.26</td><td class="r faint">16,914.26</td></tr>
<tr class="sum"><td>Day out by</td><td class="r bad">1,344.86</td><td class="r good">0.00</td></tr>
</tbody>
</table>
</div>
<p class="measure">The guests paid the catering fee inside their card payments. Nothing in the
books said the restaurant had earned it, so the day was short by exactly the fee.</p>
<div class="scroll">
<table>
<thead><tr><th>A refunded tip &mdash; NGFL, 19 May</th><th class="r">Before</th><th class="r">After</th></tr></thead>
<tbody>
<tr class="hl"><td><b>Tips</b></td><td class="r">238.46</td><td class="r">70.42</td></tr>
<tr><td class="faint">Card refunds</td><td class="r faint">168.04</td><td class="r faint">168.04</td></tr>
<tr class="sum"><td>Day out by</td><td class="r bad">&minus;168.04</td><td class="r good">0.00</td></tr>
</tbody>
</table>
</div>
<p class="measure">Guests were refunded $168.04, tips included. The books kept crediting the full
original tip. The corrected figure matches the refund to the penny.</p>
<div class="scroll">
<table>
<thead><tr><th>One restaurant, two records &mdash; 11 May</th><th class="r">Record A</th><th class="r">Record B</th></tr></thead>
<tbody>
<tr><td>Orders that day</td><td class="r">221</td><td class="r">221</td></tr>
<tr class="hl"><td><b>Refunds that day</b></td><td class="r bad">none</td><td class="r">2 &mdash; $2,232.29</td></tr>
<tr class="sum"><td>Day out by</td><td class="r bad">2,232.29</td><td class="r good">0.00</td></tr>
</tbody>
</table>
</div>
<p class="measure">The same restaurant, twice in the system. Both records held the same 221 orders,
but the refunds existed only once. The record without them showed the sales being returned and no
refunds against them, and was out by exactly what its twin was holding. Across its whole history
that record held <b>158,535 orders and five refunds.</b></p>
</section>
<section>
<h2>Making the duplicated restaurants whole</h2>
<div class="measure">
<p>The duplicated restaurants needed more than the arithmetic fixes, and this is the part with a
real choice in it.</p>
<p>Orders were always recorded per customer record, so both copies of a restaurant built their
own order history. Refunds and payouts were not &mdash; only one record ever held each of them.
So one copy shows returns with no refunds against them, and the other looks fine.</p>
<p><b>Rather than invent duplicate records, the fix asks the till again.</b> Once each record
carries its own identity, replaying ninety days of history means each one imports its own copy
and the two sets of books converge on their own &mdash; no code inventing figures that someone
then has to trust.</p>
</div>
<div class="cards">
<div class="card"><span class="v">423</span><span class="k">days out of balance on these records, before</span></div>
<div class="card ok"><span class="v">3</span><span class="k">days out of balance, after</span></div>
<div class="card"><span class="v">$18,508</span><span class="k">discrepancy before</span></div>
<div class="card ok"><span class="v">$649</span><span class="k">discrepancy after</span></div>
</div>
<div class="measure">
<p>The alternative was to retire one record of each pair. That was measured too, and it is worse:
it leaves <b>279 days and $7,790.54</b> open, and it forces a decision about whose trading history
to abandon. Replaying instead leaves 122 days and $2,379.45, and no record is lost.</p>
</div>
<div class="note warn">
<span class="h">A capped import, found while doing this</span>
<p>The refund import asked the till for a restaurant's refunds and read only the first page of
the answer. The till sends a hundred at a time, so any location with more than a hundred refunds
silently returned a hundred &mdash; and the response looked complete. <b>This was quietly
truncating the nightly refund import for every busy location, not only the duplicated ones.</b>
Now fixed.</p>
</div>
</section>
<section>
<h2>What is left, and what it is</h2>
<div class="scroll">
<table>
<thead><tr><th>Remaining discrepancy</th><th class="r">Days</th><th class="r">Amount</th></tr></thead>
<tbody>
<tr><td>Real trading days &mdash; small, mostly rounding</td><td class="r">106</td><td class="r">$1,151.80</td></tr>
<tr class="sum"><td>Refunds for restaurants whose sales never arrive</td><td class="r">16</td><td class="r">$1,227.65</td></tr>
</tbody>
</table>
</div>
<div class="measure">
<p>Of the 106 trading days, only <b>three</b> are on the duplicated restaurants, and all three are
understood: two are a day where the till recorded $6,358.99 of card payments against $6,059.57 of
orders &mdash; a genuine gap in the till's own figures, not a fault in the books &mdash; and one
is an auto-gratuity booked as a service charge. The remaining 103 days come to <b>$502.96 spread
across 190 restaurants</b>, mostly a few cents each.</p>
<p><b>That $502.96 has been identical every time this was measured</b>, across several rebuilds
with deliberately different preparation. It is the floor this work reaches.</p>
<p>The 16 remaining days belong to two restaurants &mdash; <span class="mono">NG4S</span> and
<span class="mono">NGPS</span> &mdash; that receive refunds while none of their sales are
imported. Their books cannot close until the sales arrive. <b>These are deliberately left visibly
out of balance (R4).</b> The arithmetic could be made to agree in one line, and that change was
written, measured and then removed: an unbalanced day is the only signal anyone gets that a
restaurant's sales are missing. Closing it would remove the alarm and leave the fire.</p>
</div>
</section>
<section>
<h2>What rollout costs, and what it risks</h2>
<div class="scroll">
<table>
<thead><tr><th>Step</th><th class="r">Cost</th><th>Risk</th></tr></thead>
<tbody>
<tr><td>Deploy the change</td><td class="r">minutes</td><td>None on its own &mdash; the two accounting changes are off per restaurant by default</td></tr>
<tr><td>Re-label existing records</td><td class="r">~38 min</td><td>Runs with imports paused; newest month first, so stopping early is safe</td></tr>
<tr><td>Replay 90 days for the 10 duplicated restaurants</td><td class="r">~5.9 hrs</td><td>Overnight job; reads from the till only</td></tr>
<tr><td>Recompute the books, then switch on per restaurant</td><td class="r">~25 min</td><td>Reversible &mdash; switching a restaurant back off restores today's behaviour exactly</td></tr>
</tbody>
</table>
</div>
<div class="measure">
<p>The whole sequence is one overnight maintenance window. No customer record is retired and no
trading history is discarded. The accounting changes go on a few restaurants at a time, and
switching one back off is the rollback.</p>
</div>
</section>
<section>
<h2>Three decisions needed</h2>
<dl class="req">
<dt>Accounting</dt>
<dd><b>Which revenue account service charges post to.</b> Currently <span class="mono">49000
Service Income</span>, chosen so the work could be measured. It affects how revenue is reported,
never whether a day balances. Needs sign-off before switching any restaurant on.</dd>
<dt>Operations</dt>
<dd><b>Why <span class="mono">NG4S</span> and <span class="mono">NGPS</span> receive refunds but
no sales.</b> 160 refunds worth $4,347.68 predate any order for these restaurants. Replaying
their history from the till is the cheap first thing to try &mdash; it is what resolved seven
similar cases.</dd>
<dt>Reporting</dt>
<dd><b>Ten restaurants remain in the system twice, by design.</b> Each copy now keeps complete,
correct books. If any report adds figures across customer records, one restaurant's takings would
be counted twice at that layer. Worth confirming before this ships.</dd>
</dl>
</section>
<footer>
<span>Measured against a restored copy of live data as at 14 Aug 2026, over 10 May &ndash; 7 Aug
2026: 210 restaurants, 18,900 restaurant-days. Nothing in the live system was read or written.</span>
<span>A day counts as out of balance when money taken minus money earned differs by half a penny or
more. Figures were captured directly after each run rather than reconstructed afterwards.</span>
</footer>
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -95,7 +95,7 @@
"sales"
(#{::payment-routes/all-page ::payment-routes/pending-page ::payment-routes/cleared-page ::payment-routes/voided-page} (:matched-route request))
"payments"
(#{::ledger-routes/all-page ::ledger-routes/external-page ::ledger-routes/external-import-page ::ledger-routes/balance-sheet ::ledger-routes/cash-flows ::ledger-routes/profit-and-loss} (:matched-route request))
(#{::ledger-routes/all-page ::ledger-routes/external-page ::ledger-routes/external-import-page ::ledger-routes/balance-sheet ::ledger-routes/cash-flows ::ledger-routes/profit-and-loss ::ledger-routes/prime-cost} (:matched-route request))
"ledger"
:else
nil)]
@@ -320,6 +320,11 @@
:profit-and-loss)} "Old profit and loss"))
(menu-button- {:href (bidi/path-for client-routes/routes
:profit-and-loss-detail)} "Profit & Loss Detail")
(menu-button- {:href (bidi/path-for ssr-routes/only-routes
::ledger-routes/prime-cost)
:active? (= ::ledger-routes/prime-cost (:matched-route request))
:hx-boost "true"}
"Prime Cost Report")
(menu-button- {:href (bidi/path-for client-routes/routes
:cash-flows)} "Cash Flows")
(if (is-admin? (:identity request))

View File

@@ -25,6 +25,7 @@
[auto-ap.ssr.ledger.common :as ledger.common]
[auto-ap.ssr.ledger.investigate :as investigate]
[auto-ap.ssr.ledger.new :as new]
[auto-ap.ssr.ledger.prime-cost :as prime-cost]
[auto-ap.ssr.ledger.profit-and-loss :as profit-and-loss]
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
[auto-ap.ssr.svg :as svg]
@@ -737,6 +738,7 @@
(wrap-client-redirect-unauthenticated))))
balance-sheet/key->handler
profit-and-loss/key->handler
prime-cost/key->handler
cash-flows/key->handler
investigate/key->handler
new/key->handler))

View File

@@ -0,0 +1,512 @@
(ns auto-ap.ssr.ledger.prime-cost
"Prime Cost Report — what a restaurant sold against what it paid people to sell it.
Neither figure is interesting alone; the ratio between them is, and it only reads as a trend,
which is why this shows eight periods rather than one range.
The two halves come from deliberately different places. **Payroll is read from the ledger**,
because that is where payroll lands once it has been coded to an account — taken from anywhere
else it would not tie back to the profit and loss sitting beside it. **Sales are read from the
daily sales summaries**, which are the reconciled books rather than the raw till feed.
That split is why this report sits with the ledger reports despite only half of it being ledger
data: it has to agree with the P&L, and it uses `auto-ap.ledger.reports/groupings` for the
payroll bands so that agreement is structural rather than a coincidence that survives until
somebody edits one of the two lists.
Reading payroll from the ledger has one consequence worth stating: a journal entry carries the
date it was posted, not the span it covers, so a client paying every fortnight puts a fortnight
of labour on a single day. Against weekly columns that reads as one enormous week beside one
empty one. Hence the period control — set it to the length of the client's pay cycle and the
labour line lands where the sales it bought are.
Replaces the PCR Detail sheet of the `Bi-Weekly` workbook."
(:require
[auto-ap.datomic :refer [conn]]
[auto-ap.graphql.utils :refer [extract-client-ids]]
[auto-ap.ledger.reports :as l-reports]
[auto-ap.permissions :refer [wrap-must]]
[auto-ap.routes.ledger :as route]
[auto-ap.routes.utils :refer [wrap-client-redirect-unauthenticated]]
[auto-ap.ssr-routes :as ssr-routes]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.form-cursor :as fc]
[auto-ap.ssr.ledger.report-table :refer [table]]
[auto-ap.ssr.ui :refer [base-page]]
[auto-ap.ssr.utils :refer [apply-middleware-to-all-handlers clj-date-schema
html-response wrap-schema-enforce]]
[auto-ap.time :as atime]
[bidi.bidi :as bidi]
[clj-time.coerce :as coerce]
[clj-time.core :as time]
[clojure.string :as str]
[datomic.api :as dc]
[malli.core :as mc]))
(def periods-shown
"Eight, as the workbook this replaces used — about the shortest run in which a labour-percentage
trend is readable through ordinary noise."
8)
(def period-lengths
"Selectable column widths, in weeks. Longer columns exist to match a client's pay cycle; see the
namespace docstring for why that matters."
[{:value "1" :label "Weekly" :weeks 1}
{:value "2" :label "Bi-weekly" :weeks 2}
{:value "4" :label "Four weeks" :weeks 4}])
(def default-period "1")
(defn weeks-per-period [period]
(or (:weeks (first (filter #(= period (:value %)) period-lengths)))
1))
(def payroll-bands
"`[[label from to] ...]` for payroll, taken from the shared ledger groupings rather than restated
here, so this report and the profit and loss can never disagree about what counts as payroll."
(:payroll l-reports/groupings))
(def payroll-range
"The full span the bands cover, used to filter the query before grouping."
{:from (apply min (map second payroll-bands))
:to (inc (apply max (map #(nth % 2) payroll-bands)))})
(defn band-for
"The payroll band a numeric account code falls in, or nil when it is not payroll."
[code]
(some (fn [[label from to]]
(when (and code (>= code from) (<= code to)) label))
payroll-bands))
(defn period-windows
"`[{:starts :ends}]` for the `periods-shown` periods of `weeks` weeks ending at `end-date`, most
recent first.
`:ends` is exclusive, so periods tile and no day is counted twice at a boundary."
([end-date] (period-windows end-date 1))
([end-date weeks]
(let [end (coerce/to-date-time end-date)]
(for [i (range periods-shown)]
{:starts (time/minus end (time/weeks (* weeks (inc i))))
:ends (time/minus end (time/weeks (* weeks i)))}))))
;; ---------------------------------------------------------------------------
;; Sales — the reconciled daily summaries
;; ---------------------------------------------------------------------------
(defn revenue-line?
"Whether a summary line is revenue rather than tender, tax or tip.
Revenue is the 40000 block. Tax and tip are liabilities held for someone else, and the tender
lines are the money side of the same transaction — none of them are sales."
[item]
(let [code (get-in item [:ledger-mapped/account :account/numeric-code])]
(and code (>= code 40000) (< code 50000))))
(defn signed-amount
"A line's contribution to net sales. Credits add and debits subtract, so discounts and returns —
posted as debits against revenue accounts — reduce sales with no special casing."
[item]
(let [amount (or (:ledger-mapped/amount item) 0.0)]
(if (= :ledger-side/debit (get-in item [:ledger-mapped/ledger-side :db/ident]))
(- amount)
amount)))
(defn sales-in-window
"`{:total n :by-category {category n}}` of net sales for `client-ids` over `[starts ends)`."
[db client-ids starts ends]
(let [lines (->> (dc/q '[:find [(pull ?s [{:sales-summary/items
[:sales-summary-item/category
:ledger-mapped/amount
{:ledger-mapped/ledger-side [:db/ident]}
{:ledger-mapped/account [:account/numeric-code]}]}]) ...]
:in $ [?c ...] ?start ?end
:where
[?s :sales-summary/client ?c]
[?s :sales-summary/date ?d]
[(>= ?d ?start)]
[(< ?d ?end)]]
db client-ids (coerce/to-date starts) (coerce/to-date ends))
(mapcat :sales-summary/items)
(filter revenue-line?))]
{:total (reduce + 0.0 (map signed-amount lines))
:by-category (reduce (fn [acc item]
(update acc (:sales-summary-item/category item)
(fnil + 0.0) (signed-amount item)))
{} lines)}))
;; ---------------------------------------------------------------------------
;; Payroll — the ledger, where coded payroll lands
;; ---------------------------------------------------------------------------
(defn payroll-in-window
"`{:total n :by-band {band-label n} :posted? bool}` of payroll cost for `client-ids` over
`[starts ends)`.
Cost is debits less credits, so a payroll correction posted as a credit reduces the period rather
than counting as more labour. `:posted?` records whether any payroll line existed at all, which
is not the same question as whether the cost came to zero."
[db client-ids starts ends]
(let [{:keys [from to]} payroll-range
rows (dc/q '[:find ?code ?debit ?credit
:with ?jel
:in $ [?c ...] ?start ?end ?from ?to
:where
[?jel :journal-entry-line/client ?c]
[?jel :journal-entry-line/date ?d]
[(>= ?d ?start)]
[(< ?d ?end)]
[?jel :journal-entry-line/account ?a]
[?a :account/numeric-code ?code]
[(>= ?code ?from)]
[(< ?code ?to)]
[(get-else $ ?jel :journal-entry-line/debit 0.0) ?debit]
[(get-else $ ?jel :journal-entry-line/credit 0.0) ?credit]]
db client-ids (coerce/to-date starts) (coerce/to-date ends) from to)]
(reduce (fn [acc [code debit credit]]
(let [cost (- (or debit 0.0) (or credit 0.0))]
(cond-> (update acc :total + cost)
(band-for code) (update-in [:by-band (band-for code)] (fnil + 0.0) cost))))
{:total 0.0 :by-band {} :posted? (boolean (seq rows))}
rows)))
;; ---------------------------------------------------------------------------
;; Assembly
;; ---------------------------------------------------------------------------
(defn ratio [part whole]
(when (and part whole (not (zero? whole)))
(/ part whole)))
(defn get-report-data
"One column per period, most recent first, each carrying its own sales and payroll."
([db client-ids end-date] (get-report-data db client-ids end-date 1))
([db client-ids end-date weeks]
(for [{:keys [starts ends]} (period-windows end-date weeks)]
(let [sales (sales-in-window db client-ids starts ends)
payroll (payroll-in-window db client-ids starts ends)]
{:starts starts
:ends ends
:sales sales
:payroll payroll
:labor-ratio (when (:posted? payroll)
(ratio (:total payroll) (:total sales)))}))))
(defn average
"Mean of the values that exist, ignoring the ones that do not.
For a ratio, a missing period is genuinely unknown and has to be left out of the denominator."
[xs]
(let [xs (remove nil? xs)]
(when (seq xs) (/ (reduce + 0.0 xs) (count xs)))))
(defn per-period
"Mean over every period, counting one with nothing posted as zero.
Money is not a ratio: a band that only posts every second period still averages across all of
them. Dropping the empty periods from the denominator would let a detail line print larger than
the subtotal it is part of."
[xs]
(when (seq xs)
(/ (reduce + 0.0 (map #(or % 0.0) xs)) (count xs))))
(defn category-rows
"Sales categories present anywhere in the trend, largest average first.
Driven by the data rather than a fixed list, so a restaurant that starts selling something new
shows it without anyone editing this file."
[columns]
(->> (into #{} (mapcat (comp keys :by-category :sales)) columns)
(map (fn [n] {:label n :values (map #(get-in % [:sales :by-category n]) columns)}))
(sort-by #(- (or (per-period (:values %)) 0.0)))))
(defn headline
"The three numbers a manager actually opens this report for: the latest labour percentage, what
it normally runs at, and which way it moved."
[columns]
(let [current (first columns)
ratios (keep :labor-ratio columns)
trend (average (rest ratios))]
{:current (:labor-ratio current)
:current-sales (get-in current [:sales :total])
:current-payroll (get-in current [:payroll :total])
:trend trend
:delta (when (and (:labor-ratio current) trend)
(- (:labor-ratio current) trend))}))
;; ---------------------------------------------------------------------------
;; Rendering — the shared ledger report table, so this reads like its siblings
;; ---------------------------------------------------------------------------
(def ^:private hot [185 28 28]) ; labour running above its own trend
(def ^:private cool [21 128 61]) ; below
(def ^:private plain [17 24 39])
(defn- heat
"Colour for a labour percentage, judged against the trend rather than a fixed target, since the
right percentage differs by concept and this report should not pretend to know it."
[r trend]
(cond
(or (nil? r) (nil? trend) (zero? trend)) plain
(> r (* trend 1.05)) hot
(< r (* trend 0.95)) cool
:else plain))
(defn- label-cell
"Detail lines sit under their subtotal. The indent is non-breaking spaces because the cell
renderer emits the label as text into a `td`, where ordinary leading whitespace collapses away."
[label & {:keys [bold indent]}]
(cond-> {:value (if indent (str "   " label) label)}
bold (assoc :bold true)))
(defn- figure
"A numeric cell. The value is always a number, never nil: the shared cell renderer runs
`dollars-0?` on it before any nil-punning of its own, so a nil here is an NPE at render time
rather than a blank cell."
[v fmt & {:keys [bold color]}]
(cond-> {:value (or v 0.0) :format fmt}
bold (assoc :bold true)
color (assoc :color color)))
(def ^:private no-value
"An em dash, for a period where the number does not exist — as distinct from being zero."
{:value "—" :align :right})
(defn- money-row
"Label, average per period, that average's share of sales, then a column per period."
[label values sales & {:keys [bold indent]}]
(into [(label-cell label :bold bold :indent indent)
(figure (per-period values) :dollar :bold bold)
(figure (ratio (per-period values) (per-period sales)) :percent)]
(for [v values] (figure v :dollar :bold bold))))
(defn- ratio-row
"The headline row: payroll as a percentage of the sales it bought.
A period with no payroll posted gets an em dash rather than 0.0%. Zero labour against real sales
is not something a restaurant does; it means the pay run has not landed in the ledger yet, and
printing 0.0% would state the opposite."
[columns]
(let [ratios (map :labor-ratio columns)
trend (average ratios)]
(into [(label-cell "Payroll as % of sales" :bold true)
(figure trend :percent :bold true)
{:value ""}]
(for [r ratios]
(if (nil? r)
(assoc no-value :bold true)
(figure r :percent :bold true :color (heat r trend)))))))
(defn period-label
"`Aug 1` for a one-week column, `Jul 25 Aug 7` for a longer one — the span, when the span is
not obvious from the header alone."
[{:keys [starts ends]}]
(let [last-day (time/minus ends (time/days 1))
short "MMM d"]
(if (time/before? starts (time/minus last-day (time/days 6)))
(str (atime/unparse-local starts short) " " (atime/unparse-local last-day short))
(atime/unparse-local last-day short))))
(defn report-table
"`{:header :rows}` in the shape `auto-ap.ssr.ledger.report-table/table` renders."
[columns]
(let [sales-totals (map #(get-in % [:sales :total]) columns)
payroll-totals (map #(get-in % [:payroll :total]) columns)]
{:header [(into [{:value "" :bold true}
{:value "Average" :bold true}
{:value "% of sales" :bold true}]
(for [c columns] {:value (period-label c) :bold true}))]
:rows (concat
[(money-row "Sales" sales-totals sales-totals :bold true)]
(for [{:keys [label values]} (category-rows columns)]
(money-row label values sales-totals :indent true))
[[] ; a blank row, so sales and payroll read as two blocks rather than one long list
(money-row "Payroll" payroll-totals sales-totals :bold true)]
(for [[label] payroll-bands
:let [values (map #(get-in % [:payroll :by-band label]) columns)]
:when (some some? values)]
(money-row label values sales-totals :indent true))
[[]
(ratio-row columns)])}))
;; ---------------------------------------------------------------------------
;; The card
;; ---------------------------------------------------------------------------
(defn- percent-str [r]
(if r (format "%.1f%%" (* 100.0 r)) "—"))
(defn- stat
"One figure in the summary strip, big enough to read from across a desk."
[{:keys [label value hint color]}]
[:div {:class "flex-1 min-w-[9rem] px-5 py-4 rounded-lg bg-gray-50 dark:bg-gray-700"}
[:div {:class "text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400"} label]
[:div {:class (str "text-2xl font-bold tabular-nums " (or color "text-gray-900 dark:text-white"))}
value]
(when hint [:div {:class "text-xs text-gray-500 dark:text-gray-400 mt-0.5"} hint])])
(def ^:private spark-w 190)
(def ^:private spark-h 40)
(defn spark-points
"`[[x y] ...]` for the labour percentages, oldest on the left, scaled to fill the box.
Periods with no payroll posted contribute no point, so the line joins across a gap rather than
diving to the floor and inventing a good week."
[columns]
(let [ratios (reverse (map :labor-ratio columns))
present (keep identity ratios)]
(when (> (count present) 1)
(let [lo (apply min present)
span (max 1e-9 (- (apply max present) lo))
steps (max 1 (dec (count ratios)))]
(keep-indexed
(fn [i r]
(when r
[(+ 3 (* (- spark-w 6) (/ (double i) steps)))
(- (- spark-h 3) (* (- spark-h 6) (/ (- r lo) span)))]))
ratios)))))
(defn- sparkline
"The labour trend as a shape, for the reader who wants the direction before the numbers."
[columns]
(when-let [pts (spark-points columns)]
(let [[lx ly] (last pts)]
[:svg {:viewBox (str "0 0 " spark-w " " spark-h)
:width spark-w :height spark-h
:class "text-gray-400 dark:text-gray-500 mt-1"
:role "img"
:aria-label "Labour as a percentage of sales, oldest period on the left"}
[:polyline {:points (str/join " " (map (fn [[x y]] (str x "," y)) pts))
:fill "none" :stroke "currentColor" :stroke-width "1.5"
:stroke-linejoin "round" :stroke-linecap "round"}]
[:circle {:cx lx :cy ly :r "2.5" :class "text-gray-700 dark:text-gray-200"
:fill "currentColor"}]])))
(defn- summary-strip [columns]
(let [{:keys [current current-sales current-payroll trend delta]} (headline columns)
latest (first columns)]
[:div {:class "flex flex-wrap gap-3 mb-5"}
(stat {:label "Labour, latest period"
:value (percent-str current)
:hint (period-label latest)
:color (cond
(nil? delta) nil
(pos? delta) "text-red-700 dark:text-red-400"
:else "text-green-700 dark:text-green-400")})
(stat {:label "Usual"
:value (percent-str trend)
:hint (str "prior " (dec periods-shown) " periods")})
(stat {:label "Change"
:value (if delta
(format "%+.1f pts" (* 100.0 delta))
"—")
:hint (if delta "against the usual" "no payroll posted yet")
:color (cond
(nil? delta) nil
(pos? delta) "text-red-700 dark:text-red-400"
:else "text-green-700 dark:text-green-400")})
(stat {:label "Sales, latest period"
:value (format "$%,.0f" (or current-sales 0.0))
:hint (format "on $%,.0f of payroll" (or current-payroll 0.0))})
(when-let [line (sparkline columns)]
[:div {:class "flex-1 min-w-[11rem] px-5 py-4 rounded-lg bg-gray-50 dark:bg-gray-700"}
[:div {:class "text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400"}
"Labour trend"]
line
[:div {:class "text-xs text-gray-500 dark:text-gray-400 mt-0.5"} "oldest to latest"]])]))
(defn- footnote []
[:p {:class "text-xs text-gray-500 dark:text-gray-400 mt-4 max-w-3xl"}
"Sales are net revenue from the daily sales summaries — discounts and returns subtract, and tax, "
"tips and tender are excluded. Payroll is the ledger's payroll accounts, debits less credits, on "
"the date each entry was posted. A period showing "
[:span {:class "font-medium"} "—"]
" had no payroll posted at all; if that alternates, set the period to the client's pay cycle."])
(defn prime-cost-card* [{:keys [request columns]}]
(let [params (:query-params request)]
(com/content-card
{:class "w-full" :id "prime-cost-report"}
[:div {:class "flex flex-col px-8 py-8"}
[:h1.text-2xl.font-bold "Prime Cost Report"]
[:p {:class "text-sm text-gray-600 dark:text-gray-400 mt-1 mb-5"}
"Sales against the payroll that earned them, over "
(str periods-shown)
" periods. Payroll from the ledger, sales from the daily summaries."]
[:form {:hx-get (bidi/path-for ssr-routes/only-routes ::route/run-prime-cost)
:hx-target "#prime-cost-report"
:hx-swap "outerHTML"
:class "mb-6"}
(fc/start-form
params
(:form-errors request)
[:div {:class "flex flex-wrap gap-3 items-end"}
(fc/with-field :end-date
(com/validated-field {:label "Period ending" :errors (fc/field-errors)}
[:div {:class "w-48"}
(com/date-input {:name (fc/field-name)
:class "w-48"
:value (some-> (fc/field-value)
coerce/to-date-time
(atime/unparse-local atime/normal-date))})]))
(fc/with-field :period
(com/validated-field {:label "Period length" :errors (fc/field-errors)}
[:div {:class "w-40"}
(com/select {:name (fc/field-name)
:class "w-40"
:value (or (fc/field-value) default-period)
:options (map (juxt :value :label) period-lengths)})]))
[:div {:class "pb-1"}
(com/button {:color :primary :class "w-24"} "Run")]])]
(if (seq columns)
[:div
(summary-strip columns)
(table {:table (report-table columns)
:widths (into [16 9 7] (repeat periods-shown 9))
:height "max-h-[70vh]"})
(footnote)]
[:div {:class "text-gray-600 dark:text-gray-400 py-6"}
"Choose a period-ending date and run the report."])])))
(defn page [request]
(base-page
request
(com/page {:nav com/company-aside-nav
:client-selection (:client-selection request)
:client (:client request)
:clients (:clients request)
:identity (:identity request)}
(com/breadcrumbs {}
[:a {:href (bidi/path-for ssr-routes/only-routes ::route/prime-cost)}
"Prime Cost Report"])
(prime-cost-card* {:request request :columns nil}))
"Prime Cost Report"))
(defn run [{{:keys [end-date period]} :query-params :as request}]
(let [client-ids (extract-client-ids (:clients request)
(:client-id request)
(when (:client-code request)
[:client/code (:client-code request)]))
columns (when end-date
(get-report-data (dc/db conn) client-ids end-date
(weeks-per-period (or period default-period))))]
(html-response (prime-cost-card* {:request request :columns columns}))))
(def query-schema
(mc/schema
[:maybe [:map
[:end-date {:optional true} [:maybe clj-date-schema]]
[:period {:optional true} [:maybe (into [:enum] (map :value period-lengths))]]]]))
(def key->handler
(apply-middleware-to-all-handlers
{::route/prime-cost page
::route/run-prime-cost run}
(fn [h]
(-> h
(wrap-schema-enforce :query-schema query-schema)
(wrap-must {:activity :read :subject :profit-and-loss})
(wrap-client-redirect-unauthenticated)))))

View File

@@ -1,4 +1,4 @@
(ns auto-ap.ssr.ledger.report-table
(ns auto-ap.ssr.ledger.report-table
(:require
[auto-ap.ssr.components :as com]
[auto-ap.time :as atime]
@@ -7,22 +7,21 @@
[hiccup.util :as hu]
[iol-ion.query :as query]))
(defn cell [{:keys [width investigate-url other-style]} c]
(let [cell-contents (cond
(= :dollar (:format c))
(format "$%,.2f" (if (query/dollars-0? (:value c))
(format "$%,.2f" (if (or (nil? (:value c))
(query/dollars-0? (:value c)))
0.0
(:value c)))
(= :percent (:format c))
(format "%%%.1f" (if (query/dollars-0? (:value c))
(format "%.1f%%" (if (or (nil? (:value c))
(query/dollars-0? (:value c)))
0.0
(* 100.0 (or (:value c) 0.0))))
(* 100.0 (:value c))))
:else
(str (:value c)))
cell-contents (if (:filters c)
@@ -32,8 +31,7 @@
(inst? (:date-range (:filters c))) (assoc :end-date (atime/unparse-local (coerce/to-date-time (:date-range (:filters c))) atime/normal-date))
(:end (:date-range (:filters c))) (assoc :end-date (atime/unparse-local (coerce/to-date-time (:end (:date-range (:filters c)))) atime/normal-date))
(:start (:date-range (:filters c))) (assoc :start-date (atime/unparse-local (coerce/to-date-time (:start (:date-range (:filters c)))) atime/normal-date))
(:client-id (:filters c)) (assoc :client-id (:client-id (:filters c))))
)}
(:client-id (:filters c)) (assoc :client-id (:client-id (:filters c)))))}
cell-contents)
cell-contents)]
[:td.px-4.py-2
@@ -44,10 +42,9 @@
(fn [s]
(->> (:border c)
(map
(fn [b]
[(keyword (str "border-" (name b))) "1px solid black"])
)
(into s))))
(fn [b]
[(keyword (str "border-" (name b))) "1px solid black"]))
(into s))))
(:colspan c) (assoc :colspan (:colspan c))
(:align c) (assoc :align (:align c))
(= :dollar (:format c)) (assoc :align :right)
@@ -57,10 +54,10 @@
(str/join ","
(:color c))
")"))
true (assoc-in [:style :background-color] (str "rgb("
(str/join ","
(or (:bg-color c) [255 255 255]))
")")))
true (assoc-in [:style :background-color] (str "rgb("
(str/join ","
(or (:bg-color c) [255 255 255]))
")")))
cell-contents]))
@@ -70,49 +67,52 @@
(apply max counts)
0)))
(defn table [{:keys [table widths investigate-url warning]}]
(defn table [{:keys [table widths investigate-url warning height]}]
(let [cell-count (cell-count table)]
(com/content-card {:class "inline-block overflow-scroll"}
[:div {:class "overflow-scroll h-[70vh] m-4 inline-block"}
(when warning [:div.rounded.bg-red-50.text-red-800.p-4.m-2
warning])
(-> [:table {:class "text-sm text-left text-gray-500 dark:text-gray-400"}
[:thead {:class "text-xs text-gray-800 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 font-bold"}
(map
(fn [header-row header]
(into
[:tr {:class " dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700"}]
(map
(fn [w header i]
(cell {:width w
:investigate-url investigate-url
:other-style {:position "sticky"
:top (* header-row (+ 22 18))}} header))
widths
header
(range))))
(range)
(:header table))]]
(conj
(-> [:tbody {:style {}}]
(into
(for [[i row] (map vector (range) (:rows table))]
[:tr {:class " dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700"}
(for [[i c] (map vector (range) (take cell-count
(reduce
(fn [[acc cnt] cur]
(if (>= (+ cnt (:colspan cur 1)) cell-count)
(reduced (conj acc cur))
[(conj acc cur) (+ cnt (:colspan cur 1))]))
[[] 0]
(concat row (repeat nil)))))]
(cell {:investigate-url investigate-url} c))]))
(conj [:tr (for [i (range cell-count)]
(cell {:investigate-url investigate-url} {:value " "}))]))))])))
(com/content-card {:class "inline-block overflow-scroll"}
;; `height` is a Tailwind height class, defaulting to the fixed 70vh the long
;; reports need. A short report can pass `max-h-[70vh]` and then takes only
;; the room it uses, rather than leaving a blank pane below itself.
[:div {:class (str "overflow-scroll m-4 inline-block " (or height "h-[70vh]"))}
(when warning [:div.rounded.bg-red-50.text-red-800.p-4.m-2
warning])
(-> [:table {:class "text-sm text-left text-gray-500 dark:text-gray-400"}
[:thead {:class "text-xs text-gray-800 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 font-bold"}
(map
(fn [header-row header]
(into
[:tr {:class " dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700"}]
(map
(fn [w header i]
(cell {:width w
:investigate-url investigate-url
:other-style {:position "sticky"
:top (* header-row (+ 22 18))}} header))
widths
header
(range))))
(range)
(:header table))]]
(conj
(-> [:tbody {:style {}}]
(into
(for [[i row] (map vector (range) (:rows table))]
[:tr {:class " dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700"}
(for [[i c] (map vector (range) (take cell-count
(reduce
(fn [[acc cnt] cur]
(if (>= (+ cnt (:colspan cur 1)) cell-count)
(reduced (conj acc cur))
[(conj acc cur) (+ cnt (:colspan cur 1))]))
[[] 0]
(concat row (repeat nil)))))]
(cell {:investigate-url investigate-url} c))]))
(conj [:tr (for [i (range cell-count)]
(cell {:investigate-url investigate-url} {:value " "}))]))))])))
(defn concat-tables [tables]
(let [[first & rest] tables]
@@ -120,8 +120,8 @@
:rows (concat (:rows first)
[[]]
(mapcat
(fn [table]
(-> (:header table)
(into (:rows table))
(conj [])))
rest))}))
(fn [table]
(-> (:header table)
(into (:rows table))
(conj [])))
rest))}))

View File

@@ -24,4 +24,6 @@
"/export" ::export-cash-flows}
"/reports/profit-and-loss" {"" ::profit-and-loss
"/run" ::run-profit-and-loss
"/export" ::export-profit-and-loss}})
"/export" ::export-profit-and-loss}
"/reports/prime-cost" {"" ::prime-cost
"/run" ::run-prime-cost}})

View File

@@ -0,0 +1,218 @@
(ns auto-ap.ssr.ledger.prime-cost-test
(:require
[auto-ap.datomic :refer [conn]]
[auto-ap.integration.util :refer [setup-test-data wrap-setup]]
[auto-ap.ledger.reports :as l-reports]
[auto-ap.ssr.ledger.prime-cost :as sut]
[clj-time.coerce :as coerce]
[clj-time.core :as time]
[clojure.test :refer [deftest is testing use-fixtures]]
[datomic.api :as dc]))
(use-fixtures :each wrap-setup)
(def week-ending (time/date-time 2026 8 10))
(def in-week #inst "2026-08-05T12:00:00.000-00:00")
(def week-before #inst "2026-07-29T12:00:00.000-00:00")
(defn- account [tempid code]
{:db/id tempid :account/name (str "Account " code) :account/numeric-code code})
(defn- jel [client account-tempid date debit]
{:journal-entry-line/client client
:journal-entry-line/account account-tempid
:journal-entry-line/date date
:journal-entry-line/debit debit})
(deftest period-windows-tile-without-gaps
(testing "each period ends where the next begins, so no day is counted twice or missed"
(let [ws (sut/period-windows week-ending)]
(is (= sut/periods-shown (count ws)))
(is (= (coerce/to-date week-ending) (coerce/to-date (:ends (first ws))))
"the newest window ends at the requested date")
(is (every? (fn [[newer older]] (= (:starts newer) (:ends older)))
(partition 2 1 ws))
"windows abut")
(is (apply > (map (comp coerce/to-long :starts) ws))
"most recent first")))
(testing "a longer period widens each column without leaving a gap between them"
(let [ws (sut/period-windows week-ending 2)]
(is (= sut/periods-shown (count ws)))
(is (= 14 (time/in-days (time/interval (:starts (first ws)) (:ends (first ws))))))
(is (every? (fn [[newer older]] (= (:starts newer) (:ends older)))
(partition 2 1 ws))))))
(deftest period-length-select-maps-to-weeks
(testing "the query param the form submits resolves to a column width"
(is (= 1 (sut/weeks-per-period "1")))
(is (= 2 (sut/weeks-per-period "2")))
(is (= 1 (sut/weeks-per-period nil)) "an absent or unknown period falls back to weekly")))
(deftest payroll-bands-come-from-the-shared-ledger-groupings
(testing "the bands are the ledger's, not a second list that can drift from the P&L"
(is (= (:payroll l-reports/groupings) sut/payroll-bands)))
(testing "every payroll account code lands in exactly one band"
(is (some? (sut/band-for 60500)) "general payroll is not orphaned")
(is (some? (sut/band-for 61100)))
(is (some? (sut/band-for 62200)))
(is (some? (sut/band-for 63200)))
(is (some? (sut/band-for 65000)))
(is (some? (sut/band-for 69800)))
(is (nil? (sut/band-for 50000)) "food cost is not payroll")
(is (nil? (sut/band-for 70000)) "controllable costs are not payroll")))
(deftest payroll-is-debits-less-credits-within-the-week
(testing "a credit correction reduces the week rather than counting as more labour, and lines
outside the window are excluded"
(let [{:strs [test-client-id]} (setup-test-data [])]
@(dc/transact conn [(account "foh" 63200)
(account "boh" 62200)
(jel test-client-id "foh" in-week 1000.0)
(jel test-client-id "boh" in-week 400.0)
{:journal-entry-line/client test-client-id
:journal-entry-line/account "foh"
:journal-entry-line/date in-week
:journal-entry-line/credit 100.0}
(jel test-client-id "foh" week-before 999.0)])
(let [{:keys [total by-band]} (sut/payroll-in-window
(dc/db conn) [test-client-id]
(time/minus week-ending (time/weeks 1)) week-ending)]
(is (= 1300.0 total) "1000 + 400 - 100, and nothing from the prior week")
(is (= 900.0 (get by-band "63000-66000 Payroll - FOH")))
(is (= 400.0 (get by-band "62000 Payroll - BOH")))))))
(deftest payroll-excludes-non-payroll-accounts
(testing "only the 60000 block counts, so food cost never lands in labour"
(let [{:strs [test-client-id]} (setup-test-data [])]
@(dc/transact conn [(account "foh" 63200)
(account "food" 50000)
(jel test-client-id "foh" in-week 500.0)
(jel test-client-id "food" in-week 5000.0)])
(is (= 500.0 (:total (sut/payroll-in-window
(dc/db conn) [test-client-id]
(time/minus week-ending (time/weeks 1)) week-ending)))))))
(deftest sales-count-revenue-only-and-net-of-discounts
(testing "tender, tax and tip are not sales, and a debit against revenue reduces it"
(let [{:strs [test-client-id]} (setup-test-data [])]
@(dc/transact conn [(account "food-rev" 40111)
(account "discount" 41000)
(account "tax" 25700)
(account "card" 75460)
{:sales-summary/client test-client-id
:sales-summary/date in-week
:sales-summary/client+date [test-client-id in-week]
:sales-summary/items
[{:sales-summary-item/category "Gyros"
:ledger-mapped/amount 1000.0
:ledger-mapped/ledger-side :ledger-side/credit
:ledger-mapped/account "food-rev"}
{:sales-summary-item/category "Discounts"
:ledger-mapped/amount 100.0
:ledger-mapped/ledger-side :ledger-side/debit
:ledger-mapped/account "discount"}
{:sales-summary-item/category "Tax"
:ledger-mapped/amount 90.0
:ledger-mapped/ledger-side :ledger-side/credit
:ledger-mapped/account "tax"}
{:sales-summary-item/category "Card Payments"
:ledger-mapped/amount 990.0
:ledger-mapped/ledger-side :ledger-side/debit
:ledger-mapped/account "card"}]}])
(let [{:keys [total by-category]} (sut/sales-in-window
(dc/db conn) [test-client-id]
(time/minus week-ending (time/weeks 1)) week-ending)]
(is (= 900.0 total) "1000 of revenue less a 100 discount; tax and tender excluded")
(is (= 1000.0 (get by-category "Gyros")))
(is (= -100.0 (get by-category "Discounts")))
(is (nil? (get by-category "Tax")) "tax is money held for someone else, not a sale")
(is (nil? (get by-category "Card Payments")) "tender is the other side of the sale")))))
(deftest the-headline-ratio-is-payroll-over-sales
(testing "the number the report exists for"
(let [{:strs [test-client-id]} (setup-test-data [])]
@(dc/transact conn [(account "food-rev" 40111)
(account "foh" 63200)
(jel test-client-id "foh" in-week 250.0)
{:sales-summary/client test-client-id
:sales-summary/date in-week
:sales-summary/client+date [test-client-id in-week]
:sales-summary/items
[{:sales-summary-item/category "Gyros"
:ledger-mapped/amount 1000.0
:ledger-mapped/ledger-side :ledger-side/credit
:ledger-mapped/account "food-rev"}]}])
(let [columns (sut/get-report-data (dc/db conn) [test-client-id] week-ending)
current (first columns)]
(is (= sut/periods-shown (count columns)))
(is (= 0.25 (:labor-ratio current)) "250 of labour against 1000 of sales")
(is (nil? (:labor-ratio (second columns)))
"a week with no sales has no ratio rather than a divide-by-zero")))))
(deftest a-period-with-no-payroll-posted-has-no-ratio
(testing "payroll posted on a pay-period date leaves neighbouring weeks empty, and an empty week
must read as unknown rather than as 0% labour"
(let [{:strs [test-client-id]} (setup-test-data [])]
@(dc/transact conn [(account "food-rev" 40111)
{:sales-summary/client test-client-id
:sales-summary/date in-week
:sales-summary/client+date [test-client-id in-week]
:sales-summary/items
[{:sales-summary-item/category "Gyros"
:ledger-mapped/amount 1000.0
:ledger-mapped/ledger-side :ledger-side/credit
:ledger-mapped/account "food-rev"}]}])
(let [current (first (sut/get-report-data (dc/db conn) [test-client-id] week-ending))]
(is (= 1000.0 (get-in current [:sales :total])) "the sales are real")
(is (false? (get-in current [:payroll :posted?])))
(is (nil? (:labor-ratio current))
"no payroll line exists, so the ratio is unknown, not zero")))))
(deftest report-table-renders-a-column-per-week-plus-label-and-average
(testing "the table lines up with its header"
(let [{:strs [test-client-id]} (setup-test-data [])]
@(dc/transact conn [(account "food-rev" 40111)
{:sales-summary/client test-client-id
:sales-summary/date in-week
:sales-summary/client+date [test-client-id in-week]
:sales-summary/items
[{:sales-summary-item/category "Gyros"
:ledger-mapped/amount 1000.0
:ledger-mapped/ledger-side :ledger-side/credit
:ledger-mapped/account "food-rev"}]}])
(let [{:keys [header rows]} (sut/report-table
(sut/get-report-data (dc/db conn) [test-client-id] week-ending))
width (+ 3 sut/periods-shown)]
(is (= width (count (first header))))
(is (every? #(= width (count %)) (remove empty? rows))
"every populated row is the same width as the header, or the table skews")))))
(deftest the-trend-line-skips-periods-with-no-payroll
(testing "a period with no payroll posted contributes no point, so the line joins across the gap
rather than diving to the floor and drawing a week that never happened"
(let [columns [{:labor-ratio 0.30} {:labor-ratio nil} {:labor-ratio 0.20}]]
(is (= 2 (count (sut/spark-points columns))))))
(testing "a single readable period is not a trend"
(is (nil? (sut/spark-points [{:labor-ratio 0.30} {:labor-ratio nil}])))))
(deftest a-detail-line-never-averages-larger-than-its-subtotal
(testing "money averages over every period, so a band posting every second period is not divided
by a smaller denominator than the total it rolls up into"
(is (= 500.0 (sut/per-period [1000.0 nil 1000.0 nil]))
"the empty periods still count")
(is (= 1000.0 (sut/average [1000.0 nil 1000.0 nil]))
"a ratio, by contrast, only averages the periods it is known for"))
(testing "on real-shaped data the payroll bands sum to no more than the payroll total"
(let [{:strs [test-client-id]} (setup-test-data [])]
@(dc/transact conn [(account "foh" 63200)
(jel test-client-id "foh" in-week 1400.0)])
(let [{:keys [rows]} (sut/report-table
(sut/get-report-data (dc/db conn) [test-client-id] week-ending))
average-of (fn [label]
(->> rows
(filter #(= label (-> (str (:value (first %)))
(clojure.string/replace "\u00a0" "")
(clojure.string/trim))))
first second :value))]
(is (= (average-of "Payroll") (average-of "63000-66000 Payroll - FOH"))
"one band carrying all the payroll averages exactly what the total does")))))