diff --git a/src/clj/auto_ap/datomic/sales_summaries.clj b/src/clj/auto_ap/datomic/sales_summaries.clj new file mode 100644 index 00000000..c8d2dd69 --- /dev/null +++ b/src/clj/auto_ap/datomic/sales_summaries.clj @@ -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))) diff --git a/src/clj/auto_ap/jobs/sales_summaries.clj b/src/clj/auto_ap/jobs/sales_summaries.clj index e759078b..52391a1b 100644 --- a/src/clj/auto_ap/jobs/sales_summaries.clj +++ b/src/clj/auto_ap/jobs/sales_summaries.clj @@ -1,5 +1,6 @@ (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] @@ -8,6 +9,7 @@ [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] @@ -39,27 +41,100 @@ (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]))))) - + (->> + (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 [c] (let [client-id (dc/entid (dc/db conn) c)] (->> (dc/index-pull (dc/db conn) {: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]]}) (filter (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 @@ -99,53 +174,53 @@ "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})))) + (->> + (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) @@ -162,7 +237,7 @@ :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] @@ -173,26 +248,24 @@ (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)) - {}) + (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})))) - - + {: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)] @@ -278,17 +351,18 @@ (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)] + :in $ + :where [?c :client/code ?client-code]] + (dc/db conn)) + {: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?)) - calculated-items (->> + :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)) @@ -301,20 +375,19 @@ (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))) - @(dc/transact conn [[:upsert-sales-summary result]])) - @(dc/transact conn [{:db/id id :sales-summary/dirty 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))) + @(dc/transact conn [[:upsert-sales-summary result]])) + @(dc/transact conn [{:db/id id :sales-summary/dirty false}])))))) (defn reset-summaries [] @(dc/transact conn (->> (dc/q '[:find ?sos @@ -324,9 +397,6 @@ (map (fn [[sos]] [:db/retractEntity sos]))))) - - - (comment (auto-ap.datomic/transact-schema conn) @@ -336,26 +406,19 @@ (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"]] @@ -386,15 +449,24 @@ @(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) - - - - - ) + (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" sales-summaries-v2)) - \ No newline at end of file + (execute "sales-summaries" #(refresh-sales-summaries (days-arg (:args env))))) diff --git a/src/clj/auto_ap/server.clj b/src/clj/auto_ap/server.clj index c3143575..82cfcff1 100644 --- a/src/clj/auto_ap/server.clj +++ b/src/clj/auto_ap/server.clj @@ -13,6 +13,7 @@ [auto-ap.jobs.load-historical-sales :as job-load-historical-sales] [auto-ap.jobs.plaid :as job-plaid] [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.sysco :as job-sysco] [auto-ap.jobs.vendor-usages :as job-vendor-usages] @@ -33,23 +34,22 @@ (.addShutdownHook (Runtime/getRuntime) (Thread. f))) - (defn gzip-handler [] (let [gz (GzipHandler.)] (doto gz (.setIncludedMethods (into-array ["GET" "POST" "PUT" "DELETE" "PATCH"])) - (.setIncludedMimeTypes (into-array ["text/css" - "text/*" - "text/plain" - "text/javascript" - "text/csv" - "text/html" - "text/html;charset=utf-8" - "application/javascript" - "application/csv" - "application/edn" - "application/json" - "image/svg+xml"])) + (.setIncludedMimeTypes (into-array ["text/css" + "text/*" + "text/plain" + "text/javascript" + "text/csv" + "text/html" + "text/html;charset=utf-8" + "application/javascript" + "application/csv" + "application/edn" + "application/json" + "image/svg+xml"])) (.setMinGzipSize 1024)) gz)) @@ -126,6 +126,9 @@ (= job "close-auto-invoices") (job-close-auto-invoices/-main) + (= job "sales-summaries") + (job-sales-summaries/-main) + (= job "ezcater-upsert") (job-ezcater-upsert/-main) diff --git a/src/clj/auto_ap/ssr/admin/background_jobs.clj b/src/clj/auto_ap/ssr/admin/background_jobs.clj index b5576aa1..9d6e65d7 100644 --- a/src/clj/auto_ap/ssr/admin/background_jobs.clj +++ b/src/clj/auto_ap/ssr/admin/background_jobs.clj @@ -28,14 +28,13 @@ (com.amazonaws.services.ecs.model AssignPublicIp))) (defn get-ecs-tasks [] - (->> - (concat (:task-arns (ecs/list-tasks :max-results 50)) (:task-arns (ecs/list-tasks :desired-status "STOPPED" :max-results 50))) - (ecs/describe-tasks :include [] :tasks) - :tasks - (map #(assoc % :task-definition (:task-definition (ecs/describe-task-definition :task-definition (:task-definition-arn %))))) - (sort-by :created-at) - reverse)) - + (->> + (concat (:task-arns (ecs/list-tasks :max-results 50)) (:task-arns (ecs/list-tasks :desired-status "STOPPED" :max-results 50))) + (ecs/describe-tasks :include [] :tasks) + :tasks + (map #(assoc % :task-definition (:task-definition (ecs/describe-task-definition :task-definition (:task-definition-arn %))))) + (sort-by :created-at) + reverse)) (defn is-background-job? "This function checks whether a given task is a background job. @@ -60,7 +59,7 @@ (defn job-exited-successfully? [task] (if (= 0 (->> task :containers - (filter (comp #{"integreat-app" } :name)) + (filter (comp #{"integreat-app"} :name)) (first) :exit-code)) true @@ -77,7 +76,7 @@ :succeeded :failed)) :name (task-definition->job-name (:task-definition task)) - :end-date (some-> (:stopped-at task) coerce/to-date-time (time/to-time-zone (time/time-zone-for-offset 0))) + :end-date (some-> (:stopped-at task) coerce/to-date-time (time/to-time-zone (time/time-zone-for-offset 0))) :start-date (some-> (:created-at task) coerce/to-date-time (time/to-time-zone (time/time-zone-for-offset 0)))}) (defn fetch-page [request] @@ -85,7 +84,7 @@ (filter is-background-job?) (map ecs-task->job))] [jobs (count jobs)])) -(def query-schema (mc/schema [:map ])) +(def query-schema (mc/schema [:map])) (def grid-page (helper/build {:id "job-table" @@ -107,8 +106,7 @@ :entity-name "Job" :query-schema query-schema :route :admin-job-table - :headers [ - {:key "start" + :headers [{:key "start" :name "Start" :render #(some-> % :start-date (atime/unparse-local atime/standard-time))} {:key "end" @@ -119,7 +117,7 @@ :render (fn [e] (when (and (:start-date e) (:end-date e)) - (str (time/in-minutes (time/interval + (str (time/in-minutes (time/interval (:start-date e) (:end-date e))) " minutes")))} {:key "name" @@ -150,16 +148,16 @@ :network-configuration {:aws-vpc-configuration {:subnets ["subnet-5e675761" "subnet-8519fde2" "subnet-89bab8d4"] :security-groups ["sg-004e5855310c453a3" "sg-02d167406b1082698"] :assign-public-ip AssignPublicIp/ENABLED}}} - args (assoc-in [:overrides :container-overrides ] [{:name "integreat-app" :environment [{:name "args" :value (pr-str args)}]}])))) + args (assoc-in [:overrides :container-overrides] [{:name "integreat-app" :environment [{:name "args" :value (pr-str args)}]}])))) (defn job-start [{:keys [form-params]}] (if (not (get (currently-running-jobs) (:name form-params))) (let [new-job (run-task - (-> (:name form-params) - (str/replace #"-" "_") - (str/replace #":" "") - (str "_" (:dd-env env))) - (dissoc form-params :name))] + (-> (:name form-params) + (str/replace #"-" "_") + (str/replace #":" "") + (str "_" (:dd-env env))) + (dissoc form-params :name))] {:message (str "task " (str new-job) " started.")}) (form-validation-error "This job is already running" :form-params form-params))) @@ -170,107 +168,109 @@ [(fc/with-field :ledger-url (com/validated-field {:label "Url" :errors (fc/field-errors)} - [:div.flex.place-items-center.gap-2 - [:pre.text-xs.mr-1 "s3://data.prod.app.integreatconsult.com/bulk-import/"] - (com/text-input {:placeholder "ledger-data.csv" - :name (fc/field-name) - :value (fc/field-value)} )]))] + [:div.flex.place-items-center.gap-2 + [:pre.text-xs.mr-1 "s3://data.prod.app.integreatconsult.com/bulk-import/"] + (com/text-input {:placeholder "ledger-data.csv" + :name (fc/field-name) + :value (fc/field-value)})]))] (= "register-invoice-import" name) - [ - (fc/with-field :invoice-url - (com/validated-field {:label "Url" - :errors (fc/field-errors)} - [:div.flex.place-items-center.gap-2 - [:pre.text-xs.mr-1 "s3://data.prod.app.integreatconsult.com/bulk-import/"] - (com/text-input {:placeholder "invoice-data.csv" - :name (fc/field-name) - :value (fc/field-value)} )]))] + [(fc/with-field :invoice-url + (com/validated-field {:label "Url" + :errors (fc/field-errors)} + [:div.flex.place-items-center.gap-2 + [:pre.text-xs.mr-1 "s3://data.prod.app.integreatconsult.com/bulk-import/"] + (com/text-input {:placeholder "invoice-data.csv" + :name (fc/field-name) + :value (fc/field-value)})]))] (= "load-historical-sales" name) - [ - (fc/with-field :client - (com/validated-field {:label "Client" - :errors (fc/field-errors)} - (com/typeahead {:name (fc/field-name) - :value (fc/field-value) - :placeholder "Search..." - :url (bidi/path-for ssr-routes/only-routes - :company-search)}))) - (fc/with-field :days + [(fc/with-field :client + (com/validated-field {:label "Client" + :errors (fc/field-errors)} + (com/typeahead {:name (fc/field-name) + :value (fc/field-value) + :placeholder "Search..." + :url (bidi/path-for ssr-routes/only-routes + :company-search)}))) + (fc/with-field :days (com/validated-field {:label "Days to load" :errors (fc/field-errors)} (com/text-input {:placeholder "60" :name (fc/field-name) - :value (fc/field-value)} )))] - :else nil)) + :value (fc/field-value)})))] + (= "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 - (fc/start-form {} nil - (subform* {:name name})))) + (fc/start-form {} nil + (subform* {:name name})))) (defn job-start-dialog [{:keys [form-errors form-params] :as request}] (fc/start-form (or form-params {}) form-errors - (modal-response - (com/modal ;; TODO we need a cleaner way to have forms that wrap the whole. In this cas - {} - [:form {:hx-post (bidi/path-for ssr-routes/only-routes :admin-job-start) - :class "h-full w-full"} - [:fieldset {:class "hx-disable h-full w-full"} - (com/modal-card {} - [:div.m-2 "New job"] - [:div.space-y-6 + (modal-response + (com/modal ;; TODO we need a cleaner way to have forms that wrap the whole. In this cas + {} + [:form {:hx-post (bidi/path-for ssr-routes/only-routes :admin-job-start) + :class "h-full w-full"} + [:fieldset {:class "hx-disable h-full w-full"} + (com/modal-card {} + [:div.m-2 "New job"] + [:div.space-y-6 - (fc/with-field :name - (com/validated-field {:label "Job" - :errors (fc/field-errors)} - (com/select {:name (fc/field-name) - :value (fc/field-value) - :class "w-64" - :options [["" ""] - ["yodlee2" "Yodlee Import"] - ["yodlee2-accounts" "Yodlee Account Import"] - ["intuit" "Intuit import"] - ["plaid" "Plaid import"] - ["bulk-journal-import" "Bulk Journal Import"] - ["square-import-job" "Square Import"] - ["register-invoice-import" "Register Invoice Import "] - ["ezcater-upsert" "Upsert recent ezcater orders"] - ["load-historical-sales" "Load Historical Square Sales"] - ["export-backup" "Export Backup"]] - :hx-get (bidi/path-for ssr-routes/only-routes - :admin-job-subform) - :hx-target "#sub-form" - :hx-swap "innerHTML"}))) + (fc/with-field :name + (com/validated-field {:label "Job" + :errors (fc/field-errors)} + (com/select {:name (fc/field-name) + :value (fc/field-value) + :class "w-64" + :options [["" ""] + ["yodlee2" "Yodlee Import"] + ["yodlee2-accounts" "Yodlee Account Import"] + ["intuit" "Intuit import"] + ["plaid" "Plaid import"] + ["bulk-journal-import" "Bulk Journal Import"] + ["square-import-job" "Square Import"] + ["register-invoice-import" "Register Invoice Import "] + ["ezcater-upsert" "Upsert recent ezcater orders"] + ["load-historical-sales" "Load Historical Square Sales"] + ["sales-summaries" "Refresh Sales Summaries"] + ["export-backup" "Export Backup"]] + :hx-get (bidi/path-for ssr-routes/only-routes + :admin-job-subform) + :hx-target "#sub-form" + :hx-swap "innerHTML"}))) - [:div#sub-form (subform* {:name (fc/with-field :name (fc/field-value))}) ]] - [:div - - (com/form-errors {:errors (:errors fc/*form-errors*)}) - (com/validated-save-button {:errors form-errors} "Run job")])]])))) + [:div#sub-form (subform* {:name (fc/with-field :name (fc/field-value))})]] + [:div + + (com/form-errors {:errors (:errors fc/*form-errors*)}) + (com/validated-save-button {:errors form-errors} "Run job")])]])))) (def form-schema (mc/schema [:map [:name [:string {:min 1}]] [:ledger-url {:optional true} [:string {:min 1}]] [:invoice-url {:optional true} [:string {:min 1}]] [:client {:optional true} entity-id] - [:days {:optional true} [:int {:min 1 :max 120}]] - ])) + [:days {:optional true} [:int {:min 1 :max 120}]]])) (def key->handler - (apply-middleware-to-all-handlers - (->> - {:admin-jobs (helper/page-route grid-page) - :admin-job-table (helper/table-route grid-page) - :admin-job-subform (-> subform (wrap-schema-enforce :query-schema [:map [:name {:optional true} [:maybe :string]]])) - :admin-job-start (-> job-start - (wrap-schema-enforce :form-schema form-schema) - (wrap-nested-form-params) - (wrap-form-4xx-2 job-start-dialog)) - :admin-job-start-dialog job-start-dialog}) - (fn [h] - (-> h - (wrap-admin) - (wrap-client-redirect-unauthenticated))))) + (apply-middleware-to-all-handlers + (->> + {:admin-jobs (helper/page-route grid-page) + :admin-job-table (helper/table-route grid-page) + :admin-job-subform (-> subform (wrap-schema-enforce :query-schema [:map [:name {:optional true} [:maybe :string]]])) + :admin-job-start (-> job-start + (wrap-schema-enforce :form-schema form-schema) + (wrap-nested-form-params) + (wrap-form-4xx-2 job-start-dialog)) + :admin-job-start-dialog job-start-dialog}) + (fn [h] + (-> h + (wrap-admin) + (wrap-client-redirect-unauthenticated))))) diff --git a/src/clj/auto_ap/ssr/pos/sales_summaries.clj b/src/clj/auto_ap/ssr/pos/sales_summaries.clj index 45988b07..c9d0bad3 100644 --- a/src/clj/auto_ap/ssr/pos/sales_summaries.clj +++ b/src/clj/auto_ap/ssr/pos/sales_summaries.clj @@ -4,6 +4,7 @@ :refer [apply-pagination apply-sort-3 conn merge-query pull-many query2]] [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.query-params :refer [wrap-copy-qp-pqp]] [auto-ap.client-routes :as client-routes] @@ -116,18 +117,6 @@ (defn sort-items [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] (if (> (count s) max-len) (str (subs s 0 (- max-len 3)) "...") @@ -158,13 +147,13 @@ [:span.text-sm account-name] (com/pill {:color :red} "Missing acct")) (com/a-icon-button {:class "p-1" - :hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-item-account) - :hx-target "closest .account-cell" - :hx-swap "outerHTML" - :hx-vals (hx/json {:item-index (or (:item-index item) 0) - :client-id client-id - :current-account-id (or account-id "")})} - svg/pencil)])) + :hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-item-account) + :hx-target "closest .account-cell" + :hx-swap "outerHTML" + :hx-vals (hx/json {:item-index (or (:item-index item) 0) + :client-id client-id + :current-account-id (or account-id "")})} + svg/pencil)])) (defn account-edit-cell [{:keys [field-name-prefix client-id current-account-id]}] (let [account-input-name (str field-name-prefix "[ledger-mapped/account]")] @@ -172,23 +161,23 @@ (account-typeahead* {:name account-input-name :value current-account-id :client-id client-id}) - [:div.flex.gap-1 - (com/a-icon-button {:class "p-1" - :hx-put (bidi/path-for ssr-routes/only-routes ::route/save-item-account) - :hx-target "closest .account-cell" - :hx-swap "outerHTML" - :hx-include "closest .account-cell" - :hx-vals (hx/json {:field-name-prefix field-name-prefix - :client-id client-id})} - svg/check) - (com/a-icon-button {:class "p-1" - :hx-get (bidi/path-for ssr-routes/only-routes ::route/cancel-item-account) - :hx-target "closest .account-cell" - :hx-swap "outerHTML" - :hx-vals (hx/json {:field-name-prefix field-name-prefix - :client-id client-id - :current-account-id (or current-account-id "")})} - svg/x)]])) + [:div.flex.gap-1 + (com/a-icon-button {:class "p-1" + :hx-put (bidi/path-for ssr-routes/only-routes ::route/save-item-account) + :hx-target "closest .account-cell" + :hx-swap "outerHTML" + :hx-include "closest .account-cell" + :hx-vals (hx/json {:field-name-prefix field-name-prefix + :client-id client-id})} + svg/check) + (com/a-icon-button {:class "p-1" + :hx-get (bidi/path-for ssr-routes/only-routes ::route/cancel-item-account) + :hx-target "closest .account-cell" + :hx-swap "outerHTML" + :hx-vals (hx/json {:field-name-prefix field-name-prefix + :client-id client-id + :current-account-id (or current-account-id "")})} + svg/x)]])) (def grid-page (helper/build {:id "entity-table" @@ -576,8 +565,8 @@ [:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)] (account-display-cell {:item (assoc item :item-index actual-idx) :field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]") - :client-id client-id}) - [:span.ml-auto.font-mono.tabular-nums.text-gray-900 (format "$%,.2f" (:ledger-mapped/amount item))]])) + :client-id client-id}) + [:span.ml-auto.font-mono.tabular-nums.text-gray-900 (format "$%,.2f" (:ledger-mapped/amount item))]])) [:div.h-6]))] [:div.mt-2.border-t.pt-1 (summary-total-display request) @@ -619,13 +608,13 @@ [:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)] (account-display-cell {:item (assoc item :item-index actual-idx) :field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]") - :client-id client-id}) - [:span.ml-auto.font-mono.tabular-nums.text-gray-900 (format "$%,.2f" (:ledger-mapped/amount item))]])) + :client-id client-id}) + [:span.ml-auto.font-mono.tabular-nums.text-gray-900 (format "$%,.2f" (:ledger-mapped/amount item))]])) [:div.h-6]))] [:div.mt-2.border-t.pt-1 (summary-total-display request) (unbalanced-display request)]]] - [:div.mt-4.border-t.pt-2 + [:div.mt-4.border-t.pt-2 (fc/with-field :sales-summary/items (com/data-grid-new-row {:colspan 2 :hx-get (bidi/path-for ssr-routes/only-routes ::route/new-summary-item) @@ -761,16 +750,16 @@ ::route/edit-wizard-navigate (-> mm/next-handler (mm/wrap-wizard edit-wizard) (mm/wrap-decode-multi-form-state)) - ::route/new-summary-item (-> (add-new-entity-handler [:step-params :sales-summary/items] - (fn render [cursor request] - (sales-summary-item-row* - {:value cursor - :client-id (:client-id (:query-params request))})) - (fn build-new-row [base _] - (assoc base :sales-summary-item/manual? true))) - (wrap-schema-enforce :query-schema [:map - [:client-id {:optional true} - [:maybe entity-id]]])) + ::route/new-summary-item (-> (add-new-entity-handler [:step-params :sales-summary/items] + (fn render [cursor request] + (sales-summary-item-row* + {:value cursor + :client-id (:client-id (:query-params request))})) + (fn build-new-row [base _] + (assoc base :sales-summary-item/manual? true))) + (wrap-schema-enforce :query-schema [:map + [:client-id {:optional true} + [:maybe entity-id]]])) ::route/edit-item-account (-> edit-item-account (wrap-schema-enforce :query-schema [:map [:item-index nat-int?] diff --git a/terraform/deploy.tf b/terraform/deploy.tf index 6e6f95c9..36cdf097 100644 --- a/terraform/deploy.tf +++ b/terraform/deploy.tf @@ -386,6 +386,20 @@ module "close_auto_invoices_job" { 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" { count = var.enable_schedules ? 1 : 0 source = "./background-job/"