(ns auto-ap.tools.compare-sales-summaries "Compares sales summaries between two points in the same database. A verification tool, not part of the running application: it lives on the test/dev classpath so nothing in production can depend on it. Load it from a REPL when auditing a recompute. 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. DO NOT USE `compare-against` — OR ANY `d/as-of` DATABASE — TO COMPARE AMOUNTS. The obvious reading is that Datomic keeps every past value, so a recompute can be audited against what was there before with no snapshot. That does not hold here: `:ledger-mapped/amount`, `:ledger-mapped/ledger-side` and `:ledger-mapped/account` are all `:db/noHistory true`, so superseded values are discarded rather than retained. A summary that has since been recomputed reads back through `as-of` with its categories intact and its amounts *absent* — which is indistinguishable from a legitimate all-zero day, and quietly turns every rewritten summary into a false \"was balanced, still balances\". To compare amounts, capture `summaries-in` from a live `(d/db conn)` immediately after each run, keep the two captures outside the database, and diff those. `compare-window` is safe when both arguments are live database values; only the historical read is unsound. Categories and which-days-changed do survive `as-of`, since `:sales-summary-item/category` retains history." (: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 "Compare the current database against its own past value at basis `t`. UNSOUND FOR AMOUNTS — see the namespace docstring. The amount, side and account attributes are `:db/noHistory`, so any summary rewritten since `t` reads back with no amounts and appears balanced. Kept only for comparing categories and identifying which days changed." [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))))