Files
integreat/src/cljc/auto_ap/ledger/reports.cljc

564 lines
27 KiB
Clojure

(ns auto-ap.ledger.reports
#?@
(:clj
[(:require
[auto-ap.time :as atime]
[auto-ap.utils :refer [dollars-0?]]
[clojure.string :as str])]
:cljs
[(:require
[auto-ap.utils :refer [dollars-0?]]
[auto-ap.views.utils :as au]
[clojure.string :as str])]))
(defn date->str [d]
#?(:clj
(atime/unparse-local d atime/normal-date)
:cljs (au/date->str d au/pretty)))
(def ranges
{:assets [11000 19999]
:liabilities [20100 28999]
:equities [30000 39999]
:sales [40000 49999]
:cogs [50000 59999]
:payroll [60000 69999]
:controllable [70000 79999]
:fixed-overhead [80000 89999]
:ownership-controllable [90000 99999]})
(def groupings
{:assets [["1100 Cash and Bank Accounts" 11000 11999]
["1200 Accounts Receivable" 12000 12999]
["1300 Inventory" 13000 13999]
["1400 Prepaid Expenses" 14000 14999]
["1500 Property and Equipment" 15000 15999]
["1600 Intangible Assets" 16000 16999]
["1700 Other Assets" 17000 19999]]
:liabilities [["2000 Accounts Payable" 20100 23999]
["2400 Accrued Expenses" 24000 24999]
["2500 Other Liabilities" 25000 25999]
["2600 Split Accounts" 26000 26999]
["2700 Current Portion of Long-Term Debt" 27000 27999]
["2800 Notes Payable" 28000 28999]]
:equities [["3000 Owner's Equity" 30000 39999]]
:sales [["40000-43999 Food Sales " 40000 43999]
["44000-46999 Alcohol Sales" 44000 46999]
["47000 Merchandise Sales" 47000 47999]
["48000 Other Operating Income" 48000 48999]
["49000 Non-Business Income" 49000 49999]]
:cogs [
["50000-54000 Food Costs" 50000 53999]
["54000-56000 Alcohol Costs" 54000 55999]
["56000 Merchandise Costs" 56000 56999]
["57000-60000 Other Costs of Sales" 57000 59999]]
:payroll [["60000 Payroll - General" 60000 60999]
["61000 Payroll - Management" 61000 61999]
["62000 Payroll - BOH" 62000 62999]
["63000-66000 Payroll - FOH" 63000 65999]
["66000-70000 Payroll - Other" 66000 69999]]
:controllable [["70000 72000 GM Controllable Costs - Ops Related" 70000 71999]
["72000 GM Controllable Costs - Customer Related" 72000 72999]
["73000 GM Controllable Costs - Employee Related" 73000 73999]
["74000 GM Controllable Costs - Building & Equipment Related" 74000 74999]
["75000 GM Controllable Costs - Office & Management Related" 75000 75999]
["76000-80000 GM Controllable Costs - Other" 76000 79999]]
:fixed-overhead [["80000-82000 Operational Costs" 80000 81999]
["82000 Occupancy Costs" 82000 82999]
["83000 Utility Costs" 83000 83999]
["84000 Equipment Rental" 84000 84999]
["85000-87000 Taxes & Insurance" 85000 86999]
["87000-90000 Other Non-Controllable Costs" 87000 89999]]
:ownership-controllable [["90000-93000 Research & Entertainment" 90000 92999]
["93000 Bank Charges & Interest" 93000 93999]
["94000-96000 Other Owner Controllable Costs" 94000 95999]
["96000 Depreciation" 96000 96999]
["97000 Taxes" 97000 97999]
["98000 Other Expenses" 98000 98999]]})
(defn in-range? [code]
(if code
(reduce
(fn [acc [start end]]
(if (<= start code end)
(reduced true)
acc))
false
(vals ranges))
false))
(defn client-locations [pnl-data]
(->> pnl-data
:data
(filter (comp in-range? :numeric-code))
(filter #(not= "A" (:location %)))
(group-by (juxt :client-id :location))
(filter (fn [[_ as]]
(not (dollars-0? (reduce + 0 (map :amount as))))))
(mapcat second)
(map (fn [a]
(if (or (not (:client-id a))
(empty? (:location a)))
nil
[(:client-id a)
(:location a)])))
(filter identity)
(set)
(sort-by (fn [x]
[(:client-id x)
(if (= (:location x) "HQ" )
"ZZZZZZ"
(:location x))]))))
(defn locations [pnl-data]
(->> (client-locations pnl-data)
(map second)
set
(sort-by (fn [x]
(if (= x "HQ" )
"ZZZZZZ"
x)))))
(defn aggregate-accounts [pnl-data]
(reduce (fnil + 0.0) 0.0 (map :amount (:data pnl-data))))
(defn best-category [a]
(->> ranges
(filter (fn [[_ [start end]]]
(and (:numeric-code a)
(<= start (:numeric-code a) end))))
first
first))
(defn filter-client [pnl-data client]
(-> pnl-data
(update :data (fn [data]
((group-by :client-id data) client)))
(update :filters (fn [f]
(assoc f :client-id client)))))
(defn filter-location [pnl-data location]
(-> pnl-data
(update :data (fn [data]
((group-by :location data) location)))
(update :filters (fn [f]
(assoc f :location location)))))
(defn filter-numeric-code [pnl-data from to]
(-> pnl-data
(update :data (fn [data]
(filter
#(<= from (or (:numeric-code %) 0) to)
data)))
(update :filters (fn [f]
(assoc f
:from-numeric-code from
:to-numeric-code to)))))
(defn filter-categories [pnl-data categories]
(if (= 1 (count categories))
(let [[from to] (ranges (first categories))]
(-> pnl-data
(filter-numeric-code from to)))
(-> pnl-data
(update :data (fn [data]
(mapcat identity
((apply juxt categories)
(group-by best-category data))))))))
(defn filter-period [pnl-data period]
(-> pnl-data
(update :data (fn [data]
((group-by :period data) period)))
(update :filters (fn [f]
(assoc f :date-range period)))))
(defn zebra [pnl-data i]
(if (odd? i)
(assoc-in pnl-data [:cell-args :bg-color] [240 240 240])
pnl-data))
(defn negate [pnl-data types]
(update pnl-data :data
(fn [accounts]
(map
(fn [account]
(if (types (best-category account))
(update account :amount -)
account))
accounts))))
(defn used-accounts [pnl-datas]
(->>
pnl-datas
(mapcat :data)
(map #(select-keys % [:numeric-code :name]))
(set)
(sort-by :numeric-code)))
(defn subtotal-by-column-row [pnl-datas title & [cell-args]]
(into [{:value title
:bold true}]
(map
(fn [p]
(merge
{:format :dollar
:value (aggregate-accounts p)
:filters (when (:from-numeric-code (:filters p)) ;; don't allow filtering when you don't at least filter numeric codes
(:filters p))}
(:cell-args p)
cell-args))
pnl-datas)))
(defn calc-percent-of-sales [table pnl-datas]
(let [sales (map
(fn [p]
(aggregate-accounts (filter-categories p [:sales])))
pnl-datas)]
(->> table
(map (fn [[_ & values]]
(map
(fn [v s]
{:border (:border v)
:bg-color (:bg-color v)
:format (if (string? (:value v))
:text
:percent)
:color [128 128 128]
:value (cond
(string? (:value v))
""
(dollars-0? s)
0.0
:else
(/ (:value v) s))})
values sales))))))
(defn calc-deltas [table]
(->> table
(map (fn [[_ & values]]
(->> values
(partition 2 1)
(map (fn [[a b]]
(if (or (string? (:value b))
(string? (:value a)))
{:value ""
:format :text
:bg-color (:bg-color b)}
{:border (:border b)
:format :dollar
:value (- (:value b)
(:value a))
:bg-color (:bg-color b)}))))))))
(defn combine-tables
([[pnl-data] table percent-of-sales deltas]
(map (fn [[title & row] percent-of-sales deltas ]
(let [deltas (cons {:value 0.0
:format :dollar
:border (:border (first row))} deltas)]
(into [title]
(mapcat
(fn [v p d]
(if (:include-deltas (:args pnl-data))
[v p d]
[v p]))
row
percent-of-sales
deltas))))
table
percent-of-sales
deltas)))
(defn headers [[pnl-data :as pnl-datas] header-title]
(let [big-header (into [{:value header-title
:bold true}]
(map-indexed (fn [i p]
(cond-> {:value
(str (date->str (:start p))
" - "
(date->str (:end p)))
:colspan (cond
(-> pnl-data :args :include-deltas)
3
(-> pnl-data :args :column-per-location)
(* 2 (/ (count pnl-datas)
(count (-> pnl-data :args :periods))))
:else
2)
:align :center
:bold true}
(odd? i) (assoc :bg-color [240 240 240])))
(:periods (:args pnl-data))))
sub-header (into [{:value ""}]
(if (-> pnl-data :args :column-per-location)
(mapcat
(fn [p]
(cond-> [(merge {:value (or (when (-> p :filters :location)
(str ((-> p :client-codes) (-> p :filters :client-id)) "-" (-> p :filters :location)))
"Total")
:align :right}
(:cell-args p))
(merge {:value "%"
:align :right}
(:cell-args p))]
(-> pnl-data :args :include-deltas) (conj (merge {:value "+/-"
:align :right}
(:cell-args p)))))
pnl-datas)
(mapcat
(fn [p]
(cond-> [(merge {:value "Amt"
:align :right}
(:cell-args p))
(merge {:value "%"
:align :right}
(:cell-args p))]
(-> pnl-data :args :include-deltas) (conj (merge {:value "+/-"
:align :right}
(:cell-args p)))))
pnl-datas)))]
[big-header
sub-header]))
(defn location-summary-table [pnl-datas title]
(let [table [(subtotal-by-column-row (map #(filter-categories % [:sales])
pnl-datas)
"Sales")
(subtotal-by-column-row (map #(filter-categories % [:cogs ]) pnl-datas)
"Cogs")
(subtotal-by-column-row (map #(filter-categories % [:payroll ]) pnl-datas)
"Payroll")
(subtotal-by-column-row (map #(-> %
(filter-categories [:sales :payroll :cogs])
(negate #{:payroll :cogs}))
pnl-datas)
"Gross Profits")
(subtotal-by-column-row (map #(filter-categories % [:controllable :fixed-overhead :ownership-controllable])
pnl-datas)
"Overhead")
(subtotal-by-column-row (map #(-> %
(filter-categories [:sales :cogs :payroll :controllable :fixed-overhead :ownership-controllable])
(negate #{:cogs :payroll :controllable :fixed-overhead :ownership-controllable}))
pnl-datas)
"Net Income")]
percent-of-sales (calc-percent-of-sales table pnl-datas)
deltas (calc-deltas table)]
{:header (headers pnl-datas title)
:rows (combine-tables pnl-datas table percent-of-sales deltas)}))
(defn detail-rows [pnl-datas grouping title]
(let [pnl-datas (map #(filter-categories % [grouping])
pnl-datas)
individual-accounts
(for [[grouping-name from to] (groupings grouping)
:let [pnl-datas (map #(filter-numeric-code % from to)
pnl-datas)
account-codes (used-accounts pnl-datas)]
:when (seq account-codes)
row (-> [(into [{:value (str "---" grouping-name "---")}]
(map
(fn [p]
(assoc (:cell-args p) :value "" :format ""))
pnl-datas)
)]
(into (for [{:keys [numeric-code name]} account-codes]
(into [{:value name}]
(map
(fn [p]
(let [pnl-data (-> p (filter-numeric-code numeric-code numeric-code))]
(merge
{:format :dollar
:filters (:filters pnl-data)
:value (aggregate-accounts pnl-data)}
(:cell-args p))))
pnl-datas))))
(conj (subtotal-by-column-row pnl-datas "" {:border [:top]})))]
row)]
(-> [(into [{:value title
:bold true}]
(map
(fn [p]
(assoc (:cell-args p) :value "" :format ""))
pnl-datas))]
(into individual-accounts)
(conj (subtotal-by-column-row pnl-datas title)))))
(defn location-detail-table [pnl-datas client-datas title prefix]
(let [table (-> []
(into (detail-rows pnl-datas
:sales
(str prefix " Sales")))
(into (detail-rows pnl-datas
:cogs
(str prefix " COGS")))
(into (detail-rows
pnl-datas
:payroll
(str prefix " Payroll")))
(conj (subtotal-by-column-row (map #(filter-categories % [:payroll :cogs])
pnl-datas)
(str prefix " Prime Costs")))
(conj (subtotal-by-column-row (map #(-> %
(filter-categories [:sales :payroll :cogs])
(negate #{:payroll :cogs}))
pnl-datas)
(str prefix " Gross Profits")))
(into (detail-rows
pnl-datas
:controllable
(str prefix " Controllable Expenses")))
(into (detail-rows
pnl-datas
:fixed-overhead
(str prefix " Fixed Overhead")))
(into (detail-rows
pnl-datas
:ownership-controllable
(str prefix " Ownership Controllable")))
(conj (subtotal-by-column-row (map #(filter-categories % [:controllable :fixed-overhead :ownership-controllable]) pnl-datas)
(str prefix " Overhead")))
(conj (subtotal-by-column-row (map #(-> %
(filter-categories [:sales :cogs :payroll :controllable :fixed-overhead :ownership-controllable])
(negate #{:cogs :payroll :controllable :fixed-overhead :ownership-controllable}))
pnl-datas)
(str prefix " Net Income"))))
table (if (seq client-datas)
(conj table (subtotal-by-column-row (map #(-> %
(filter-categories [:sales :cogs :payroll :controllable :fixed-overhead :ownership-controllable])
(negate #{:cogs :payroll :controllable :fixed-overhead :ownership-controllable}))
client-datas)
"All Location Net Income"))
table)
percent-of-sales (calc-percent-of-sales table pnl-datas)
deltas (into [] (calc-deltas table))]
{:header (headers pnl-datas title)
:rows (combine-tables pnl-datas table percent-of-sales deltas)}))
(defn warning-message [pnl-data]
(let [errors (->> pnl-data
:data
(filter (fn [{:keys [numeric-code]}]
(nil? numeric-code))))
error-count (count errors)]
(when (> error-count 0)
(str "This report does not include "
(str/join ", "
(map #(str (:count %) " unresolved ledger entries for " (if (str/blank? (:location %))
" all locations"
(:location %)))
errors))))))
(defn summarize-pnl [pnl-data]
{:warning (warning-message pnl-data)
:summaries
(if (-> pnl-data :args :column-per-location)
[(location-summary-table (mapcat identity (for [[period i] (map vector (-> pnl-data :args :periods ) (range))]
(concat
(for [[client-id location] (client-locations pnl-data)]
(-> pnl-data
(filter-client client-id)
(filter-location location)
(filter-period period)
(zebra i)))
[(zebra (filter-period pnl-data period) i)])))
"All location Summary")]
(for [[client-id location] (client-locations pnl-data)]
(location-summary-table (for [[period i] (map vector (-> pnl-data :args :periods ) (range))]
(-> pnl-data
(filter-client client-id)
(filter-location location)
(filter-period period)
(zebra i)))
(str (-> pnl-data :clients-by-id (get client-id)) " (" location ") Summary"))))
:details
(doall (if (-> pnl-data :args :column-per-location)
[(location-detail-table (mapcat identity (for [[period i] (map vector (-> pnl-data :args :periods ) (range))]
(concat
(for [[client-id location] (client-locations pnl-data)]
(-> pnl-data
(filter-client client-id)
(filter-location location)
(filter-period period)
(zebra i)))
[(-> pnl-data
(filter-period period)
(zebra i))])))
nil
"All location Detail"
"")]
(for [[client-id location] (client-locations pnl-data)]
(location-detail-table (for [[period i] (map vector (-> pnl-data :args :periods ) (range))]
(-> pnl-data
(filter-client client-id)
(filter-location location)
(filter-period period)
(zebra i)))
(for [[period i] (map vector (-> pnl-data :args :periods ) (range))]
(-> pnl-data
(filter-client client-id)
(filter-period period)
(zebra i)))
(str (-> pnl-data :clients-by-id (get client-id)) " (" location ") Detail")
location))))})
(defn balance-sheet-headers [pnl-data]
[(cond-> [{:value "Period Ending"}
{:value (date->str (:date (:args pnl-data)))}]
(:include-comparison (:args pnl-data)) (into [{:value (date->str (:comparison-date (:args pnl-data)))} {:value "+/-"}]))])
(defn append-deltas [table]
(->> table
(map (fn [[title a b]]
[title a b (and (:value a) (:value b)
{:border (:border b)
:format :dollar
:value (- (or (:value a) 0.0)
(or (:value b) 0.0))})]))))
(defn summarize-balance-sheet [pnl-data]
(let [pnl-datas (map (fn [p]
(filter-period pnl-data p))
(:periods (:args pnl-data)))
table (-> []
(into (detail-rows pnl-datas
:assets
"Assets"))
(into (detail-rows pnl-datas
:liabilities
"Liabilities"))
(into (detail-rows pnl-datas
:equities
"Owner's Equity"))
(conj (subtotal-by-column-row
(map #(-> %
(filter-categories [:sales :cogs :payroll :controllable :fixed-overhead :ownership-controllable])
(negate #{:cogs :payroll :controllable :fixed-overhead :ownership-controllable}))
pnl-datas)
"Retained Earnings")))
table (if (:include-comparison (:args pnl-data))
(append-deltas table)
table)]
{:header (balance-sheet-headers pnl-data)
:rows table}))
(defrecord PNLData [args data client-codes])