feat(sales-summaries): add a summary comparison tool, and report 30-day impact
compare-sales-summaries diffs summaries between two points in the same database using as-of, so a recompute can be audited against exactly what was there before without a snapshot or scratch copy. It answers a question that balance-status counting hides: a day can stay balanced while its line amounts move, which is a real change to the books even though no red turns green. Amounts are compared at the cent so floating-point noise does not read as a change. Run over 2026-07-15 to 08-13 against production's own summaries at basis-t 209608347: out of balance -> balanced 309 balanced -> out of balance 0 balanced -> balanced, numbers moved 237 no summary in production 4,058 Broken down by fix, neither calculation change touches a healthy day: R1 fixes 86 with 0 unbalanced and 0 balanced-day edits, R2 fixes 276 on the same terms. Every one of the 237 balanced-but-changed days comes from the data work, which is also the only step that unbalances anything — 19 days, all of them absorbed by R1 and R2, which is why the end-to-end count is zero. The 4,058 missing summaries include eight consecutive days, Jul 30 to Aug 6, missing for all 210 clients — the coverage hole the plan predicted from the scheduler's seven-day lookback, confirmed here independently from the data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
420
docs/2026-08-15-thirty-day-reconciliation.html
Normal file
420
docs/2026-08-15-thirty-day-reconciliation.html
Normal file
@@ -0,0 +1,420 @@
|
||||
<title>Thirty-Day Reconciliation</title>
|
||||
<style>
|
||||
:root {
|
||||
--paper: #F6F8F7;
|
||||
--card: #FFFFFF;
|
||||
--ink: #141F1D;
|
||||
--ink-soft: #4A5C58;
|
||||
--ink-faint: #7C8D89;
|
||||
--rule: #DCE4E1;
|
||||
--accent: #0E5B57;
|
||||
--accent-soft: #E3EFED;
|
||||
--good: #1A6B49;
|
||||
--bad: #A03B26;
|
||||
--warn: #8A6410;
|
||||
--shadow: 0 1px 2px rgba(20,31,29,.06), 0 8px 24px rgba(20,31,29,.05);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--paper: #0E1615;
|
||||
--card: #151F1E;
|
||||
--ink: #E8EFED;
|
||||
--ink-soft: #A3B3AF;
|
||||
--ink-faint: #74847F;
|
||||
--rule: #26332F;
|
||||
--accent: #5FBDB4;
|
||||
--accent-soft: #16302E;
|
||||
--good: #5FBE8C;
|
||||
--bad: #E08A72;
|
||||
--warn: #D6AC55;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.3);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--paper: #0E1615;
|
||||
--card: #151F1E;
|
||||
--ink: #E8EFED;
|
||||
--ink-soft: #A3B3AF;
|
||||
--ink-faint: #74847F;
|
||||
--rule: #26332F;
|
||||
--accent: #5FBDB4;
|
||||
--accent-soft: #16302E;
|
||||
--good: #5FBE8C;
|
||||
--bad: #E08A72;
|
||||
--warn: #D6AC55;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.3);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
padding: 0 20px 96px;
|
||||
}
|
||||
|
||||
.wrap { max-width: 940px; margin: 0 auto; }
|
||||
.measure { max-width: 66ch; }
|
||||
|
||||
.num { font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; }
|
||||
.mono {
|
||||
font-family: ui-monospace, "SF Mono", "Cascadia Code", "Roboto Mono", monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
header.masthead {
|
||||
padding: 72px 0 40px;
|
||||
border-bottom: 2px solid var(--ink);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 12px;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
h1 {
|
||||
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
|
||||
font-size: clamp(34px, 5.4vw, 54px);
|
||||
line-height: 1.08;
|
||||
font-weight: 600;
|
||||
letter-spacing: -.015em;
|
||||
margin: 0;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.standfirst { font-size: 19px; color: var(--ink-soft); margin: 0; max-width: 60ch; }
|
||||
.meta {
|
||||
display: flex; flex-wrap: wrap; gap: 10px 28px;
|
||||
font-size: 13px; color: var(--ink-faint);
|
||||
padding-top: 6px;
|
||||
}
|
||||
.meta b { color: var(--ink-soft); font-weight: 600; }
|
||||
|
||||
section { padding-top: 56px; display: flex; flex-direction: column; gap: 20px; }
|
||||
h2 {
|
||||
font-family: Georgia, "Iowan Old Style", serif;
|
||||
font-size: 27px; font-weight: 600; letter-spacing: -.01em;
|
||||
margin: 0; text-wrap: balance;
|
||||
}
|
||||
h3 {
|
||||
font-size: 14px; letter-spacing: .08em; text-transform: uppercase;
|
||||
color: var(--ink-soft); font-weight: 700; margin: 0;
|
||||
}
|
||||
p { margin: 0; }
|
||||
.measure p + p { margin-top: 14px; }
|
||||
|
||||
/* headline before/after */
|
||||
.ledger {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
background: var(--card);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ledger > div { padding: 26px 28px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.ledger .arrow {
|
||||
justify-content: center; align-items: center;
|
||||
border-left: 1px solid var(--rule); border-right: 1px solid var(--rule);
|
||||
color: var(--ink-faint); font-size: 22px; padding: 26px 18px;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.ledger .side-label {
|
||||
font-size: 12px; letter-spacing: .12em; text-transform: uppercase;
|
||||
color: var(--ink-faint); font-weight: 600;
|
||||
}
|
||||
.figure { font-size: clamp(28px, 4.4vw, 40px); font-weight: 650; line-height: 1.05; letter-spacing: -.02em; }
|
||||
.figure.after { color: var(--good); }
|
||||
.subfig { font-size: 14px; color: var(--ink-soft); }
|
||||
|
||||
/* stat row */
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; }
|
||||
.stat {
|
||||
background: var(--card); border: 1px solid var(--rule); border-radius: 4px;
|
||||
padding: 18px 20px; display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.stat .k { font-size: 30px; font-weight: 650; letter-spacing: -.02em; line-height: 1; }
|
||||
.stat .l { font-size: 13px; color: var(--ink-soft); }
|
||||
.stat.zero .k { color: var(--good); }
|
||||
.stat.flag .k { color: var(--warn); }
|
||||
|
||||
/* steps */
|
||||
.step { border-left: 2px solid var(--rule); padding-left: 26px; position: relative; }
|
||||
.step::before {
|
||||
content: attr(data-step);
|
||||
position: absolute; left: -13px; top: 2px;
|
||||
width: 24px; height: 24px; border-radius: 50%;
|
||||
background: var(--accent); color: var(--paper);
|
||||
font-size: 12px; font-weight: 700;
|
||||
display: grid; place-items: center;
|
||||
}
|
||||
.step h3 { margin-bottom: 8px; }
|
||||
.step-title { font-size: 20px; font-weight: 650; letter-spacing: -.01em; }
|
||||
|
||||
.scroll { overflow-x: auto; border: 1px solid var(--rule); border-radius: 4px; 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: 11.5px; letter-spacing: .09em; text-transform: uppercase;
|
||||
color: var(--ink-faint); font-weight: 700; background: var(--accent-soft);
|
||||
}
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
td.n, th.n { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
tr.total td { font-weight: 650; background: var(--accent-soft); }
|
||||
.good { color: var(--good); font-weight: 650; }
|
||||
.bad { color: var(--bad); font-weight: 650; }
|
||||
.dim { color: var(--ink-faint); }
|
||||
|
||||
.pill {
|
||||
display: inline-block; font-size: 11.5px; font-weight: 700;
|
||||
letter-spacing: .05em; text-transform: uppercase;
|
||||
padding: 3px 9px; border-radius: 3px;
|
||||
}
|
||||
.pill.ok { background: var(--accent-soft); color: var(--good); }
|
||||
.pill.no { background: var(--accent-soft); color: var(--bad); }
|
||||
|
||||
.callout {
|
||||
background: var(--card); border: 1px solid var(--rule);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 4px; padding: 20px 24px;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.callout.warn { border-left-color: var(--warn); }
|
||||
.callout .h { font-weight: 650; }
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, "SF Mono", monospace; font-size: .9em;
|
||||
background: var(--accent-soft); padding: 1px 5px; border-radius: 3px;
|
||||
}
|
||||
footer {
|
||||
margin-top: 72px; padding-top: 24px; border-top: 1px solid var(--rule);
|
||||
font-size: 13px; color: var(--ink-faint);
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
ul { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 8px; }
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<header class="masthead">
|
||||
<div class="eyebrow">Sales summaries · restored production backup</div>
|
||||
<h1>Thirty-Day Reconciliation</h1>
|
||||
<p class="standfirst">What the deduplication work and the two calculation fixes actually did to the books, measured day by day against production's own summaries.</p>
|
||||
<div class="meta">
|
||||
<span><b>Window</b> 2026-07-15 → 2026-08-13</span>
|
||||
<span><b>Client-days</b> <span class="num">3,040</span> with activity</span>
|
||||
<span><b>Compared against</b> basis-t <span class="num">209608347</span></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<div class="ledger">
|
||||
<div>
|
||||
<span class="side-label">Baseline</span>
|
||||
<span class="figure num">$22,527.40</span>
|
||||
<span class="subfig"><span class="num">398</span> days out of balance · <span class="num">86.00%</span> clean</span>
|
||||
</div>
|
||||
<div class="arrow" aria-hidden="true">→</div>
|
||||
<div>
|
||||
<span class="side-label">After fixes</span>
|
||||
<span class="figure after num">$405.66</span>
|
||||
<span class="subfig"><span class="num">67</span> days out of balance · <span class="num">97.64%</span> clean</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat"><span class="k num">331</span><span class="l">client-days brought into balance</span></div>
|
||||
<div class="stat zero"><span class="k num">0</span><span class="l">days knocked out of balance</span></div>
|
||||
<div class="stat flag"><span class="k num">237</span><span class="l">already-balanced days whose numbers moved</span></div>
|
||||
<div class="stat"><span class="k num">98.2%</span><span class="l">of the dollar variance removed</span></div>
|
||||
</div>
|
||||
|
||||
<div class="measure">
|
||||
<p>Both arms are computed on the same deduplicated data, so this isolates what the <em>calculation</em> fixes are worth. Figures exclude the ten deactivated duplicate clients, which the plan says to exclude from reporting; including them the shape is identical (432 → 70 days, $25,622.98 → $1,548.64).</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Was anything already balanced disturbed?</h2>
|
||||
<div class="measure">
|
||||
<p>This is the question that matters most for the books, and it has two halves. No day that balanced under production went out of balance — that count is zero. But <strong>237 client-days that were balanced had their line amounts change anyway</strong>, and a balance-only view would hide every one of them.</p>
|
||||
<p>None of those 237 came from the calculation fixes. Both fixes are provably inert on balanced days — see the decomposition below. They came from the data being corrected underneath.</p>
|
||||
</div>
|
||||
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Outcome, production → final</th><th class="n">Client-days</th><th>Reading</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Out of balance → balanced</td><td class="n good">309</td><td class="dim">the intended repair</td></tr>
|
||||
<tr><td>Balanced → out of balance</td><td class="n good">0</td><td class="dim">nothing was broken</td></tr>
|
||||
<tr><td>Balanced → balanced, numbers changed</td><td class="n" style="color:var(--warn);font-weight:650">237</td><td class="dim">amounts moved, balance held</td></tr>
|
||||
<tr><td>Balanced → balanced, identical</td><td class="n">1,652</td><td class="dim">untouched</td></tr>
|
||||
<tr><td>Still out of balance</td><td class="n">44</td><td class="dim">residual, see below</td></tr>
|
||||
<tr class="total"><td>Production summaries in window</td><td class="n">2,242</td><td class="dim">of 6,300 client-day slots</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>What moved on those 237 days</h3>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>Pattern</th><th class="n">Days</th><th>Cause</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Every category 0.00 → real values</td><td class="n">~100</td><td>production held an all-zero summary for a day that had orders</td></tr>
|
||||
<tr><td>Card Payments ↔ Fees reallocation</td><td class="n">~135</td><td>the payout arrived, so the processing fee is now known and booked separately</td></tr>
|
||||
<tr><td>Service Charges line appears</td><td class="n">13</td><td>R2, on days that were already out of balance</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<p>The second pattern is worth reading carefully: <code>Card Payments</code> falls by exactly what <code>Fees</code> gains, so the day stays balanced while the split between the two lines changes. That is a data-freshness effect from re-importing, not a change in how anything is calculated.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Impact by underlying fix</h2>
|
||||
<div class="measure">
|
||||
<p>Applied in sequence, each measured against the state before it. Percentages of a 3,040 client-day population.</p>
|
||||
</div>
|
||||
|
||||
<div class="step" data-step="1">
|
||||
<h3>Data</h3>
|
||||
<div class="step-title">Deduplication — Phase 0, re-key, re-import</div>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>Outcome</th><th class="n">Client-days</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Unchanged</td><td class="n">1,581</td></tr>
|
||||
<tr><td>Numbers changed</td><td class="n">547</td></tr>
|
||||
<tr><td>Out of balance → balanced</td><td class="n good">56</td></tr>
|
||||
<tr><td>Balanced → out of balance</td><td class="n bad">19</td></tr>
|
||||
<tr><td>Balanced → balanced, numbers changed</td><td class="n" style="color:var(--warn);font-weight:650">237</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<p>The data work is the only step that unbalances anything: <strong>19 days</strong> go out of balance purely from correcting the data, mostly where a refund that had been sitting under the twin client now lands on the right one. The two calculation fixes then absorb all 19, which is why the end-to-end figure is zero.</p>
|
||||
</div>
|
||||
|
||||
<div class="step" data-step="2">
|
||||
<h3>R1</h3>
|
||||
<div class="step-title">Tips on untendered orders</div>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>Outcome</th><th class="n">Client-days</th><th class="n">Share</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Unchanged</td><td class="n">2,941</td><td class="n dim">96.7%</td></tr>
|
||||
<tr><td>Out of balance → balanced</td><td class="n good">86</td><td class="n dim">2.8%</td></tr>
|
||||
<tr><td>Balanced → out of balance</td><td class="n good">0</td><td class="n dim">—</td></tr>
|
||||
<tr><td>Balanced → balanced, numbers changed</td><td class="n good">0</td><td class="n dim">—</td></tr>
|
||||
<tr><td>Still out of balance, but closer</td><td class="n">13</td><td class="n dim">0.4%</td></tr>
|
||||
<tr class="total"><td>Days touched · dollars moved</td><td class="n">99</td><td class="n">$973.79</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="step" data-step="3">
|
||||
<h3>R2</h3>
|
||||
<div class="step-title">Square service charges, both signs</div>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>Outcome</th><th class="n">Client-days</th><th class="n">Share</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Unchanged</td><td class="n">2,760</td><td class="n dim">90.8%</td></tr>
|
||||
<tr><td>Out of balance → balanced</td><td class="n good">276</td><td class="n dim">9.1%</td></tr>
|
||||
<tr><td>Balanced → out of balance</td><td class="n good">0</td><td class="n dim">—</td></tr>
|
||||
<tr><td>Balanced → balanced, numbers changed</td><td class="n good">0</td><td class="n dim">—</td></tr>
|
||||
<tr><td>Still out of balance, but closer</td><td class="n">4</td><td class="n dim">0.1%</td></tr>
|
||||
<tr class="total"><td>Days touched · dollars moved</td><td class="n">280</td><td class="n">$23,729.45</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="callout">
|
||||
<span class="h">Both calculation fixes are inert on healthy days</span>
|
||||
<p>Across 3,040 client-days, neither R1 nor R2 changed a single number on a day that was already balanced. Every day they touched was already out of balance. That is the strongest available evidence that they cannot quietly restate correct books — and it is why R2 carries the larger dollar figure without carrying larger risk.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Days production never had</h2>
|
||||
<div class="measure">
|
||||
<p>Of 6,300 client-day slots in the window, production held a summary for only 2,242. The remaining <strong>4,058</strong> had none at all — nothing to compare against, and nothing an accountant could have looked at.</p>
|
||||
<p>They are not evenly spread. Eight consecutive days show <span class="num">210</span> missing summaries each, which is <em>every client in the database</em>:</p>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>Dates</th><th class="n">Missing per day</th><th>Reading</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Jul 15 – Jul 29</td><td class="n">117</td><td class="dim">clients with no summary that day</td></tr>
|
||||
<tr><td class="bad">Jul 30 – Aug 6</td><td class="n bad">210</td><td class="bad">every client — a total coverage hole</td></tr>
|
||||
<tr><td>Aug 7 – Aug 13</td><td class="n">89</td><td class="dim">clients with no summary that day</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="callout warn">
|
||||
<span class="h">The coverage hole is real and self-confirming</span>
|
||||
<p>The plan predicted a global gap at 2026-07-30 → 08-06 from reading the scheduler, which only looks back seven days and so can never backfill a hole older than that. This measurement found the same eight days independently, from the data. Every one of those 1,680 client-days now has a summary.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What is left</h2>
|
||||
<div class="measure">
|
||||
<p>Five client-days above the ten-cent materiality threshold, totalling $404.04. Everything else — 62 days — comes to <strong>$1.62</strong>, with the largest single day at nine cents.</p>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>Client</th><th>Date</th><th class="n">Variance</th><th>Explanation</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="mono">NGBK</td><td>2026-08-06</td><td class="n">+$299.42</td><td>refunds now held by both twins — a re-key consequence</td></tr>
|
||||
<tr><td class="mono">NGDA</td><td>2026-08-01</td><td class="n">−$50.00</td><td>auto-gratuity booked as a service charge</td></tr>
|
||||
<tr><td class="mono">NGEB</td><td>2026-08-10</td><td class="n">−$25.00</td><td>ezCater fee semantics <span class="pill ok">predicted</span></td></tr>
|
||||
<tr><td class="mono">NGEB</td><td>2026-07-29</td><td class="n">−$20.00</td><td>ezCater fee semantics <span class="pill ok">predicted</span></td></tr>
|
||||
<tr><td class="mono">NGPS</td><td>2026-08-12</td><td class="n">+$9.62</td><td>unexplained <span class="pill ok">predicted</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<p>The ten-cent threshold separates register rounding from real variance with nothing sitting near the boundary — the largest sub-threshold day is 9.00¢ and the smallest material one is $9.62, two orders of magnitude apart.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>How this was measured</h2>
|
||||
<div class="measure">
|
||||
<ul>
|
||||
<li>Run against a production backup restored to basis-t <span class="num">209608347</span>, newest transaction 2026-08-14 22:52. Nothing in production was touched.</li>
|
||||
<li>"Before" is production's own stored summaries, read via <code>as-of</code> — not a re-simulation of them. Datomic keeps every past value, so the comparison is against exactly what was there.</li>
|
||||
<li>A day counts as out of balance when debits minus credits is at least half a cent. Line amounts are compared at the cent, so floating-point noise does not read as a change.</li>
|
||||
<li>R1 and R2 only ever add credits, so baseline is derived as <code>fixed + untendered tip + service charges</code>. That identity was checked against a from-scratch baseline recomputation on 20 random client-days and agreed on every one.</li>
|
||||
<li>All 14,458 summaries in the database were recomputed through the real job, not a test harness.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="callout warn">
|
||||
<span class="h">Two defects still open before this ships</span>
|
||||
<p>Re-keying a charge that has two parent orders duplicates the tender, because the other client's import then matches neither key and creates a second charge which cardinality-many appends. The migration must split shared charges first. Separately, <code>remove-voided-orders</code> retracts orders, and 63% of the contended clients' charges have two parents, so a retraction there deletes the other client's payment.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<span>Measured 2026-08-15 on <span class="mono">integreat-prod-restore</span>. Branch <span class="mono">worktree-sales-summary-balance</span>.</span>
|
||||
<span>Service charges map to account 49000 Service Income, chosen to make measurement possible and still pending accounting sign-off. The account affects only whether a day can reach accepted — never whether it balances — so no figure here depends on it.</span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
135
src/clj/auto_ap/jobs/compare_sales_summaries.clj
Normal file
135
src/clj/auto_ap/jobs/compare_sales_summaries.clj
Normal file
@@ -0,0 +1,135 @@
|
||||
(ns auto-ap.jobs.compare-sales-summaries
|
||||
"Compares sales summaries between two points in the same database.
|
||||
|
||||
Datomic keeps every past value, so a recompute can be audited against exactly what was there
|
||||
before by reading `(d/as-of db t)` for some earlier `t` — no snapshot or scratch copy needed.
|
||||
|
||||
The question this exists to answer is narrower than \"did the totals improve\": it is *which
|
||||
days changed, and were any of them already balanced*. A day that was balanced before and still
|
||||
balances after can still have had its line amounts move, and that is a real change to the
|
||||
books even though no red turns green. Counting only balanced/unbalanced transitions would hide
|
||||
it entirely."
|
||||
(:require
|
||||
[auto-ap.datomic :refer [conn]]
|
||||
[auto-ap.datomic.sales-summaries :as d-ss]
|
||||
[clj-time.coerce :as c]
|
||||
[datomic.api :as dc]))
|
||||
|
||||
(def item-read
|
||||
[:sales-summary-item/category
|
||||
:sales-summary-item/manual?
|
||||
:ledger-mapped/amount
|
||||
{:ledger-mapped/ledger-side [:db/ident]}
|
||||
{:ledger-mapped/account [:account/numeric-code]}])
|
||||
|
||||
(defn- cents
|
||||
"Amounts are doubles carrying float noise, so compare them at the cent — the unit the books are
|
||||
actually kept in. Without this, 182.87000000000003 and 182.87 read as a change."
|
||||
[x]
|
||||
(Math/round (* 100.0 (double (or x 0.0)))))
|
||||
|
||||
(defn- line
|
||||
"One item reduced to what a reader would call \"the number\": category, side, amount, account."
|
||||
[item]
|
||||
{:category (:sales-summary-item/category item)
|
||||
:side (get-in item [:ledger-mapped/ledger-side :db/ident])
|
||||
:cents (cents (:ledger-mapped/amount item))
|
||||
:account (get-in item [:ledger-mapped/account :account/numeric-code])})
|
||||
|
||||
(defn summaries-in
|
||||
"`{[client-code date] {:lines … :imbalance … :balanced?}}` for every summary in `[start end)`.
|
||||
|
||||
Keyed by client code and date rather than entity id so the two sides line up even if an entity
|
||||
were recreated between the points being compared."
|
||||
[db start end]
|
||||
(->> (dc/q {:find [(list 'pull '?s [:sales-summary/date
|
||||
{:sales-summary/client [:client/code]}
|
||||
{:sales-summary/items item-read}])]
|
||||
:in '[$ ?start ?end]
|
||||
:where '[[?s :sales-summary/date ?d]
|
||||
[(>= ?d ?start)]
|
||||
[(< ?d ?end)]]}
|
||||
db (c/to-date start) (c/to-date end))
|
||||
(map first)
|
||||
(reduce (fn [acc s]
|
||||
(let [items (map d-ss/<-pulled-item (:sales-summary/items s))]
|
||||
(assoc acc
|
||||
[(get-in s [:sales-summary/client :client/code]) (:sales-summary/date s)]
|
||||
{:lines (frequencies (map line (:sales-summary/items s)))
|
||||
:imbalance (d-ss/imbalance items)
|
||||
:balanced? (d-ss/balanced? items)})))
|
||||
{})))
|
||||
|
||||
(defn- classify
|
||||
"How one client-day differs. `:numbers-changed` is the interesting one — the lines themselves
|
||||
moved, whether or not the day's balance status did."
|
||||
[before after]
|
||||
(cond
|
||||
(nil? before) :added
|
||||
(nil? after) :removed
|
||||
(= (:lines before) (:lines after)) :identical
|
||||
:else :numbers-changed))
|
||||
|
||||
(defn compare-window
|
||||
"Compares every summary in `[start end)` between two database values.
|
||||
|
||||
Returns per-day rows plus the tallies worth reporting, including the one that is easy to miss:
|
||||
days that were **already balanced** and whose numbers moved anyway."
|
||||
[before-db after-db start end]
|
||||
(let [before (summaries-in before-db start end)
|
||||
after (summaries-in after-db start end)
|
||||
rows (for [k (distinct (concat (keys before) (keys after)))
|
||||
:let [b (get before k) a (get after k)]]
|
||||
{:client (first k)
|
||||
:date (second k)
|
||||
:change (classify b a)
|
||||
:was-balanced? (:balanced? b)
|
||||
:now-balanced? (:balanced? a)
|
||||
:before-imbalance (:imbalance b)
|
||||
:after-imbalance (:imbalance a)
|
||||
:lines-before (:lines b)
|
||||
:lines-after (:lines a)})
|
||||
rows (vec rows)
|
||||
changed (filter #(= :numbers-changed (:change %)) rows)]
|
||||
{:rows rows
|
||||
:tally {:compared (count rows)
|
||||
:identical (count (filter #(= :identical (:change %)) rows))
|
||||
:numbers-changed (count changed)
|
||||
:added (count (filter #(= :added (:change %)) rows))
|
||||
:removed (count (filter #(= :removed (:change %)) rows))}
|
||||
:balance-transitions
|
||||
{:unbalanced->balanced (count (filter #(and (false? (:was-balanced? %)) (true? (:now-balanced? %))) rows))
|
||||
:balanced->unbalanced (count (filter #(and (true? (:was-balanced? %)) (false? (:now-balanced? %))) rows))
|
||||
:stayed-balanced (count (filter #(and (true? (:was-balanced? %)) (true? (:now-balanced? %))) rows))
|
||||
:stayed-unbalanced (count (filter #(and (false? (:was-balanced? %)) (false? (:now-balanced? %))) rows))}
|
||||
:previously-balanced-and-changed
|
||||
(->> changed (filter :was-balanced?) vec)}))
|
||||
|
||||
(defn line-diff
|
||||
"Which categories actually moved on one row, as `{category [before-cents after-cents]}`. For
|
||||
reading a handful of rows by hand once the tallies point at them."
|
||||
[row]
|
||||
(let [by-cat (fn [lines] (reduce (fn [m [l n]] (assoc m (:category l) (* n (:cents l)))) {} lines))
|
||||
b (by-cat (:lines-before row))
|
||||
a (by-cat (:lines-after row))]
|
||||
(->> (distinct (concat (keys b) (keys a)))
|
||||
(keep (fn [cat]
|
||||
(let [x (get b cat 0) y (get a cat 0)]
|
||||
(when (not= x y) [cat [(/ x 100.0) (/ y 100.0)]]))))
|
||||
(into {}))))
|
||||
|
||||
(defn compare-against
|
||||
"Convenience: compare the current database against its own past value at basis `t`."
|
||||
[t start end]
|
||||
(let [db (dc/db conn)]
|
||||
(compare-window (dc/as-of db t) db start end)))
|
||||
|
||||
(comment
|
||||
;; the restore point, i.e. production's own summaries before any of this work
|
||||
(def result (compare-against 209608347
|
||||
(clj-time.core/date-time 2026 7 15)
|
||||
(clj-time.core/date-time 2026 8 14)))
|
||||
(:tally result)
|
||||
(:balance-transitions result)
|
||||
(count (:previously-balanced-and-changed result))
|
||||
(map line-diff (take 3 (:previously-balanced-and-changed result))))
|
||||
Reference in New Issue
Block a user