Three faults were leaving restaurant days out of balance — one in the data, two in the arithmetic — plus a fourth that turned out to be a missing-data problem and is deliberately left visible. Measured over ninety days on a restored copy of production (210 clients, 18,900 client-days): 1,258 days out of balance and $69,560.10 becomes 279 days and $7,790.54 — of which 171 are not arithmetic faults at all, but days whose sales were never imported. 979 days repaired, none knocked out of balance, and not one already-balanced day altered — verified line by line (category, side, amount to the cent, account), not just on each day's bottom line. THE DATA FAULT Ten Square locations were configured against two client records each. Sales orders scoped their identifier by client; refunds, card payments, payouts and cash-drawer shifts used the bare Square id. Those attributes are :db.unique/identity, so both clients' imports resolved to a single entity and the last writer won — 3,387 refunds, 4,069 payouts and 2,628 cash-drawer shifts changed hands over time, across 19 client pairs of which only 10 are visible in today's configuration. Worse, one payment could belong to two orders. :sales-order/charges is :db/isComponent, so removing a voided order cascaded into payments the other client still needed. Fixes: client-scope the four key schemes; look the record up under both schemes so the change deploys before the migration finishes; and a migration that gives every order its own payment. Run over the whole database that is 19,040,785 orders walked, 9,100,314 payments re-keyed and 200,027 copied, ending with 17,047,142 payments scoped, none left to rename, none unscopable, and no payment owned by more than one order. Idempotent and resumable; about thirteen minutes. THE ARITHMETIC FAULTS - Refunded tips stayed on the books. get-tip summed tips by joining through :sales-order/charges, so a return-only order — no tender to join through — contributed nothing while its reversal sat unread on :sales-order/tip. Additive, not substitutive: where an order does have a tender the tender is the correct source. - Service charges were collected but never earned. Nothing read :sales-order/service-charge. Now credited for Square orders only, both signs, behind summary-service-charges. The flag is off by default, so deploying this changes nothing until a client is opted in. docs/2026-08-15-sales-summary-rollout-plan.md has the steps. WHAT IS DELIBERATELY NOT FIXED 156 of the 279 remaining days carry refunds on a record that recorded no sales at all that day, and 132 of those fall before that client's first ever order. The refunds are not theirs: ownership history shows a $35.35 refund dated 26 February belonging to NGDG that day and taken over by NGDU on 12 August, flipping between the two several times a day. Across nine records, 659 refunds worth $15,225.24 sit on a record dated before its own first order — unscoped keys let whichever import ran last take ownership. A rule closing those days was written and measured (156 days, $4,820.19, nothing broken) and then removed. An unbalanced day is the only visible signal that a restaurant's sales are not being imported; balancing it would remove the alarm and leave the fire. A comment and a test hold that decision in place. Step 9 of the rollout plan is the real fix, and it needs a business decision. SUPPORTING - Install schema attributes before the tuples that compose them. A tuple in schema.edn is built from an attribute in cloud-migration-schema.edn, so every test fixture died in setup — very likely why sales summaries had no tests before this. - Log each day's imbalance and its suspect lines. - Bound the dirty-summary scan to one client: 1,321 ms to 5.6 ms. - compare-sales-summaries lives in test/clj as auto-ap.tools.* — it is a verification harness, not part of the running application. Its docstring now warns that d/as-of cannot be used to compare summary amounts: :ledger-mapped/amount, ledger-side and account are :db/noHistory, so a recomputed summary reads back with its amounts absent and looks like a legitimate balanced day. 26 tests, 62 assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
610 lines
26 KiB
Clojure
610 lines
26 KiB
Clojure
(ns auto-ap.jobs.sales-summaries
|
|
(:require [auto-ap.datomic :refer [conn]]
|
|
[auto-ap.datomic.sales-summaries :as d-ss]
|
|
[auto-ap.jobs.core :refer [execute]]
|
|
[auto-ap.logging :as alog]
|
|
[auto-ap.time :as atime]
|
|
[clj-time.coerce :as c]
|
|
[clj-time.core :as time]
|
|
[clj-time.periodic :as per]
|
|
[clojure.string :as str]
|
|
[com.brunobonacci.mulog :as mu]
|
|
[config.core :refer [env]]
|
|
[datomic.api :as dc]))
|
|
|
|
(defn mark-dirty [client start end]
|
|
(let [client (dc/entid (dc/db conn) client)]
|
|
@(dc/transact conn
|
|
(for [s (per/periodic-seq start
|
|
end
|
|
(time/days 1))]
|
|
{:sales-summary/client client
|
|
:sales-summary/date (c/to-date s)
|
|
:sales-summary/dirty true
|
|
:sales-summary/client+date [client (c/to-date s)]}))))
|
|
|
|
(defn last-n-days [n]
|
|
[(.toDateMidnight (atime/localize (time/plus (time/now) (time/days (- n)))))
|
|
(.toDateMidnight (atime/localize (time/now)))])
|
|
|
|
(defn mark-all-dirty [days]
|
|
(doseq [[c] (dc/q '[:find ?c
|
|
:in $
|
|
:where [_ :sales-order/client ?c]]
|
|
(dc/db conn))]
|
|
(apply mark-dirty c (last-n-days days))))
|
|
|
|
(defn lookup-account [number]
|
|
(ffirst (dc/q '[:find ?a
|
|
:in $ ?number
|
|
:where [?a :account/numeric-code ?number]]
|
|
(dc/db conn)
|
|
number)))
|
|
|
|
(defn delete-all []
|
|
@(dc/transact-async conn
|
|
(->>
|
|
(dc/q '[:find ?ss
|
|
:where [?ss :sales-summary/date]]
|
|
(dc/db conn))
|
|
(map (fn [[ss]]
|
|
[:db/retractEntity ss])))))
|
|
|
|
(def item-read
|
|
"Enough of a summary item to both evaluate `d-ss/accepted?` and transact the item back
|
|
unchanged. `:db/id` matters: `:sales-summary/items` is a component attribute upserted via
|
|
`[:reset-rels ...]`, so an item re-transacted without its id is deleted and recreated."
|
|
'[:db/id
|
|
:sales-summary-item/category
|
|
:sales-summary-item/sort-order
|
|
:sales-summary-item/manual?
|
|
:ledger-mapped/amount
|
|
{:ledger-mapped/ledger-side [:db/ident]}
|
|
{:ledger-mapped/account [:db/id]}])
|
|
|
|
(defn dirty-sales-summaries
|
|
"The client's dirty summaries, with enough of each item to evaluate and re-transact it.
|
|
|
|
`index-pull` returns a lazy seq running from `:start` to the END of the index, so this must
|
|
stop at the client boundary rather than filter: `:sales-summary/client+dirty` sorts by client
|
|
first, so every later client's summaries sit beyond this client's and filtering would walk all
|
|
of them — for every client — pulling their items on the way. That is quadratic in the number of
|
|
summaries, and it showed up as a full refresh degrading from ~180 client-days a minute to ~3 as
|
|
the summary count grew."
|
|
[c]
|
|
(let [client-id (dc/entid (dc/db conn) c)]
|
|
(->> (dc/index-pull (dc/db conn)
|
|
{:index :avet
|
|
:selector (conj '[:sales-summary/date :sales-summary/client :db/id]
|
|
{:sales-summary/items item-read})
|
|
:start [:sales-summary/client+dirty [client-id true]]})
|
|
(take-while (fn [sales-summary]
|
|
(= client-id (:db/id (:sales-summary/client sales-summary))))))))
|
|
|
|
(def default-days
|
|
"How far back the scheduled refresh looks for summaries that still need recomputing."
|
|
7)
|
|
|
|
(defn trailing-window
|
|
"`[start end)` covering the last `days` business days, ending with today. `end` is
|
|
exclusive, matching both `periodic-seq`'s 3-arity and the grid's date filters, so
|
|
`(trailing-window 7)` is day -6 through today inclusive."
|
|
[days]
|
|
[(.toDateMidnight (atime/localize (time/minus (time/now) (time/days (dec days)))))
|
|
(.toDateMidnight (atime/localize (time/plus (time/now) (time/days 1))))])
|
|
|
|
(defn accepted-client+dates
|
|
"Set of `[client-id date]` pairs in `[start end)` whose summary is already accepted, and
|
|
so should be left alone rather than re-marked. Accepted means balanced with every line
|
|
mapped to an account — the condition the grid renders as \"Balanced\"."
|
|
[db start end]
|
|
(->> (dc/q '[:find (pull ?ss selector)
|
|
:in $ ?start ?end selector
|
|
:where
|
|
[?ss :sales-summary/date ?d]
|
|
[(>= ?d ?start)]
|
|
[(< ?d ?end)]]
|
|
db
|
|
(c/to-date start)
|
|
(c/to-date end)
|
|
(conj '[:sales-summary/date {:sales-summary/client [:db/id]}]
|
|
{:sales-summary/items item-read}))
|
|
(map first)
|
|
(filter #(d-ss/accepted? (map d-ss/<-pulled-item (:sales-summary/items %))))
|
|
(map (juxt (comp :db/id :sales-summary/client) :sales-summary/date))
|
|
set))
|
|
|
|
(defn mark-stale-dirty
|
|
"Marks every client/day in the trailing `days` window dirty so `sales-summaries-v2` will
|
|
recompute it, skipping days whose summary is already accepted. Because accepted is derived
|
|
rather than stored, a summary that later falls out of balance is picked up again on the
|
|
next run. Returns the number of client/days marked."
|
|
[days]
|
|
(let [db (dc/db conn)
|
|
[start end] (trailing-window days)
|
|
accepted (accepted-client+dates db start end)
|
|
clients (map first (dc/q '[:find ?c
|
|
:in $
|
|
:where [_ :sales-order/client ?c]]
|
|
db))
|
|
dates (map c/to-date (per/periodic-seq start end (time/days 1)))
|
|
tx-data (for [client clients
|
|
date dates
|
|
:when (not (accepted [client date]))]
|
|
{:sales-summary/client client
|
|
:sales-summary/date date
|
|
:sales-summary/dirty true
|
|
:sales-summary/client+date [client date]})]
|
|
(alog/info ::marking-dirty
|
|
:days days
|
|
:client-count (count clients)
|
|
:accepted-count (count accepted)
|
|
:marked (count tx-data))
|
|
(doseq [batch (partition-all 500 tx-data)]
|
|
@(dc/transact conn batch))
|
|
(count tx-data)))
|
|
|
|
(defn- get-fee [c date]
|
|
(- (or (ffirst (dc/q '[:find ?f
|
|
:in $ ?client ?d
|
|
:where
|
|
[?e :expected-deposit/client ?client]
|
|
[?e :expected-deposit/sales-date ?d]
|
|
[?e :expected-deposit/fee ?f]]
|
|
(dc/db conn)
|
|
c
|
|
date))
|
|
0.0)))
|
|
|
|
(def service-charges-account
|
|
"Where a credited Square service charge lands. 49000 is the existing \"Service Income\"
|
|
revenue account, which is the closest fit for auto-gratuity and catering fees.
|
|
|
|
NEEDS ACCOUNTING SIGN-OFF before `service-charges-flag` is enabled for any client: the wrong
|
|
account misstates revenue, and a category with no account at all keeps a day from ever
|
|
reaching accepted, since `accepted?` requires every line to be mapped."
|
|
49000)
|
|
|
|
(def name->number
|
|
{"gyros and pitas" 40111
|
|
"service charges" service-charges-account
|
|
"returns" 41300
|
|
"card payments" 75460
|
|
"cash payments" 75452
|
|
"cash refunds" 41400
|
|
"food app payments" 72350
|
|
"unknown" 40000
|
|
"discounts" 41000
|
|
"fees" 75400
|
|
"alcohol" 46900
|
|
"beverages" 42000
|
|
"bowls" 40118
|
|
"catering" 43000
|
|
"ezcater catering" 43010
|
|
"desserts" 40116
|
|
"fries" 40117
|
|
"plates" 40113
|
|
"sides" 40115
|
|
"soup & salads" 40114
|
|
"uncategorized" 40000
|
|
"tax" 25700
|
|
"tip" 25500
|
|
"card refunds" 41400
|
|
"food app refunds" 41400})
|
|
|
|
(defn get-payment-items [c date]
|
|
(->>
|
|
(dc/q '[:find ?processor ?type-name (sum ?total)
|
|
:with ?c
|
|
:in $ [?clients ?start-date ?end-date]
|
|
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
|
[?e :sales-order/charges ?c]
|
|
[?c :charge/type-name ?type-name]
|
|
(or-join [?c ?processor]
|
|
(and [?c :charge/processor ?p]
|
|
[?p :db/ident ?processor])
|
|
(and
|
|
(not [?c :charge/processor])
|
|
[(ground :ccp-processor/na) ?processor]))
|
|
[?c :charge/total ?total]]
|
|
(dc/db conn)
|
|
[[c] date date])
|
|
(reduce
|
|
(fn [acc [processor type-name total]]
|
|
(update
|
|
acc
|
|
(cond (= type-name "CARD")
|
|
"Card Payments"
|
|
(= type-name "CASH")
|
|
"Cash Payments"
|
|
(#{"SQUARE_GIFT_CARD" "WALLET" "GIFT_CARD"} type-name)
|
|
"Gift Card Payments"
|
|
(#{:ccp-processor/toast
|
|
#_:ccp-processor/ezcater
|
|
#_:ccp-processor/koala
|
|
:ccp-processor/doordash
|
|
:ccp-processor/grubhub
|
|
:ccp-processor/uber-eats} processor)
|
|
"Food App Payments"
|
|
:else
|
|
"Unknown")
|
|
(fnil + 0.0)
|
|
total))
|
|
{})
|
|
(map (fn [[k v]]
|
|
{:db/id (str (java.util.UUID/randomUUID))
|
|
:sales-summary-item/sort-order 0
|
|
:sales-summary-item/category k
|
|
|
|
:ledger-mapped/amount (if (= "Card Payments" k)
|
|
(- v (get-fee c date))
|
|
v)
|
|
:ledger-mapped/ledger-side :ledger-side/debit}))))
|
|
|
|
(defn get-discounts [c date]
|
|
(when-let [discount (ffirst (dc/q '[:find (sum ?discount)
|
|
:with ?e
|
|
:in $ [?clients ?start-date ?end-date]
|
|
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
|
[?e :sales-order/discount ?discount]]
|
|
(dc/db conn)
|
|
[[c] date date]))]
|
|
{:db/id (str (java.util.UUID/randomUUID))
|
|
:sales-summary-item/sort-order 1
|
|
:sales-summary-item/category "Discounts"
|
|
:ledger-mapped/amount discount
|
|
:ledger-mapped/ledger-side :ledger-side/debit}))
|
|
|
|
(defn get-refund-items [c date]
|
|
(->>
|
|
(dc/q '[:find ?type-name (sum ?t)
|
|
:with ?e
|
|
:in $ [?clients ?start-date ?end-date]
|
|
:where
|
|
:where [(iol-ion.query/scan-sales-refunds $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
|
[?e :sales-refund/type ?type-name]
|
|
[?e :sales-refund/total ?t]]
|
|
(dc/db conn)
|
|
[[c] date date])
|
|
(reduce
|
|
(fn [acc [type-name total]]
|
|
(update
|
|
acc
|
|
(cond (= type-name "CARD")
|
|
"Card Refunds"
|
|
(= type-name "CASH")
|
|
"Cash Refunds"
|
|
:else
|
|
"Food App Refunds")
|
|
(fnil + 0.0)
|
|
total))
|
|
{})
|
|
(map (fn [[k v]]
|
|
{:db/id (str (java.util.UUID/randomUUID))
|
|
:sales-summary-item/sort-order 3
|
|
:sales-summary-item/category k
|
|
:ledger-mapped/amount v
|
|
:ledger-mapped/ledger-side :ledger-side/credit}))))
|
|
|
|
(defn get-fees [c date]
|
|
(when-let [fee (get-fee c date)]
|
|
{:db/id (str (java.util.UUID/randomUUID))
|
|
:sales-summary-item/sort-order 2
|
|
:sales-summary-item/category "Fees"
|
|
:ledger-mapped/amount fee
|
|
:ledger-mapped/ledger-side :ledger-side/debit}))
|
|
|
|
(defn- get-tax [c date]
|
|
{:db/id (str (java.util.UUID/randomUUID))
|
|
:sales-summary-item/category "Tax"
|
|
:sales-summary-item/sort-order 1
|
|
:ledger-mapped/ledger-side :ledger-side/credit
|
|
:ledger-mapped/amount
|
|
(or (ffirst (dc/q '[:find (sum ?tax)
|
|
:with ?e
|
|
:in $ [?clients ?start-date ?end-date]
|
|
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
|
[?e :sales-order/tax ?tax]
|
|
#_[?e :sales-order/charges ?c]
|
|
#_[?c :charge/tax ?tax]]
|
|
(dc/db conn)
|
|
[[c] date date]))
|
|
0.0)})
|
|
|
|
(defn- tendered-tip
|
|
"Tips read off the tenders, which is where a tip actually settles."
|
|
[c date]
|
|
(or (ffirst (dc/q '[:find (sum ?tip)
|
|
:with ?c
|
|
:in $ [?clients ?start-date ?end-date]
|
|
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
|
[?e :sales-order/charges ?c]
|
|
[?c :charge/tip ?tip]]
|
|
(dc/db conn)
|
|
[[c] date date]))
|
|
0.0))
|
|
|
|
(defn- untendered-tip
|
|
"Tips on orders that carry no tender at all. A return-only order reverses its tip on
|
|
`:sales-order/tip` but has no charge to join through, so the reversal is invisible to
|
|
`tendered-tip` and the day ends up crediting a tip that was handed back."
|
|
[c date]
|
|
(or (ffirst (dc/q '[:find (sum ?tip)
|
|
:with ?e
|
|
:in $ [?clients ?start-date ?end-date]
|
|
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
|
[?e :sales-order/tip ?tip]
|
|
(not [?e :sales-order/charges])]
|
|
(dc/db conn)
|
|
[[c] date date]))
|
|
0.0))
|
|
|
|
(defn- get-tip
|
|
"Tendered tips plus the tips on untendered orders. Additive rather than substitutive on
|
|
purpose: where an order does have a tender, the tender is the correct source, and real
|
|
orders exist whose tender carries a tip their `:sales-order/tip` does not — auto-gratuity
|
|
booked as a service charge, and wallet tips absent from the net amounts. Reading the order
|
|
instead of the tender would drop those."
|
|
[c date]
|
|
{:ledger-mapped/ledger-side :ledger-side/credit
|
|
:sales-summary-item/sort-order 2
|
|
:db/id (str (java.util.UUID/randomUUID))
|
|
|
|
:sales-summary-item/category "Tip"
|
|
:ledger-mapped/amount (+ (tendered-tip c date)
|
|
(untendered-tip c date))})
|
|
|
|
(defn- get-sales [c date]
|
|
(let [sales (->> (dc/q '[:find ?category (sum ?total) (sum ?tax) (sum ?discount)
|
|
:with ?e ?li
|
|
:in $ [?clients ?start-date ?end-date]
|
|
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
|
[?e :sales-order/line-items ?li]
|
|
[(get-else $ ?li :order-line-item/category "Unknown") ?category]
|
|
[?li :order-line-item/total ?total]
|
|
[?li :order-line-item/tax ?tax]
|
|
[?li :order-line-item/discount ?discount]]
|
|
(dc/db conn)
|
|
[[c] date date]))]
|
|
(for [[category total tax discount] sales]
|
|
{:db/id (str (java.util.UUID/randomUUID))
|
|
:sales-summary-item/category category
|
|
:sales-summary-item/sort-order 0
|
|
:sales-summary-item/total total
|
|
:sales-summary-item/net (- (+ total discount) tax)
|
|
:sales-summary-item/tax tax
|
|
:sales-summary-item/discount discount
|
|
:ledger-mapped/ledger-side :ledger-side/credit
|
|
:ledger-mapped/amount (- (+ total discount) tax)
|
|
#_#_:ledger-mapped/account nil})))
|
|
|
|
;; A day carrying refunds and no sales at all is left out of balance on purpose. It is tempting
|
|
;; to close it by booking a return against the day's refunds — the arithmetic works, and no
|
|
;; trading day could be affected. Do not. Those days are overwhelmingly not "a refund settled
|
|
;; while the restaurant was shut": they are days whose *orders were never imported*, on client
|
|
;; records that took ownership of another record's refunds through the unscoped keys this branch
|
|
;; fixes. Balancing them would convert the only signal that a client's sales are missing into
|
|
;; silence. See `docs/2026-08-15-sales-summary-rollout-plan.md`.
|
|
|
|
(defn- get-returns [c date]
|
|
(when-let [amount (ffirst (dc/q '[:find (sum ?r)
|
|
:with ?e
|
|
:in $ [?clients ?start-date ?end-date]
|
|
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
|
[?e :sales-order/returns ?r]
|
|
|
|
#_[?e :sales-order/charges ?c]
|
|
#_[?c :charge/tax ?tax]]
|
|
(dc/db conn)
|
|
[[c] date date]))]
|
|
{:db/id (str (java.util.UUID/randomUUID))
|
|
:sales-summary-item/category "Returns"
|
|
|
|
:ledger-mapped/amount amount
|
|
:ledger-mapped/ledger-side :ledger-side/debit}))
|
|
|
|
(def service-charges-flag
|
|
"Per-client rollout lever for crediting Square service charges, in the same style as
|
|
`new-square` and `import-custom-amount`. Absent, the summary behaves exactly as it does
|
|
today."
|
|
"summary-service-charges")
|
|
|
|
(defn- service-charges-enabled? [c]
|
|
(contains? (set (:client/feature-flags (dc/pull (dc/db conn) '[:client/feature-flags] c)))
|
|
service-charges-flag))
|
|
|
|
(defn service-charge-total
|
|
"Square service charges for the day, both signs.
|
|
|
|
A service charge is collected inside the card tender but nothing credits it, so every order
|
|
carrying one leaves the day short by exactly that amount. Both signs matter: a returned
|
|
catering fee arrives as a negative service charge and is subtracted back out of
|
|
`:sales-order/returns`, so dropping negatives would lose the reversal.
|
|
|
|
The vendor gate is load-bearing — ezCater service charges are commission deducted from the
|
|
restaurant rather than collected from the diner, and crediting those would make things worse.
|
|
It matches on `:sales-order/vendor` where that is set and falls back to the external id
|
|
prefix where it is not, because whole eras of Square orders carry no vendor attribute at all
|
|
and a gate on vendor alone silently credits nothing.
|
|
|
|
Kept separate from the rollout flag so the arithmetic can be measured on its own."
|
|
[c date]
|
|
(ffirst (dc/q '[:find (sum ?service-charge)
|
|
:with ?e
|
|
:in $ [?clients ?start-date ?end-date]
|
|
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
|
[?e :sales-order/service-charge ?service-charge]
|
|
(or-join [?e]
|
|
[?e :sales-order/vendor :vendor/ccp-square]
|
|
(and (not [?e :sales-order/vendor])
|
|
[?e :sales-order/external-id ?external-id]
|
|
[(clojure.string/starts-with? ?external-id "square/order/")]))]
|
|
(dc/db conn)
|
|
[[c] date date])))
|
|
|
|
(defn- get-service-charges
|
|
"The day's service charges as a summary item, for clients opted in to the rollout."
|
|
[c date]
|
|
(when (service-charges-enabled? c)
|
|
(when-let [amount (service-charge-total c date)]
|
|
(when-not (zero? amount)
|
|
{:db/id (str (java.util.UUID/randomUUID))
|
|
:sales-summary-item/category "Service Charges"
|
|
:sales-summary-item/sort-order 2
|
|
:ledger-mapped/amount amount
|
|
:ledger-mapped/ledger-side :ledger-side/credit}))))
|
|
|
|
(def ^:private suspect-categories
|
|
"The terms a balancing investigation keeps returning to. Logged beside the imbalance so a
|
|
day's shape can be read out of the logs without re-running the job."
|
|
["Tip" "Service Charges" "Returns" "Card Refunds" "Cash Refunds" "Food App Refunds"])
|
|
|
|
(defn- suspect-totals
|
|
"Amounts for `suspect-categories` present on this day, omitting the ones that are zero."
|
|
[items]
|
|
(into {}
|
|
(for [category suspect-categories
|
|
:let [amount (->> items
|
|
(filter #(= category (:sales-summary-item/category %)))
|
|
(map #(:ledger-mapped/amount % 0.0))
|
|
(reduce + 0.0))]
|
|
:when (not (zero? amount))]
|
|
[category amount])))
|
|
|
|
(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)
|
|
(let [manual-items (->> existing-summary
|
|
:sales-summary/items
|
|
(filter :sales-summary-item/manual?)
|
|
(map d-ss/<-pulled-item))
|
|
calculated-items (->>
|
|
(get-sales c date)
|
|
(concat (get-payment-items c date))
|
|
(concat (get-refund-items c date))
|
|
(cons (get-discounts c date))
|
|
(cons (get-fees c date))
|
|
(cons (get-tax c date))
|
|
(cons (get-tip c date))
|
|
(cons (get-service-charges c date))
|
|
(cons (get-returns c date))
|
|
(filter identity)
|
|
(map (fn [z]
|
|
(assoc z :ledger-mapped/account (some-> z :sales-summary-item/category str/lower-case name->number lookup-account)
|
|
:sales-summary-item/manual? false))))
|
|
all-items (concat calculated-items manual-items)
|
|
result {:db/id id
|
|
:sales-summary/client c
|
|
:sales-summary/date date
|
|
:sales-summary/dirty false
|
|
:sales-summary/client+date [c date]
|
|
:sales-summary/items all-items}]
|
|
(if (seq (:sales-summary/items result))
|
|
(do
|
|
(alog/info ::upserting-summaries
|
|
:category-count (count (:sales-summary/items result))
|
|
:imbalance (d-ss/imbalance all-items)
|
|
:balanced? (d-ss/balanced? all-items)
|
|
:suspect-totals (suspect-totals all-items))
|
|
@(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 $
|
|
:where [?sos :sales-summary/client]]
|
|
(dc/db conn))
|
|
(map (fn [[sos]]
|
|
[:db/retractEntity sos])))))
|
|
|
|
(comment
|
|
(auto-ap.datomic/transact-schema conn)
|
|
|
|
(apply mark-dirty [:client/code "NGCL"] (last-n-days 30))
|
|
|
|
(apply mark-dirty [:client/code "NGDG"] (last-n-days 30))
|
|
|
|
(dirty-sales-summaries [:client/code "NGWH"])
|
|
|
|
(apply mark-dirty [:client/code "NGWH"] (last-n-days 5))
|
|
|
|
(iol-ion.tx.upsert-sales-summary-ledger/summary->journal-entry (dc/db conn) 17592314245819)
|
|
|
|
(iol-ion.tx.upsert-sales-summary-ledger/upsert-sales-summary (dc/db conn) {:db/id 17592314241429})
|
|
|
|
(mark-all-dirty 5)
|
|
(delete-all)
|
|
|
|
(sales-summaries-v2)
|
|
|
|
1
|
|
|
|
(dc/q '[:find (pull ?sos [* {:sales-summary/sales-items [*]}])
|
|
:in $
|
|
:where [?sos :sales-summary/client [:client/code "NGHW"]]
|
|
[?sos :sales-summary/date ?d]
|
|
[(= ?d #inst "2024-04-10T00:00:00-07:00")]]
|
|
(dc/db conn))
|
|
|
|
(dc/q '[:find ?n ?p2 (sum ?total)
|
|
:with ?c
|
|
:in $ [?clients ?start-date ?end-date]
|
|
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
|
[?e :sales-order/charges ?c]
|
|
[?c :charge/type-name ?n]
|
|
[?c :charge/processor ?p]
|
|
[?p :db/ident ?p2]
|
|
[?c :charge/total ?total]]
|
|
(dc/db conn)
|
|
[[(auto-ap.datomic/pull-attr (dc/db conn) :db/id [:client/code "NGHW"])] #inst "2024-04-11T00:00:00-07:00" #inst "2024-04-11T00:00:00-07:00"])
|
|
|
|
(dc/q '[:find ?n
|
|
:in $ [?clients ?start-date ?end-date]
|
|
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
|
[?e :sales-order/line-items ?li]
|
|
[?li :order-line-item/item-name ?n]]
|
|
(dc/db conn)
|
|
[[(auto-ap.datomic/pull-attr (dc/db conn) :db/id [:client/code "NGCL"])] #inst "2024-04-11T00:00:00-07:00" #inst "2024-04-24T00:00:00-07:00"])
|
|
|
|
@(dc/transact conn [{:db/id :sales-summary/total-tax :db/ident :sales-summary/total-tax-legacy}
|
|
{:db/id :sales-summary/total-tip :db/ident :sales-summary/total-tip-legacy}])
|
|
|
|
(auto-ap.datomic/transact-schema conn))
|
|
|
|
(defn days-arg
|
|
"Trailing-window size from the job's `args`, e.g. `{:days 30}` set as a container override
|
|
from the admin Background Jobs page for an ad-hoc wider backfill. Values arrive as EDN but
|
|
may still be strings, so coerce defensively the way load-historical-sales does."
|
|
[args]
|
|
(let [days (:days args)]
|
|
(cond-> (or days default-days)
|
|
(string? days) (#(Long/parseLong %)))))
|
|
|
|
(defn refresh-sales-summaries
|
|
"Marks the trailing `days` window dirty, skipping accepted summaries, then recomputes
|
|
everything left dirty."
|
|
([] (refresh-sales-summaries default-days))
|
|
([days]
|
|
(mark-stale-dirty days)
|
|
(sales-summaries-v2)))
|
|
|
|
(defn -main [& _]
|
|
(execute "sales-summaries" #(refresh-sales-summaries (days-arg (:args env)))))
|