diff --git a/docs/2026-08-15-sales-summary-balance-validation.md b/docs/2026-08-15-sales-summary-balance-validation.md index 6c4a5899..396e6cbc 100644 --- a/docs/2026-08-15-sales-summary-balance-validation.md +++ b/docs/2026-08-15-sales-summary-balance-validation.md @@ -311,3 +311,83 @@ own scoped key — gained only the 2 that were genuinely new. After that, steady **State the restore was left in:** twins re-activated, i.e. Phase 0 deliberately undone for this test. Re-applying it is the ten-datom retraction recorded above. + + +--- + +# Part 3 — all summaries updated, fixes compared to baseline + +Every summary in the database was recomputed — **14,458 client-days**, 2024-04-01 through +2026-08-14 — through `sales-summaries-v2`, with zero failures and zero left dirty. The +comparison below is the **last 30 days, 2026-07-15 → 2026-08-13**. + +Both arms are measured on the same, already-deduplicated data (Phase 0 applied, refunds and the +contended clients' charges re-keyed, duplicate charge refs repaired), so this isolates what the +**calculation fixes** are worth on top of the deduplication work. + +## Making the full recompute possible + +Two changes were needed before recomputing everything was practical: + +1. **`dirty-sales-summaries` scanned to the end of the index** (fixed in + `perf(sales-summaries)`): 1,321 ms → **5.6 ms** per client, a 237× improvement, identical + results. +2. **The driver was one serial `doseq`.** `refresh-client!` was split out of `sales-summaries-v2` + so a client's work stands alone. Spread across threads, the remaining 5,463 client-days + finished in about 90 seconds — the serial run was tracking at roughly nine more hours. + +## Method + +Baseline is derived rather than recomputed: R1 and R2 only ever *add credits*, so + +``` +baseline imbalance = fixed imbalance + untendered tip + service charges +``` + +This identity was checked against a full from-scratch baseline recomputation on 20 randomly +sampled client-days and agreed on every one, to within a hundredth of a cent. + +## Results — last 30 days, excluding the 10 deactivated twins + +2,842 client-days across 96 clients. + +| | Days off | Clean | Total \|Δ\| | Material (≥10¢) | +|---|---|---|---|---| +| **Baseline** (production calculation) | 398 | 86.00% | $22,527.40 | 336 | +| **Fixed** (R1 + R2) | **67** | **97.64%** | **$405.66** | **5** | + +**331 client-days fixed, 0 regressed.** Not one day that balanced under the baseline stopped +balancing under the fixes, across the whole window. + +Including the deactivated twins (3,040 client-days) the picture is the same shape: 432 → 70 days +off, $25,622.98 → $1,548.64. + +### What is left + +| Client | Date | Δ | | +|---|---|---|---| +| NGBK | 2026-08-06 | +$299.42 | refunds now held by both twins — see below | +| NGDA | 2026-08-01 | −$50.00 | auto-gratuity booked as a service charge | +| NGEB | 2026-08-10 | −$25.00 | ezCater fee semantics, predicted by the plan | +| NGEB | 2026-07-29 | −$20.00 | ezCater fee semantics, predicted by the plan | +| NGPS | 2026-08-12 | +$9.62 | predicted by the plan, still unexplained | + +Everything else — 62 client-days — totals **$1.62**, with the largest single day at **9.00¢**. +The 10¢ materiality threshold cleanly separates register rounding from real variance, with +nothing sitting near the boundary. + +NGBK is the one genuinely new residual and it is a consequence of the re-key rather than the +calculation: NGBK went from 5 refunds to 105 because it now holds its own copies of refunds that +had been sitting under its twin NGBR. Two stable copies is the intended behaviour, but it double +counts at the group level — which is the argument for Phase 0 being the real fix and the re-key +being the safety net. + +### Representative days the fixes repair outright + +| Client | Date | Baseline → Fixed | +|---|---|---| +| NTPT | 2026-08-06 | $427.10 → $0.00 | +| NGFO | 2026-07-16 | $275.23 → $0.00 | +| NGRN | 2026-08-06 | $236.96 → $0.00 | +| N-30008 | 2026-07-24 | $229.42 → $0.00 | +| N-30003 | 2026-07-18 | $225.00 → $0.00 | diff --git a/src/clj/auto_ap/jobs/sales_summaries.clj b/src/clj/auto_ap/jobs/sales_summaries.clj index 14251c0e..92d8acf2 100644 --- a/src/clj/auto_ap/jobs/sales_summaries.clj +++ b/src/clj/auto_ap/jobs/sales_summaries.clj @@ -463,12 +463,14 @@ :when (not (zero? amount))] [category amount]))) -(defn sales-summaries-v2 [] - (doseq [[c client-code] (dc/q '[:find ?c ?client-code - :in $ - :where [?c :client/code ?client-code]] - (dc/db conn)) - {:sales-summary/keys [date] :db/keys [id] :as existing-summary} (dirty-sales-summaries c)] +(defn refresh-client! + "Recomputes every dirty summary for one client. + + Split out of the driver loop so a client's work stands on its own: it can be run for a single + client, and a backfill over the whole history can spread clients across threads instead of + grinding through the largest ones one day at a time." + [c client-code] + (doseq [{:sales-summary/keys [date] :db/keys [id] :as existing-summary} (dirty-sales-summaries c)] (mu/with-context {:client-code client-code :date date} (alog/info ::updating) @@ -507,6 +509,16 @@ @(dc/transact conn [[:upsert-sales-summary result]])) @(dc/transact conn [{:db/id id :sales-summary/dirty false}])))))) + +(defn sales-summaries-v2 + "Recomputes every dirty summary, client by client." + [] + (doseq [[c client-code] (dc/q '[:find ?c ?client-code + :in $ + :where [?c :client/code ?client-code]] + (dc/db conn))] + (refresh-client! c client-code))) + (defn reset-summaries [] @(dc/transact conn (->> (dc/q '[:find ?sos :in $