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:
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