fix(sales-summaries): stop days falling out of balance #18

Open
notid wants to merge 9 commits from sales-summary-balancing-v2 into master
6 changed files with 418 additions and 285 deletions
Showing only changes of commit 95d0537c69 - Show all commits

View File

@@ -0,0 +1,55 @@
(ns auto-ap.datomic.sales-summaries
(:require
[iol-ion.query :refer [dollars=]]))
(defn- ledger-side
"The ledger side of an item as a keyword, whether it arrived as a plain keyword (a
transaction map, or a pull using `:xform iol-ion.query/ident`) or as the `{:db/ident ...}`
map a plain `pull` returns. Resolving both shapes here matters: items whose side does not
compare equal are counted on neither side, which would leave a summary looking balanced at
zero and therefore silently accepted."
[item]
(let [side (:ledger-mapped/ledger-side item)]
(if (map? side) (:db/ident side) side)))
(defn- side-total [side items]
(->> items
(filter #(= side (ledger-side %)))
(map #(:ledger-mapped/amount % 0.0))
(reduce + 0.0)))
(defn total-debits [items]
(side-total :ledger-side/debit items))
(defn total-credits [items]
(side-total :ledger-side/credit items))
(defn fully-mapped? [items]
(every? :ledger-mapped/account items))
(defn fully-sided?
"Every item says which side of the ledger it belongs on. Guards `accepted?` against
reading a collection of sideless items as balanced at zero."
[items]
(every? #(#{:ledger-side/debit :ledger-side/credit} (ledger-side %)) items))
(defn balanced? [items]
(dollars= (total-debits items) (total-credits items)))
(defn accepted?
"True once a summary is finished: every line is mapped to an account and debits equal
credits. This is the same condition the sales summaries grid renders as \"Balanced\", and
the condition the scheduled refresh treats as \"leave this alone\"."
[items]
(boolean (and (seq items)
(fully-mapped? items)
(fully-sided? items)
(balanced? items))))
(defn <-pulled-item
"Flattens the ref values on a pulled sales summary item back to the scalars a transaction
expects. `accepted?` reads either shape, so this is only needed on the write path."
[item]
(cond-> item
(map? (:ledger-mapped/ledger-side item)) (update :ledger-mapped/ledger-side :db/ident)
(map? (:ledger-mapped/account item)) (update :ledger-mapped/account :db/id)))

View File

@@ -1,5 +1,6 @@
(ns auto-ap.jobs.sales-summaries (ns auto-ap.jobs.sales-summaries
(:require [auto-ap.datomic :refer [conn]] (:require [auto-ap.datomic :refer [conn]]
[auto-ap.datomic.sales-summaries :as d-ss]
[auto-ap.jobs.core :refer [execute]] [auto-ap.jobs.core :refer [execute]]
[auto-ap.logging :as alog] [auto-ap.logging :as alog]
[auto-ap.time :as atime] [auto-ap.time :as atime]
@@ -8,6 +9,7 @@
[clj-time.periodic :as per] [clj-time.periodic :as per]
[clojure.string :as str] [clojure.string :as str]
[com.brunobonacci.mulog :as mu] [com.brunobonacci.mulog :as mu]
[config.core :refer [env]]
[datomic.api :as dc])) [datomic.api :as dc]))
(defn mark-dirty [client start end] (defn mark-dirty [client start end]
@@ -39,7 +41,6 @@
(dc/db conn) (dc/db conn)
number))) number)))
(defn delete-all [] (defn delete-all []
@(dc/transact-async conn @(dc/transact-async conn
(->> (->>
@@ -49,17 +50,91 @@
(map (fn [[ss]] (map (fn [[ss]]
[:db/retractEntity 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 [c] (defn dirty-sales-summaries [c]
(let [client-id (dc/entid (dc/db conn) c)] (let [client-id (dc/entid (dc/db conn) c)]
(->> (dc/index-pull (dc/db conn) (->> (dc/index-pull (dc/db conn)
{:index :avet {:index :avet
:selector '[:sales-summary/date :sales-summary/client :db/id] :selector (conj '[:sales-summary/date :sales-summary/client :db/id]
{:sales-summary/items item-read})
:start [:sales-summary/client+dirty [client-id true]]}) :start [:sales-summary/client+dirty [client-id true]]})
(filter (fn [sales-summary] (filter (fn [sales-summary]
(= client-id (:db/id (:sales-summary/client 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] (defn- get-fee [c date]
(- (or (ffirst (dc/q '[:find ?f (- (or (ffirst (dc/q '[:find ?f
:in $ ?client ?d :in $ ?client ?d
@@ -192,8 +267,6 @@
:ledger-mapped/amount v :ledger-mapped/amount v
:ledger-mapped/ledger-side :ledger-side/credit})))) :ledger-mapped/ledger-side :ledger-side/credit}))))
(defn get-fees [c date] (defn get-fees [c date]
(when-let [fee (get-fee c date)] (when-let [fee (get-fee c date)]
{:db/id (str (java.util.UUID/randomUUID)) {:db/id (str (java.util.UUID/randomUUID))
@@ -287,7 +360,8 @@
(alog/info ::updating) (alog/info ::updating)
(let [manual-items (->> existing-summary (let [manual-items (->> existing-summary
:sales-summary/items :sales-summary/items
(filter :sales-summary-item/manual?)) (filter :sales-summary-item/manual?)
(map d-ss/<-pulled-item))
calculated-items (->> calculated-items (->>
(get-sales c date) (get-sales c date)
(concat (get-payment-items c date)) (concat (get-payment-items c date))
@@ -315,7 +389,6 @@
@(dc/transact conn [[:upsert-sales-summary result]])) @(dc/transact conn [[:upsert-sales-summary result]]))
@(dc/transact conn [{:db/id id :sales-summary/dirty false}])))))) @(dc/transact conn [{:db/id id :sales-summary/dirty false}]))))))
(defn reset-summaries [] (defn reset-summaries []
@(dc/transact conn (->> (dc/q '[:find ?sos @(dc/transact conn (->> (dc/q '[:find ?sos
:in $ :in $
@@ -324,9 +397,6 @@
(map (fn [[sos]] (map (fn [[sos]]
[:db/retractEntity sos]))))) [:db/retractEntity sos])))))
(comment (comment
(auto-ap.datomic/transact-schema conn) (auto-ap.datomic/transact-schema conn)
@@ -336,26 +406,19 @@
(dirty-sales-summaries [:client/code "NGWH"]) (dirty-sales-summaries [:client/code "NGWH"])
(apply mark-dirty [:client/code "NGWH"] (last-n-days 5)) (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/summary->journal-entry (dc/db conn) 17592314245819)
(iol-ion.tx.upsert-sales-summary-ledger/upsert-sales-summary (dc/db conn) {:db/id 17592314241429}) (iol-ion.tx.upsert-sales-summary-ledger/upsert-sales-summary (dc/db conn) {:db/id 17592314241429})
(mark-all-dirty 5) (mark-all-dirty 5)
(delete-all) (delete-all)
(sales-summaries-v2) (sales-summaries-v2)
1 1
(dc/q '[:find (pull ?sos [* {:sales-summary/sales-items [*]}]) (dc/q '[:find (pull ?sos [* {:sales-summary/sales-items [*]}])
:in $ :in $
:where [?sos :sales-summary/client [:client/code "NGHW"]] :where [?sos :sales-summary/client [:client/code "NGHW"]]
@@ -386,15 +449,24 @@
@(dc/transact conn [{:db/id :sales-summary/total-tax :db/ident :sales-summary/total-tax-legacy} @(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}]) {:db/id :sales-summary/total-tip :db/ident :sales-summary/total-tip-legacy}])
(auto-ap.datomic/transact-schema conn) (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 [& _] (defn -main [& _]
(execute "sales-summaries" sales-summaries-v2)) (execute "sales-summaries" #(refresh-sales-summaries (days-arg (:args env)))))

View File

@@ -13,6 +13,7 @@
[auto-ap.jobs.load-historical-sales :as job-load-historical-sales] [auto-ap.jobs.load-historical-sales :as job-load-historical-sales]
[auto-ap.jobs.plaid :as job-plaid] [auto-ap.jobs.plaid :as job-plaid]
[auto-ap.jobs.register-invoice-import :as job-register-invoice-import] [auto-ap.jobs.register-invoice-import :as job-register-invoice-import]
[auto-ap.jobs.sales-summaries :as job-sales-summaries]
[auto-ap.jobs.square :as job-square] [auto-ap.jobs.square :as job-square]
[auto-ap.jobs.sysco :as job-sysco] [auto-ap.jobs.sysco :as job-sysco]
[auto-ap.jobs.vendor-usages :as job-vendor-usages] [auto-ap.jobs.vendor-usages :as job-vendor-usages]
@@ -33,7 +34,6 @@
(.addShutdownHook (Runtime/getRuntime) (.addShutdownHook (Runtime/getRuntime)
(Thread. f))) (Thread. f)))
(defn gzip-handler [] (defn gzip-handler []
(let [gz (GzipHandler.)] (let [gz (GzipHandler.)]
(doto gz (doto gz
@@ -126,6 +126,9 @@
(= job "close-auto-invoices") (= job "close-auto-invoices")
(job-close-auto-invoices/-main) (job-close-auto-invoices/-main)
(= job "sales-summaries")
(job-sales-summaries/-main)
(= job "ezcater-upsert") (= job "ezcater-upsert")
(job-ezcater-upsert/-main) (job-ezcater-upsert/-main)

View File

@@ -36,7 +36,6 @@
(sort-by :created-at) (sort-by :created-at)
reverse)) reverse))
(defn is-background-job? (defn is-background-job?
"This function checks whether a given task is a background job. "This function checks whether a given task is a background job.
It does this by checking the environment of the task's container definitions for an environment variable It does this by checking the environment of the task's container definitions for an environment variable
@@ -107,8 +106,7 @@
:entity-name "Job" :entity-name "Job"
:query-schema query-schema :query-schema query-schema
:route :admin-job-table :route :admin-job-table
:headers [ :headers [{:key "start"
{:key "start"
:name "Start" :name "Start"
:render #(some-> % :start-date (atime/unparse-local atime/standard-time))} :render #(some-> % :start-date (atime/unparse-local atime/standard-time))}
{:key "end" {:key "end"
@@ -176,8 +174,7 @@
:name (fc/field-name) :name (fc/field-name)
:value (fc/field-value)})]))] :value (fc/field-value)})]))]
(= "register-invoice-import" name) (= "register-invoice-import" name)
[ [(fc/with-field :invoice-url
(fc/with-field :invoice-url
(com/validated-field {:label "Url" (com/validated-field {:label "Url"
:errors (fc/field-errors)} :errors (fc/field-errors)}
[:div.flex.place-items-center.gap-2 [:div.flex.place-items-center.gap-2
@@ -186,8 +183,7 @@
:name (fc/field-name) :name (fc/field-name)
:value (fc/field-value)})]))] :value (fc/field-value)})]))]
(= "load-historical-sales" name) (= "load-historical-sales" name)
[ [(fc/with-field :client
(fc/with-field :client
(com/validated-field {:label "Client" (com/validated-field {:label "Client"
:errors (fc/field-errors)} :errors (fc/field-errors)}
(com/typeahead {:name (fc/field-name) (com/typeahead {:name (fc/field-name)
@@ -201,10 +197,14 @@
(com/text-input {:placeholder "60" (com/text-input {:placeholder "60"
:name (fc/field-name) :name (fc/field-name)
:value (fc/field-value)})))] :value (fc/field-value)})))]
:else nil)) (= "sales-summaries" name)
[(fc/with-field :days
(com/validated-field {:label "Days to refresh"
) :errors (fc/field-errors)}
(com/text-input {:placeholder "7"
:name (fc/field-name)
:value (fc/field-value)})))]
:else nil)))
(defn subform [{{:keys [name]} :query-params}] (defn subform [{{:keys [name]} :query-params}]
(html-response (html-response
@@ -239,6 +239,7 @@
["register-invoice-import" "Register Invoice Import "] ["register-invoice-import" "Register Invoice Import "]
["ezcater-upsert" "Upsert recent ezcater orders"] ["ezcater-upsert" "Upsert recent ezcater orders"]
["load-historical-sales" "Load Historical Square Sales"] ["load-historical-sales" "Load Historical Square Sales"]
["sales-summaries" "Refresh Sales Summaries"]
["export-backup" "Export Backup"]] ["export-backup" "Export Backup"]]
:hx-get (bidi/path-for ssr-routes/only-routes :hx-get (bidi/path-for ssr-routes/only-routes
:admin-job-subform) :admin-job-subform)
@@ -256,8 +257,7 @@
[:ledger-url {:optional true} [:string {:min 1}]] [:ledger-url {:optional true} [:string {:min 1}]]
[:invoice-url {:optional true} [:string {:min 1}]] [:invoice-url {:optional true} [:string {:min 1}]]
[:client {:optional true} entity-id] [:client {:optional true} entity-id]
[:days {:optional true} [:int {:min 1 :max 120}]] [:days {:optional true} [:int {:min 1 :max 120}]]]))
]))
(def key->handler (def key->handler
(apply-middleware-to-all-handlers (apply-middleware-to-all-handlers

View File

@@ -4,6 +4,7 @@
:refer [apply-pagination apply-sort-3 conn merge-query pull-many :refer [apply-pagination apply-sort-3 conn merge-query pull-many
query2]] query2]]
[auto-ap.datomic.accounts :as d-accounts] [auto-ap.datomic.accounts :as d-accounts]
[auto-ap.datomic.sales-summaries :refer [total-credits total-debits]]
[auto-ap.graphql.utils :refer [extract-client-ids]] [auto-ap.graphql.utils :refer [extract-client-ids]]
[auto-ap.query-params :refer [wrap-copy-qp-pqp]] [auto-ap.query-params :refer [wrap-copy-qp-pqp]]
[auto-ap.client-routes :as client-routes] [auto-ap.client-routes :as client-routes]
@@ -116,18 +117,6 @@
(defn sort-items [ss] (defn sort-items [ss]
(sort-by (juxt :ledger-mapped/ledger-side :sales-summary-item/sort-order :sales-summary-item/category) ss)) (sort-by (juxt :ledger-mapped/ledger-side :sales-summary-item/sort-order :sales-summary-item/category) ss))
(defn total-debits [items]
(->> items
(filter #(= :ledger-side/debit (:ledger-mapped/ledger-side %)))
(map #(:ledger-mapped/amount % 0.0))
(reduce + 0.0)))
(defn total-credits [items]
(->> items
(filter #(= :ledger-side/credit (:ledger-mapped/ledger-side %)))
(map #(:ledger-mapped/amount % 0.0))
(reduce + 0.0)))
(defn truncate [s max-len] (defn truncate [s max-len]
(if (> (count s) max-len) (if (> (count s) max-len)
(str (subs s 0 (- max-len 3)) "...") (str (subs s 0 (- max-len 3)) "...")

View File

@@ -386,6 +386,20 @@ module "close_auto_invoices_job" {
cpu = 512 cpu = 512
} }
module "sales_summaries_job" {
count = var.enable_schedules ? 1 : 0
source = "./background-job/"
ecs_cluster = var.ecs_cluster
task_role_arn = var.task_role_arn
stage = var.stage
schedule = "rate(1 day)"
job_name = "sales-summaries"
execution_role_arn = var.execution_role_arn
use_schedule = true
memory = 4096
cpu = 2048
}
module "yodlee2_accounts_job" { module "yodlee2_accounts_job" {
count = var.enable_schedules ? 1 : 0 count = var.enable_schedules ? 1 : 0
source = "./background-job/" source = "./background-job/"