feat(ledger): add a Prime Cost Report alongside the other ledger reports
Prime cost is what a restaurant is actually run on: what it sold against what it paid people to sell it. Neither figure means much alone and the ratio only reads as a trend, so this shows eight weeks rather than one range — the same shape as the PCR Detail sheet of the Bi-Weekly workbook it replaces. The two halves come from deliberately different places. Payroll is read from the ledger, because that is where payroll lands once it has been coded to an account; taken from anywhere else it would not tie back to the P&L sitting beside it. Sales are read from the daily sales summaries, which are the reconciled books rather than the raw till feed. That split is why it lives with the ledger reports despite only half of it being ledger data. It has to agree with the P&L, so the payroll bands come from `auto-ap.ledger.reports/groupings` rather than a second list restated here — an earlier draft did restate them and silently dropped the 60000-60999 general payroll band on the floor. Details worth knowing: - Sales are net: credits add, debits subtract, so discounts and returns reduce sales without special casing. Only the 40000 revenue block counts, so tax and tip (liabilities held for someone else) and the tender lines (the money side of the same transaction) are excluded. - Payroll is debits less credits, so a correction posted as a credit reduces the week rather than counting as more labour. - Week windows tile with an exclusive end, so no day is counted twice at a boundary. - Sales categories are driven by what is in the data, so a restaurant that starts selling something new appears without editing this file. - A week with no sales yields no ratio rather than dividing by zero. Gated on the existing :profit-and-loss read permission, since it exposes the same figures. 7 tests, 27 assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -95,7 +95,7 @@
|
||||
"sales"
|
||||
(#{::payment-routes/all-page ::payment-routes/pending-page ::payment-routes/cleared-page ::payment-routes/voided-page} (:matched-route request))
|
||||
"payments"
|
||||
(#{::ledger-routes/all-page ::ledger-routes/external-page ::ledger-routes/external-import-page ::ledger-routes/balance-sheet ::ledger-routes/cash-flows ::ledger-routes/profit-and-loss} (:matched-route request))
|
||||
(#{::ledger-routes/all-page ::ledger-routes/external-page ::ledger-routes/external-import-page ::ledger-routes/balance-sheet ::ledger-routes/cash-flows ::ledger-routes/profit-and-loss ::ledger-routes/prime-cost} (:matched-route request))
|
||||
"ledger"
|
||||
:else
|
||||
nil)]
|
||||
@@ -320,6 +320,11 @@
|
||||
:profit-and-loss)} "Old profit and loss"))
|
||||
(menu-button- {:href (bidi/path-for client-routes/routes
|
||||
:profit-and-loss-detail)} "Profit & Loss Detail")
|
||||
(menu-button- {:href (bidi/path-for ssr-routes/only-routes
|
||||
::ledger-routes/prime-cost)
|
||||
:active? (= ::ledger-routes/prime-cost (:matched-route request))
|
||||
:hx-boost "true"}
|
||||
"Prime Cost Report")
|
||||
(menu-button- {:href (bidi/path-for client-routes/routes
|
||||
:cash-flows)} "Cash Flows")
|
||||
(if (is-admin? (:identity request))
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
[auto-ap.ssr.ledger.common :as ledger.common]
|
||||
[auto-ap.ssr.ledger.investigate :as investigate]
|
||||
[auto-ap.ssr.ledger.new :as new]
|
||||
[auto-ap.ssr.ledger.prime-cost :as prime-cost]
|
||||
[auto-ap.ssr.ledger.profit-and-loss :as profit-and-loss]
|
||||
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
@@ -737,6 +738,7 @@
|
||||
(wrap-client-redirect-unauthenticated))))
|
||||
balance-sheet/key->handler
|
||||
profit-and-loss/key->handler
|
||||
prime-cost/key->handler
|
||||
cash-flows/key->handler
|
||||
investigate/key->handler
|
||||
new/key->handler))
|
||||
287
src/clj/auto_ap/ssr/ledger/prime_cost.clj
Normal file
287
src/clj/auto_ap/ssr/ledger/prime_cost.clj
Normal file
@@ -0,0 +1,287 @@
|
||||
(ns auto-ap.ssr.ledger.prime-cost
|
||||
"Prime Cost Report — what a restaurant sold against what it paid people to sell it, by week.
|
||||
|
||||
Neither figure is interesting alone; the ratio between them is, and it only reads as a trend,
|
||||
which is why this shows eight weeks rather than one range.
|
||||
|
||||
The two halves come from deliberately different places. **Payroll is read from the ledger**,
|
||||
because that is where payroll lands once it has been coded to an account — taken from anywhere
|
||||
else it would not tie back to the profit and loss sitting beside it. **Sales are read from the
|
||||
daily sales summaries**, which are the reconciled books rather than the raw till feed.
|
||||
|
||||
That split is why this report sits with the ledger reports despite only half of it being ledger
|
||||
data: it has to agree with the P&L, and it uses `auto-ap.ledger.reports/groupings` for the
|
||||
payroll bands so that agreement is structural rather than a coincidence that survives until
|
||||
somebody edits one of the two lists.
|
||||
|
||||
Replaces the PCR Detail sheet of the `Bi-Weekly` workbook."
|
||||
(:require
|
||||
[auto-ap.datomic :refer [conn]]
|
||||
[auto-ap.graphql.utils :refer [extract-client-ids]]
|
||||
[auto-ap.ledger.reports :as l-reports]
|
||||
[auto-ap.permissions :refer [wrap-must]]
|
||||
[auto-ap.routes.ledger :as route]
|
||||
[auto-ap.routes.utils :refer [wrap-client-redirect-unauthenticated]]
|
||||
[auto-ap.ssr-routes :as ssr-routes]
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.form-cursor :as fc]
|
||||
[auto-ap.ssr.ledger.report-table :refer [table]]
|
||||
[auto-ap.ssr.ui :refer [base-page]]
|
||||
[auto-ap.ssr.utils :refer [apply-middleware-to-all-handlers clj-date-schema
|
||||
html-response wrap-schema-enforce]]
|
||||
[auto-ap.time :as atime]
|
||||
[bidi.bidi :as bidi]
|
||||
[clj-time.coerce :as coerce]
|
||||
[clj-time.core :as time]
|
||||
[datomic.api :as dc]
|
||||
[malli.core :as mc]))
|
||||
|
||||
(def weeks-shown
|
||||
"Eight, as the workbook this replaces used — about the shortest run in which a labour-percentage
|
||||
trend is readable through ordinary weekly noise."
|
||||
8)
|
||||
|
||||
(def payroll-bands
|
||||
"`[[label from to] ...]` for payroll, taken from the shared ledger groupings rather than restated
|
||||
here, so this report and the profit and loss can never disagree about what counts as payroll."
|
||||
(:payroll l-reports/groupings))
|
||||
|
||||
(def payroll-range
|
||||
"The full span the bands cover, used to filter the query before grouping."
|
||||
{:from (apply min (map second payroll-bands))
|
||||
:to (inc (apply max (map #(nth % 2) payroll-bands)))})
|
||||
|
||||
(defn band-for
|
||||
"The payroll band a numeric account code falls in, or nil when it is not payroll."
|
||||
[code]
|
||||
(some (fn [[label from to]]
|
||||
(when (and code (>= code from) (<= code to)) label))
|
||||
payroll-bands))
|
||||
|
||||
(defn week-windows
|
||||
"`[{:starts :ends}]` for the `weeks-shown` weeks ending at `end-date`, most recent first.
|
||||
|
||||
`:ends` is exclusive, so weeks tile and no day is counted twice at a boundary."
|
||||
[end-date]
|
||||
(let [end (coerce/to-date-time end-date)]
|
||||
(for [i (range weeks-shown)]
|
||||
{:starts (time/minus end (time/weeks (inc i)))
|
||||
:ends (time/minus end (time/weeks i))})))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Sales — the reconciled daily summaries
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn revenue-line?
|
||||
"Whether a summary line is revenue rather than tender, tax or tip.
|
||||
|
||||
Revenue is the 40000 block. Tax and tip are liabilities held for someone else, and the tender
|
||||
lines are the money side of the same transaction — none of them are sales."
|
||||
[item]
|
||||
(let [code (get-in item [:ledger-mapped/account :account/numeric-code])]
|
||||
(and code (>= code 40000) (< code 50000))))
|
||||
|
||||
(defn signed-amount
|
||||
"A line's contribution to net sales. Credits add and debits subtract, so discounts and returns —
|
||||
posted as debits against revenue accounts — reduce sales with no special casing."
|
||||
[item]
|
||||
(let [amount (or (:ledger-mapped/amount item) 0.0)]
|
||||
(if (= :ledger-side/debit (get-in item [:ledger-mapped/ledger-side :db/ident]))
|
||||
(- amount)
|
||||
amount)))
|
||||
|
||||
(defn sales-in-window
|
||||
"`{:total n :by-category {category n}}` of net sales for `client-ids` over `[starts ends)`."
|
||||
[db client-ids starts ends]
|
||||
(let [lines (->> (dc/q '[:find [(pull ?s [{:sales-summary/items
|
||||
[:sales-summary-item/category
|
||||
:ledger-mapped/amount
|
||||
{:ledger-mapped/ledger-side [:db/ident]}
|
||||
{:ledger-mapped/account [:account/numeric-code]}]}]) ...]
|
||||
:in $ [?c ...] ?start ?end
|
||||
:where
|
||||
[?s :sales-summary/client ?c]
|
||||
[?s :sales-summary/date ?d]
|
||||
[(>= ?d ?start)]
|
||||
[(< ?d ?end)]]
|
||||
db client-ids (coerce/to-date starts) (coerce/to-date ends))
|
||||
(mapcat :sales-summary/items)
|
||||
(filter revenue-line?))]
|
||||
{:total (reduce + 0.0 (map signed-amount lines))
|
||||
:by-category (reduce (fn [acc item]
|
||||
(update acc (:sales-summary-item/category item)
|
||||
(fnil + 0.0) (signed-amount item)))
|
||||
{} lines)}))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Payroll — the ledger, where coded payroll lands
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn payroll-in-window
|
||||
"`{:total n :by-band {band-label n}}` of payroll cost for `client-ids` over `[starts ends)`.
|
||||
|
||||
Cost is debits less credits, so a payroll correction posted as a credit reduces the week rather
|
||||
than counting as more labour."
|
||||
[db client-ids starts ends]
|
||||
(let [{:keys [from to]} payroll-range
|
||||
rows (dc/q '[:find ?code ?debit ?credit
|
||||
:with ?jel
|
||||
:in $ [?c ...] ?start ?end ?from ?to
|
||||
:where
|
||||
[?jel :journal-entry-line/client ?c]
|
||||
[?jel :journal-entry-line/date ?d]
|
||||
[(>= ?d ?start)]
|
||||
[(< ?d ?end)]
|
||||
[?jel :journal-entry-line/account ?a]
|
||||
[?a :account/numeric-code ?code]
|
||||
[(>= ?code ?from)]
|
||||
[(< ?code ?to)]
|
||||
[(get-else $ ?jel :journal-entry-line/debit 0.0) ?debit]
|
||||
[(get-else $ ?jel :journal-entry-line/credit 0.0) ?credit]]
|
||||
db client-ids (coerce/to-date starts) (coerce/to-date ends) from to)]
|
||||
(reduce (fn [acc [code debit credit]]
|
||||
(let [cost (- (or debit 0.0) (or credit 0.0))]
|
||||
(cond-> (update acc :total + cost)
|
||||
(band-for code) (update-in [:by-band (band-for code)] (fnil + 0.0) cost))))
|
||||
{:total 0.0 :by-band {}}
|
||||
rows)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Assembly
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn ratio [part whole]
|
||||
(when (and part whole (not (zero? whole)))
|
||||
(/ part whole)))
|
||||
|
||||
(defn get-report-data
|
||||
"One column per week, most recent first, each carrying its own sales and payroll."
|
||||
[db client-ids end-date]
|
||||
(for [{:keys [starts ends]} (week-windows end-date)]
|
||||
(let [sales (sales-in-window db client-ids starts ends)
|
||||
payroll (payroll-in-window db client-ids starts ends)]
|
||||
{:starts starts
|
||||
:ends ends
|
||||
:sales sales
|
||||
:payroll payroll
|
||||
:labor-ratio (ratio (:total payroll) (:total sales))})))
|
||||
|
||||
(defn average [xs]
|
||||
(let [xs (remove nil? xs)]
|
||||
(when (seq xs) (/ (reduce + 0.0 xs) (count xs)))))
|
||||
|
||||
(defn category-rows
|
||||
"Sales categories present anywhere in the trend, largest average first.
|
||||
|
||||
Driven by the data rather than a fixed list, so a restaurant that starts selling something new
|
||||
shows it without anyone editing this file."
|
||||
[columns]
|
||||
(->> (into #{} (mapcat (comp keys :by-category :sales)) columns)
|
||||
(map (fn [n] {:label n :values (map #(get-in % [:sales :by-category n]) columns)}))
|
||||
(sort-by #(- (or (average (:values %)) 0.0)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Rendering — the shared ledger report table, so this reads like its siblings
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- money-row [label values & {:keys [bold]}]
|
||||
(into [(cond-> {:value label} bold (assoc :bold true))
|
||||
(cond-> {:value (average values) :format :dollar} bold (assoc :bold true))]
|
||||
(for [v values]
|
||||
(cond-> {:value (or v 0.0) :format :dollar} bold (assoc :bold true)))))
|
||||
|
||||
(defn- percent-row [label values sales & {:keys [bold]}]
|
||||
(into [(cond-> {:value label} bold (assoc :bold true))
|
||||
(cond-> {:value (ratio (average values) (average sales)) :format :percent}
|
||||
bold (assoc :bold true))]
|
||||
(for [[v s] (map vector values sales)]
|
||||
(cond-> {:value (ratio v s) :format :percent} bold (assoc :bold true)))))
|
||||
|
||||
(defn report-table
|
||||
"`{:header :rows}` in the shape `auto-ap.ssr.ledger.report-table/table` renders."
|
||||
[columns]
|
||||
(let [sales-totals (map #(get-in % [:sales :total]) columns)
|
||||
payroll-totals (map #(get-in % [:payroll :total]) columns)]
|
||||
{:header [(into [{:value "" :bold true} {:value "Average" :bold true}]
|
||||
(for [c columns]
|
||||
{:value (atime/unparse-local (time/minus (:ends c) (time/days 1))
|
||||
atime/normal-date)
|
||||
:bold true}))]
|
||||
:rows (concat
|
||||
[(money-row "Sales" sales-totals :bold true)]
|
||||
(for [{:keys [label values]} (category-rows columns)]
|
||||
(money-row label values))
|
||||
[(money-row "Payroll" payroll-totals :bold true)]
|
||||
(for [[label] payroll-bands
|
||||
:let [values (map #(get-in % [:payroll :by-band label]) columns)]
|
||||
:when (some some? values)]
|
||||
(money-row label values))
|
||||
[(percent-row "Payroll as % of sales" payroll-totals sales-totals :bold true)])}))
|
||||
|
||||
(defn prime-cost-card* [{:keys [request columns]}]
|
||||
(com/content-card
|
||||
{:class "w-full" :id "prime-cost-report"}
|
||||
[:div {:class "flex flex-col px-8 py-8 space-y-3"}
|
||||
[:h1.text-2xl.mb-1.font-bold "Prime Cost Report"]
|
||||
[:p {:class "text-sm text-gray-600 mb-3"}
|
||||
"Payroll from the ledger, sales from the daily summaries, by week."]
|
||||
|
||||
[:form {:hx-get (bidi/path-for ssr-routes/only-routes ::route/run-prime-cost)
|
||||
:hx-target "#prime-cost-report"
|
||||
:hx-swap "outerHTML"}
|
||||
(fc/start-form
|
||||
(:query-params request)
|
||||
(:form-errors request)
|
||||
[:div.flex.gap-2
|
||||
(fc/with-field :end-date
|
||||
(com/validated-field {:label "Week ending"
|
||||
:errors (fc/field-errors)}
|
||||
[:div {:class "w-64"}
|
||||
(com/date-input {:name (fc/field-name)
|
||||
:class "w-64"
|
||||
:value (some-> (fc/field-value)
|
||||
(atime/unparse-local atime/normal-date))})]))
|
||||
(com/button {:color :primary :class "self-center w-24"} "Run")])]
|
||||
|
||||
(if (seq columns)
|
||||
(table {:table (report-table columns)
|
||||
:widths (into [14 8] (repeat weeks-shown 8))})
|
||||
[:div {:class "text-gray-600"} "Choose a week-ending date to run the report."])]))
|
||||
|
||||
(defn page [request]
|
||||
(base-page
|
||||
request
|
||||
(com/page {:nav com/company-aside-nav
|
||||
:client-selection (:client-selection request)
|
||||
:client (:client request)
|
||||
:clients (:clients request)
|
||||
:identity (:identity request)}
|
||||
(com/breadcrumbs {}
|
||||
[:a {:href (bidi/path-for ssr-routes/only-routes ::route/prime-cost)}
|
||||
"Prime Cost Report"])
|
||||
(prime-cost-card* {:request request :columns nil}))
|
||||
"Prime Cost Report"))
|
||||
|
||||
(defn run [{{:keys [end-date]} :query-params :as request}]
|
||||
(let [client-ids (extract-client-ids (:clients request)
|
||||
(:client-id request)
|
||||
(when (:client-code request)
|
||||
[:client/code (:client-code request)]))
|
||||
columns (when end-date
|
||||
(get-report-data (dc/db conn) client-ids end-date))]
|
||||
(html-response (prime-cost-card* {:request request :columns columns}))))
|
||||
|
||||
(def query-schema
|
||||
(mc/schema
|
||||
[:maybe [:map
|
||||
[:end-date {:optional true} [:maybe clj-date-schema]]]]))
|
||||
|
||||
(def key->handler
|
||||
(apply-middleware-to-all-handlers
|
||||
{::route/prime-cost page
|
||||
::route/run-prime-cost run}
|
||||
(fn [h]
|
||||
(-> h
|
||||
(wrap-schema-enforce :query-schema query-schema)
|
||||
(wrap-must {:activity :read :subject :profit-and-loss})
|
||||
(wrap-client-redirect-unauthenticated)))))
|
||||
@@ -24,4 +24,6 @@
|
||||
"/export" ::export-cash-flows}
|
||||
"/reports/profit-and-loss" {"" ::profit-and-loss
|
||||
"/run" ::run-profit-and-loss
|
||||
"/export" ::export-profit-and-loss}})
|
||||
"/export" ::export-profit-and-loss}
|
||||
"/reports/prime-cost" {"" ::prime-cost
|
||||
"/run" ::run-prime-cost}})
|
||||
157
test/clj/auto_ap/ssr/ledger/prime_cost_test.clj
Normal file
157
test/clj/auto_ap/ssr/ledger/prime_cost_test.clj
Normal file
@@ -0,0 +1,157 @@
|
||||
(ns auto-ap.ssr.ledger.prime-cost-test
|
||||
(:require
|
||||
[auto-ap.datomic :refer [conn]]
|
||||
[auto-ap.integration.util :refer [setup-test-data wrap-setup]]
|
||||
[auto-ap.ledger.reports :as l-reports]
|
||||
[auto-ap.ssr.ledger.prime-cost :as sut]
|
||||
[clj-time.coerce :as coerce]
|
||||
[clj-time.core :as time]
|
||||
[clojure.test :refer [deftest is testing use-fixtures]]
|
||||
[datomic.api :as dc]))
|
||||
|
||||
(use-fixtures :each wrap-setup)
|
||||
|
||||
(def week-ending (time/date-time 2026 8 10))
|
||||
(def in-week #inst "2026-08-05T12:00:00.000-00:00")
|
||||
(def week-before #inst "2026-07-29T12:00:00.000-00:00")
|
||||
|
||||
(defn- account [tempid code]
|
||||
{:db/id tempid :account/name (str "Account " code) :account/numeric-code code})
|
||||
|
||||
(defn- jel [client account-tempid date debit]
|
||||
{:journal-entry-line/client client
|
||||
:journal-entry-line/account account-tempid
|
||||
:journal-entry-line/date date
|
||||
:journal-entry-line/debit debit})
|
||||
|
||||
(deftest week-windows-tile-without-gaps
|
||||
(testing "each week ends where the next begins, so no day is counted twice or missed"
|
||||
(let [ws (sut/week-windows week-ending)]
|
||||
(is (= sut/weeks-shown (count ws)))
|
||||
(is (= (coerce/to-date week-ending) (coerce/to-date (:ends (first ws))))
|
||||
"the newest window ends at the requested date")
|
||||
(is (every? (fn [[newer older]] (= (:starts newer) (:ends older)))
|
||||
(partition 2 1 ws))
|
||||
"windows abut")
|
||||
(is (apply > (map (comp coerce/to-long :starts) ws))
|
||||
"most recent first"))))
|
||||
|
||||
(deftest payroll-bands-come-from-the-shared-ledger-groupings
|
||||
(testing "the bands are the ledger's, not a second list that can drift from the P&L"
|
||||
(is (= (:payroll l-reports/groupings) sut/payroll-bands)))
|
||||
(testing "every payroll account code lands in exactly one band"
|
||||
(is (some? (sut/band-for 60500)) "general payroll is not orphaned")
|
||||
(is (some? (sut/band-for 61100)))
|
||||
(is (some? (sut/band-for 62200)))
|
||||
(is (some? (sut/band-for 63200)))
|
||||
(is (some? (sut/band-for 65000)))
|
||||
(is (some? (sut/band-for 69800)))
|
||||
(is (nil? (sut/band-for 50000)) "food cost is not payroll")
|
||||
(is (nil? (sut/band-for 70000)) "controllable costs are not payroll")))
|
||||
|
||||
(deftest payroll-is-debits-less-credits-within-the-week
|
||||
(testing "a credit correction reduces the week rather than counting as more labour, and lines
|
||||
outside the window are excluded"
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])]
|
||||
@(dc/transact conn [(account "foh" 63200)
|
||||
(account "boh" 62200)
|
||||
(jel test-client-id "foh" in-week 1000.0)
|
||||
(jel test-client-id "boh" in-week 400.0)
|
||||
{:journal-entry-line/client test-client-id
|
||||
:journal-entry-line/account "foh"
|
||||
:journal-entry-line/date in-week
|
||||
:journal-entry-line/credit 100.0}
|
||||
(jel test-client-id "foh" week-before 999.0)])
|
||||
(let [{:keys [total by-band]} (sut/payroll-in-window
|
||||
(dc/db conn) [test-client-id]
|
||||
(time/minus week-ending (time/weeks 1)) week-ending)]
|
||||
(is (= 1300.0 total) "1000 + 400 - 100, and nothing from the prior week")
|
||||
(is (= 900.0 (get by-band "63000-66000 Payroll - FOH")))
|
||||
(is (= 400.0 (get by-band "62000 Payroll - BOH")))))))
|
||||
|
||||
(deftest payroll-excludes-non-payroll-accounts
|
||||
(testing "only the 60000 block counts, so food cost never lands in labour"
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])]
|
||||
@(dc/transact conn [(account "foh" 63200)
|
||||
(account "food" 50000)
|
||||
(jel test-client-id "foh" in-week 500.0)
|
||||
(jel test-client-id "food" in-week 5000.0)])
|
||||
(is (= 500.0 (:total (sut/payroll-in-window
|
||||
(dc/db conn) [test-client-id]
|
||||
(time/minus week-ending (time/weeks 1)) week-ending)))))))
|
||||
|
||||
(deftest sales-count-revenue-only-and-net-of-discounts
|
||||
(testing "tender, tax and tip are not sales, and a debit against revenue reduces it"
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])]
|
||||
@(dc/transact conn [(account "food-rev" 40111)
|
||||
(account "discount" 41000)
|
||||
(account "tax" 25700)
|
||||
(account "card" 75460)
|
||||
{:sales-summary/client test-client-id
|
||||
:sales-summary/date in-week
|
||||
:sales-summary/client+date [test-client-id in-week]
|
||||
:sales-summary/items
|
||||
[{:sales-summary-item/category "Gyros"
|
||||
:ledger-mapped/amount 1000.0
|
||||
:ledger-mapped/ledger-side :ledger-side/credit
|
||||
:ledger-mapped/account "food-rev"}
|
||||
{:sales-summary-item/category "Discounts"
|
||||
:ledger-mapped/amount 100.0
|
||||
:ledger-mapped/ledger-side :ledger-side/debit
|
||||
:ledger-mapped/account "discount"}
|
||||
{:sales-summary-item/category "Tax"
|
||||
:ledger-mapped/amount 90.0
|
||||
:ledger-mapped/ledger-side :ledger-side/credit
|
||||
:ledger-mapped/account "tax"}
|
||||
{:sales-summary-item/category "Card Payments"
|
||||
:ledger-mapped/amount 990.0
|
||||
:ledger-mapped/ledger-side :ledger-side/debit
|
||||
:ledger-mapped/account "card"}]}])
|
||||
(let [{:keys [total by-category]} (sut/sales-in-window
|
||||
(dc/db conn) [test-client-id]
|
||||
(time/minus week-ending (time/weeks 1)) week-ending)]
|
||||
(is (= 900.0 total) "1000 of revenue less a 100 discount; tax and tender excluded")
|
||||
(is (= 1000.0 (get by-category "Gyros")))
|
||||
(is (= -100.0 (get by-category "Discounts")))
|
||||
(is (nil? (get by-category "Tax")) "tax is money held for someone else, not a sale")
|
||||
(is (nil? (get by-category "Card Payments")) "tender is the other side of the sale")))))
|
||||
|
||||
(deftest the-headline-ratio-is-payroll-over-sales
|
||||
(testing "the number the report exists for"
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])]
|
||||
@(dc/transact conn [(account "food-rev" 40111)
|
||||
(account "foh" 63200)
|
||||
(jel test-client-id "foh" in-week 250.0)
|
||||
{:sales-summary/client test-client-id
|
||||
:sales-summary/date in-week
|
||||
:sales-summary/client+date [test-client-id in-week]
|
||||
:sales-summary/items
|
||||
[{:sales-summary-item/category "Gyros"
|
||||
:ledger-mapped/amount 1000.0
|
||||
:ledger-mapped/ledger-side :ledger-side/credit
|
||||
:ledger-mapped/account "food-rev"}]}])
|
||||
(let [columns (sut/get-report-data (dc/db conn) [test-client-id] week-ending)
|
||||
current (first columns)]
|
||||
(is (= sut/weeks-shown (count columns)))
|
||||
(is (= 0.25 (:labor-ratio current)) "250 of labour against 1000 of sales")
|
||||
(is (nil? (:labor-ratio (second columns)))
|
||||
"a week with no sales has no ratio rather than a divide-by-zero")))))
|
||||
|
||||
(deftest report-table-renders-a-column-per-week-plus-label-and-average
|
||||
(testing "the table lines up with its header"
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])]
|
||||
@(dc/transact conn [(account "food-rev" 40111)
|
||||
{:sales-summary/client test-client-id
|
||||
:sales-summary/date in-week
|
||||
:sales-summary/client+date [test-client-id in-week]
|
||||
:sales-summary/items
|
||||
[{:sales-summary-item/category "Gyros"
|
||||
:ledger-mapped/amount 1000.0
|
||||
:ledger-mapped/ledger-side :ledger-side/credit
|
||||
:ledger-mapped/account "food-rev"}]}])
|
||||
(let [{:keys [header rows]} (sut/report-table
|
||||
(sut/get-report-data (dc/db conn) [test-client-id] week-ending))
|
||||
width (+ 2 sut/weeks-shown)]
|
||||
(is (= width (count (first header))))
|
||||
(is (every? #(= width (count %)) rows)
|
||||
"every row is the same width as the header, or the table skews")))))
|
||||
Reference in New Issue
Block a user