Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b53169f14 | |||
| 250bf0ea7f | |||
| 359508ea9f | |||
| 62e96556e5 |
423
docs/2026-08-16-sales-summary-business-impact.html
Normal file
423
docs/2026-08-16-sales-summary-business-impact.html
Normal 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 · 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 — measured by running the real job over
|
||||||
|
ninety days of real trading.</p>
|
||||||
|
<div class="facts">
|
||||||
|
<span><b>Period</b> 10 May – 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">→</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 — 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 — 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 — 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">— 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 — 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">— 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">— 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 — 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">— 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 — 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 — once as it behaves today,
|
||||||
|
once with the fixes on — 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> — 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 —
|
||||||
|
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 — 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 — 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">−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 — 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 — $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 — 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 — 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 — 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 — 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 — a genuine gap in the till's own figures, not a fault in the books — 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 — <span class="mono">NG4S</span> and
|
||||||
|
<span class="mono">NGPS</span> — 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 — 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 — 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 — 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 – 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>
|
||||||
BIN
docs/screenshots/prime-cost-bi-weekly.png
Normal file
BIN
docs/screenshots/prime-cost-bi-weekly.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
BIN
docs/screenshots/prime-cost-summary-strip.png
Normal file
BIN
docs/screenshots/prime-cost-summary-strip.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
BIN
docs/screenshots/prime-cost-weekly.png
Normal file
BIN
docs/screenshots/prime-cost-weekly.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
@@ -95,7 +95,7 @@
|
|||||||
"sales"
|
"sales"
|
||||||
(#{::payment-routes/all-page ::payment-routes/pending-page ::payment-routes/cleared-page ::payment-routes/voided-page} (:matched-route request))
|
(#{::payment-routes/all-page ::payment-routes/pending-page ::payment-routes/cleared-page ::payment-routes/voided-page} (:matched-route request))
|
||||||
"payments"
|
"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"
|
"ledger"
|
||||||
:else
|
:else
|
||||||
nil)]
|
nil)]
|
||||||
@@ -320,6 +320,11 @@
|
|||||||
:profit-and-loss)} "Old profit and loss"))
|
:profit-and-loss)} "Old profit and loss"))
|
||||||
(menu-button- {:href (bidi/path-for client-routes/routes
|
(menu-button- {:href (bidi/path-for client-routes/routes
|
||||||
:profit-and-loss-detail)} "Profit & Loss Detail")
|
: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
|
(menu-button- {:href (bidi/path-for client-routes/routes
|
||||||
:cash-flows)} "Cash Flows")
|
:cash-flows)} "Cash Flows")
|
||||||
(if (is-admin? (:identity request))
|
(if (is-admin? (:identity request))
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
[auto-ap.ssr.ledger.common :as ledger.common]
|
[auto-ap.ssr.ledger.common :as ledger.common]
|
||||||
[auto-ap.ssr.ledger.investigate :as investigate]
|
[auto-ap.ssr.ledger.investigate :as investigate]
|
||||||
[auto-ap.ssr.ledger.new :as new]
|
[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.ledger.profit-and-loss :as profit-and-loss]
|
||||||
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
|
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
|
||||||
[auto-ap.ssr.svg :as svg]
|
[auto-ap.ssr.svg :as svg]
|
||||||
@@ -737,6 +738,7 @@
|
|||||||
(wrap-client-redirect-unauthenticated))))
|
(wrap-client-redirect-unauthenticated))))
|
||||||
balance-sheet/key->handler
|
balance-sheet/key->handler
|
||||||
profit-and-loss/key->handler
|
profit-and-loss/key->handler
|
||||||
|
prime-cost/key->handler
|
||||||
cash-flows/key->handler
|
cash-flows/key->handler
|
||||||
investigate/key->handler
|
investigate/key->handler
|
||||||
new/key->handler))
|
new/key->handler))
|
||||||
512
src/clj/auto_ap/ssr/ledger/prime_cost.clj
Normal file
512
src/clj/auto_ap/ssr/ledger/prime_cost.clj
Normal 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)))))
|
||||||
@@ -7,21 +7,20 @@
|
|||||||
[hiccup.util :as hu]
|
[hiccup.util :as hu]
|
||||||
[iol-ion.query :as query]))
|
[iol-ion.query :as query]))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(defn cell [{:keys [width investigate-url other-style]} c]
|
(defn cell [{:keys [width investigate-url other-style]} c]
|
||||||
(let [cell-contents (cond
|
(let [cell-contents (cond
|
||||||
|
|
||||||
(= :dollar (:format c))
|
(= :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
|
0.0
|
||||||
(:value c)))
|
(:value c)))
|
||||||
|
|
||||||
|
|
||||||
(= :percent (:format 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
|
0.0
|
||||||
(* 100.0 (or (:value c) 0.0))))
|
(* 100.0 (:value c))))
|
||||||
|
|
||||||
:else
|
:else
|
||||||
(str (:value c)))
|
(str (:value 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))
|
(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))
|
(: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))
|
(: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)
|
||||||
cell-contents)]
|
cell-contents)]
|
||||||
[:td.px-4.py-2
|
[:td.px-4.py-2
|
||||||
@@ -45,8 +43,7 @@
|
|||||||
(->> (:border c)
|
(->> (:border c)
|
||||||
(map
|
(map
|
||||||
(fn [b]
|
(fn [b]
|
||||||
[(keyword (str "border-" (name b))) "1px solid black"])
|
[(keyword (str "border-" (name b))) "1px solid black"]))
|
||||||
)
|
|
||||||
(into s))))
|
(into s))))
|
||||||
(:colspan c) (assoc :colspan (:colspan c))
|
(:colspan c) (assoc :colspan (:colspan c))
|
||||||
(:align c) (assoc :align (:align c))
|
(:align c) (assoc :align (:align c))
|
||||||
@@ -70,10 +67,13 @@
|
|||||||
(apply max counts)
|
(apply max counts)
|
||||||
0)))
|
0)))
|
||||||
|
|
||||||
(defn table [{:keys [table widths investigate-url warning]}]
|
(defn table [{:keys [table widths investigate-url warning height]}]
|
||||||
(let [cell-count (cell-count table)]
|
(let [cell-count (cell-count table)]
|
||||||
(com/content-card {:class "inline-block overflow-scroll"}
|
(com/content-card {:class "inline-block overflow-scroll"}
|
||||||
[:div {:class "overflow-scroll h-[70vh] m-4 inline-block"}
|
;; `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
|
(when warning [:div.rounded.bg-red-50.text-red-800.p-4.m-2
|
||||||
warning])
|
warning])
|
||||||
(-> [:table {:class "text-sm text-left text-gray-500 dark:text-gray-400"}
|
(-> [:table {:class "text-sm text-left text-gray-500 dark:text-gray-400"}
|
||||||
|
|||||||
@@ -24,4 +24,6 @@
|
|||||||
"/export" ::export-cash-flows}
|
"/export" ::export-cash-flows}
|
||||||
"/reports/profit-and-loss" {"" ::profit-and-loss
|
"/reports/profit-and-loss" {"" ::profit-and-loss
|
||||||
"/run" ::run-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}})
|
||||||
218
test/clj/auto_ap/ssr/ledger/prime_cost_test.clj
Normal file
218
test/clj/auto_ap/ssr/ledger/prime_cost_test.clj
Normal 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")))))
|
||||||
Reference in New Issue
Block a user