diff --git a/src/clj/auto_ap/pdf/ledger.clj b/src/clj/auto_ap/pdf/ledger.clj index 8d57f7b9..763dc5d0 100644 --- a/src/clj/auto_ap/pdf/ledger.clj +++ b/src/clj/auto_ap/pdf/ledger.clj @@ -1,6 +1,7 @@ (ns auto-ap.pdf.ledger (:require [amazonica.aws.s3 :as s3] + [auto-ap.ledger.reports :as l-reports] [auto-ap.datomic :refer [conn]] [auto-ap.graphql.utils :refer [<-graphql]] [auto-ap.time :as atime] @@ -9,333 +10,18 @@ [clojure.java.io :as io] [clojure.string :as str] [config.core :refer [env]] - [datomic.api :as d] - [clojure.tools.logging :as log]) + [datomic.api :as d]) (:import (java.io ByteArrayOutputStream) (java.text DecimalFormat) (java.util UUID))) -(defn date->str [d] - (atime/unparse-local d atime/normal-date)) - -(def ranges - {:sales [40000 49999] - :cogs [50000 59999] - :payroll [60000 69999] - :controllable [70000 79999] - :fixed-overhead [80000 89999] - :ownership-controllable [90000 99999]}) - -(def groupings - {: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 locations [data] - (->> data - (filter (comp in-range? :numeric-code)) - (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 aggregate-accounts [pnl-data] - (reduce (fnil + 0.0) 0.0 (map :amount (:data pnl-data)))) - -(defn best-category [a] - (->> ranges - (filter (fn [[_ [start end]]] - (<= start (:numeric-code a) end))) - first - first)) - -(defn filter-client [pnl-data client] - (update pnl-data :data (fn [data] - ((group-by :client-id data) client)))) - -(defn filter-location [pnl-data location] - (update pnl-data :data (fn [data] - ((group-by :location data) location)))) - -(defn filter-categories [pnl-data categories] - (update pnl-data :data (fn [data] - (mapcat identity - ((apply juxt categories) - (group-by best-category data)))))) - -(defn filter-period [pnl-data period] - (update pnl-data :data (fn [data] - ((group-by :period data) period)))) - -(defn filter-numeric-code [pnl-data from to] - (update pnl-data :data (fn [data] - (filter - #(<= from (:numeric-code %) to) - 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-data] - (->> (:data pnl-data) - (map #(select-keys % [:numeric-code :name])) - (set) - (sort-by :numeric-code))) - -(defn subtotal-row [pnl-data title] - (into [{:value title - :bold true}] - (map - (fn [p] - {:format :dollar - :value (aggregate-accounts (filter-period pnl-data p))}) - (-> pnl-data :args :periods)))) - -(defn calc-percent-of-sales [table pnl-data] - (let [sales-pnl-data (filter-categories pnl-data [:sales]) - sales (map - (fn [p] - (aggregate-accounts (filter-period sales-pnl-data p))) - (-> pnl-data :args :periods))] - (->> table - (map (fn [[_ & values]] - (map - (fn [v s] - {:format :percent - :color [128 128 128] - :value (if (dollars-0? s) - 0.0 - (/ (:value v) s))}) - values sales)))))) - -(defn calc-deltas [table] - (->> table - (map (fn [[_ & values]] - (->> values - (partition 2 1) - (map (fn [[a b]] - {:format :dollar - :value (- (:value b) - (:value a))}))))))) - -(defn combine-tables [pnl-data table percent-of-sales deltas] - (map (fn [[title & row] percent-of-sales deltas ] - (let [deltas (cons 0.0 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 header-title] - (let [big-header (into [{:value header-title - :bold true}] - (map (fn [p] - {:value - (str (date->str (:start p)) - " - " - (date->str (:end p))) - :colspan (if (-> pnl-data :args :include-deltas) - 3 - 2) - :align :center - :bold true}) - (:periods (:args pnl-data)))) - sub-header (into [{:value ""}] - (mapcat - (fn [_] - (cond-> [{:value "Amt" - :align :right} - {:value "%" - :align :right}] - (-> pnl-data :args :include-deltas) (conj {:value "+/-" - :align :right}))) - (:periods (:args pnl-data))))] - [big-header - sub-header])) - -(defn location-summary-table [pnl-data title] - (let [table [(subtotal-row (filter-categories pnl-data [:sales]) "Sales") - (subtotal-row (filter-categories pnl-data [:cogs ]) "Cogs") - - (subtotal-row (filter-categories pnl-data [:payroll ]) "Payroll") - - (subtotal-row (-> pnl-data - (filter-categories [:sales :payroll :cogs]) - (negate #{:payroll :cogs})) - "Gross Profits") - - (subtotal-row (filter-categories pnl-data [:controllable :fixed-overhead :ownership-controllable]) - "Overhead") - - (subtotal-row (-> pnl-data - (filter-categories [:sales :cogs :payroll :controllable :fixed-overhead :ownership-controllable]) - (negate #{:cogs :payroll :controllable :fixed-overhead :ownership-controllable})) - "Net Income")] - percent-of-sales (calc-percent-of-sales table pnl-data) - deltas (calc-deltas table)] - {:header (headers pnl-data title) - :rows (combine-tables pnl-data table percent-of-sales deltas)})) - - -(defn detail-rows [pnl-data grouping title] - (let [pnl-data (filter-categories pnl-data [grouping]) - individual-accounts - (for [[grouping-name from to] (groupings grouping) - :let [pnl-data (filter-numeric-code pnl-data from to) - account-codes (used-accounts pnl-data)] - :when (seq account-codes) - row (-> [[{:value (str "---" grouping-name "---")}]] - (into (for [{:keys [numeric-code name]} account-codes] - (into [{:value name}] - (map - (fn [p] - {:format :dollar - :value (-> pnl-data - (filter-numeric-code numeric-code numeric-code) - (filter-period p) - (aggregate-accounts))}) - - (-> pnl-data :args :periods))))))] - row)] - (-> [[{:value title - :bold true}]] - (into individual-accounts) - (conj (subtotal-row pnl-data title))))) - -(defn location-detail-table [pnl-data title] - (let [table (-> [] - (into (detail-rows pnl-data - :sales - (str (:prefix pnl-data) " Sales"))) - (into (detail-rows pnl-data - :cogs - (str (:prefix pnl-data) " COGS"))) - (into (detail-rows - pnl-data - :payroll - (str (:prefix pnl-data) " Payroll"))) - (conj (subtotal-row (filter-categories pnl-data [:payroll :cogs]) - (str (:prefix pnl-data) " Prime Costs"))) - (conj (subtotal-row (-> pnl-data - (filter-categories [:sales :payroll :cogs]) - (negate #{:payroll :cogs})) - (str (:prefix pnl-data) " Gross Profits"))) - (into (detail-rows - pnl-data - :fixed-overhead - (str (:prefix pnl-data) " Fixed Overhead"))) - (into (detail-rows - pnl-data - :ownership-controllable - (str (:prefix pnl-data) " Ownership Controllable"))) - (conj (subtotal-row (-> pnl-data - (filter-categories [:controllable :fixed-overhead :ownership-controllable])) - (str (:prefix pnl-data) " Overhead"))) - (conj (subtotal-row (-> pnl-data - (filter-categories [:sales :cogs :payroll :controllable :fixed-overhead :ownership-controllable]) - (negate #{:cogs :payroll :controllable :fixed-overhead :ownership-controllable})) - (str (:prefix pnl-data) " Net Income")))) - percent-of-sales (calc-percent-of-sales table pnl-data) - deltas (into [] (calc-deltas table))] - {:header (headers pnl-data title) - :rows (combine-tables pnl-data table percent-of-sales deltas)})) - -(defn summarize-pnl [pnl-data] - {:summaries (for [[client-id location] (locations (:data pnl-data))] - (location-summary-table (-> pnl-data - (filter-client client-id) - (filter-location location)) - (str (-> pnl-data :clients-by-id (get client-id) :client/name) " (" location ") Summary"))) - :details (for [[client-id location] (locations (:data pnl-data))] - (location-detail-table (-> pnl-data - (filter-client client-id) - (filter-location location) - (assoc :prefix location)) - (str (-> pnl-data :clients-by-id (get client-id) :client/name) " (" location ") Detail")))}) - - - - -(defrecord PNLData [args data clients-by-id]) - (defn cell->pdf [cell] [:pdf-cell (cond-> {} + (:border cell) (assoc :border true + :set-border (:border cell)) (:colspan cell) (assoc :colspan (:colspan cell)) (:align cell) (assoc :align (:align cell)) (= :dollar (:format cell)) (assoc :align :right) @@ -467,8 +153,8 @@ (:accounts p2)) ) (:periods args))) - pnl-data (PNLData. args data (by :db/id clients)) - report (summarize-pnl pnl-data) + pnl-data (l-reports/PNLData. args data (by :db/id clients)) + report (l-reports/summarize-pnl pnl-data) output-stream (ByteArrayOutputStream.)] (pdf/pdf (into @@ -513,7 +199,7 @@ (let [uuid (str (UUID/randomUUID)) pdf-data (make-pnl args data) name (args->name args) - key (str "reports/pnl/" uuid "/ " name ".pdf") + key (str "reports/pnl/" uuid "/" name ".pdf") url (str "http://" (:data-bucket env) ".s3-website-us-east-1.amazonaws.com/" key)] (s3/put-object :bucket-name (:data-bucket env) :key key diff --git a/src/cljc/auto_ap/ledger/reports.cljc b/src/cljc/auto_ap/ledger/reports.cljc new file mode 100644 index 00000000..a7d81f2d --- /dev/null +++ b/src/cljc/auto_ap/ledger/reports.cljc @@ -0,0 +1,338 @@ +(ns auto-ap.ledger.reports + (:require + [auto-ap.utils :refer [dollars-0?]] + #?(:cljs [auto-ap.views.utils :as au ] + :clj [auto-ap.time :as atime]))) + + +(defn date->str [d] + #?(:clj + (atime/unparse-local d atime/normal-date) + :cljs (au/date->str d au/standard))) + + +(def ranges + {:sales [40000 49999] + :cogs [50000 59999] + :payroll [60000 69999] + :controllable [70000 79999] + :fixed-overhead [80000 89999] + :ownership-controllable [90000 99999]}) + +(def groupings + {: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 locations [data] + (->> data + (filter (comp in-range? :numeric-code)) + (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 aggregate-accounts [pnl-data] + (reduce (fnil + 0.0) 0.0 (map :amount (:data pnl-data)))) + +(defn best-category [a] + (->> ranges + (filter (fn [[_ [start end]]] + (<= start (:numeric-code a) end))) + first + first)) + +(defn filter-client [pnl-data client] + (update pnl-data :data (fn [data] + ((group-by :client-id data) client)))) + +(defn filter-location [pnl-data location] + (update pnl-data :data (fn [data] + ((group-by :location data) location)))) + +(defn filter-categories [pnl-data categories] + (update pnl-data :data (fn [data] + (mapcat identity + ((apply juxt categories) + (group-by best-category data)))))) + +(defn filter-period [pnl-data period] + (update pnl-data :data (fn [data] + ((group-by :period data) period)))) + +(defn filter-numeric-code [pnl-data from to] + (update pnl-data :data (fn [data] + (filter + #(<= from (:numeric-code %) to) + 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-data] + (->> (:data pnl-data) + (map #(select-keys % [:numeric-code :name])) + (set) + (sort-by :numeric-code))) + +(defn subtotal-row [pnl-data title & [cell-args]] + (into [{:value title + :bold true}] + (map + (fn [p] + (merge + {:format :dollar + :value (aggregate-accounts (filter-period pnl-data p))} + cell-args)) + (-> pnl-data :args :periods)))) + +(defn calc-percent-of-sales [table pnl-data] + (let [sales-pnl-data (filter-categories pnl-data [:sales]) + sales (map + (fn [p] + (aggregate-accounts (filter-period sales-pnl-data p))) + (-> pnl-data :args :periods))] + (->> table + (map (fn [[_ & values]] + (map + (fn [v s] + {:border (:border v) + :format :percent + :color [128 128 128] + :value (if (dollars-0? s) + 0.0 + (/ (:value v) s))}) + values sales)))))) + +(defn calc-deltas [table] + (->> table + (map (fn [[_ & values]] + (->> values + (partition 2 1) + (map (fn [[a b]] + {:border (:border b) + :format :dollar + :value (- (:value b) + (:value a))}))))))) + +(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 header-title] + (let [big-header (into [{:value header-title + :bold true}] + (map (fn [p] + {:value + (str (date->str (:start p)) + " - " + (date->str (:end p))) + :colspan (if (-> pnl-data :args :include-deltas) + 3 + 2) + :align :center + :bold true}) + (:periods (:args pnl-data)))) + sub-header (into [{:value ""}] + (mapcat + (fn [_] + (cond-> [{:value "Amt" + :align :right} + {:value "%" + :align :right}] + (-> pnl-data :args :include-deltas) (conj {:value "+/-" + :align :right}))) + (:periods (:args pnl-data))))] + [big-header + sub-header])) + +(defn location-summary-table [pnl-data title] + (let [table [(subtotal-row (filter-categories pnl-data [:sales]) "Sales") + (subtotal-row (filter-categories pnl-data [:cogs ]) "Cogs") + + (subtotal-row (filter-categories pnl-data [:payroll ]) "Payroll") + + (subtotal-row (-> pnl-data + (filter-categories [:sales :payroll :cogs]) + (negate #{:payroll :cogs})) + "Gross Profits") + + (subtotal-row (filter-categories pnl-data [:controllable :fixed-overhead :ownership-controllable]) + "Overhead") + + (subtotal-row (-> pnl-data + (filter-categories [:sales :cogs :payroll :controllable :fixed-overhead :ownership-controllable]) + (negate #{:cogs :payroll :controllable :fixed-overhead :ownership-controllable})) + "Net Income")] + percent-of-sales (calc-percent-of-sales table pnl-data) + deltas (calc-deltas table)] + {:header (headers pnl-data title) + :rows (combine-tables pnl-data table percent-of-sales deltas)})) + + +(defn detail-rows [pnl-data grouping title] + (let [pnl-data (filter-categories pnl-data [grouping]) + individual-accounts + (for [[grouping-name from to] (groupings grouping) + :let [pnl-data (filter-numeric-code pnl-data from to) + account-codes (used-accounts pnl-data)] + :when (seq account-codes) + row (-> [[{:value (str "---" grouping-name "---")}]] + (into (for [{:keys [numeric-code name]} account-codes] + (into [{:value name}] + (map + (fn [p] + {:format :dollar + :value (-> pnl-data + (filter-numeric-code numeric-code numeric-code) + (filter-period p) + (aggregate-accounts))}) + + (-> pnl-data :args :periods))))) + (conj (subtotal-row pnl-data "" {:border [:top]})))] + row)] + (-> [[{:value title + :bold true}]] + (into individual-accounts) + (conj (subtotal-row pnl-data title))))) + +(defn location-detail-table [pnl-data client-data title] + (let [table (-> [] + (into (detail-rows pnl-data + :sales + (str (:prefix pnl-data) " Sales"))) + (into (detail-rows pnl-data + :cogs + (str (:prefix pnl-data) " COGS"))) + (into (detail-rows + pnl-data + :payroll + (str (:prefix pnl-data) " Payroll"))) + (conj (subtotal-row (filter-categories pnl-data [:payroll :cogs]) + (str (:prefix pnl-data) " Prime Costs"))) + (conj (subtotal-row (-> pnl-data + (filter-categories [:sales :payroll :cogs]) + (negate #{:payroll :cogs})) + (str (:prefix pnl-data) " Gross Profits"))) + (into (detail-rows + pnl-data + :fixed-overhead + (str (:prefix pnl-data) " Fixed Overhead"))) + (into (detail-rows + pnl-data + :ownership-controllable + (str (:prefix pnl-data) " Ownership Controllable"))) + (conj (subtotal-row (-> pnl-data + (filter-categories [:controllable :fixed-overhead :ownership-controllable])) + (str (:prefix pnl-data) " Overhead"))) + (conj (subtotal-row (-> pnl-data + (filter-categories [:sales :cogs :payroll :controllable :fixed-overhead :ownership-controllable]) + (negate #{:cogs :payroll :controllable :fixed-overhead :ownership-controllable})) + (str (:prefix pnl-data) " Net Income"))) + (conj (subtotal-row (-> client-data + (filter-categories [:sales :cogs :payroll :controllable :fixed-overhead :ownership-controllable]) + (negate #{:cogs :payroll :controllable :fixed-overhead :ownership-controllable})) + "All Location Net Income"))) + percent-of-sales (calc-percent-of-sales table pnl-data) + deltas (into [] (calc-deltas table))] + {:header (headers pnl-data title) + :rows (combine-tables pnl-data table percent-of-sales deltas)})) + +(defn summarize-pnl [pnl-data] + {:summaries (for [[client-id location] (locations (:data pnl-data))] + (location-summary-table (-> pnl-data + (filter-client client-id) + (filter-location location)) + (str (-> pnl-data :clients-by-id (get client-id) :client/name) " (" location ") Summary"))) + :details (for [[client-id location] (locations (:data pnl-data))] + (location-detail-table (-> pnl-data + (filter-client client-id) + (filter-location location) + (assoc :prefix location)) + (-> pnl-data + (filter-client client-id)) + (str (-> pnl-data :clients-by-id (get client-id) :client/name) " (" location ") Detail")))}) + + + + +(defrecord PNLData [args data clients-by-id])