Files
integreat/src/clj/auto_ap/pdf/ledger.clj
2022-03-25 14:59:31 -07:00

531 lines
22 KiB
Clojure

(ns auto-ap.pdf.ledger
(:require
[amazonica.aws.s3 :as s3]
[auto-ap.datomic :refer [conn]]
[auto-ap.graphql.utils :refer [<-graphql]]
[auto-ap.time :as atime]
[auto-ap.utils :refer [by dollars-0?]]
[clj-pdf.core :as pdf]
[clojure.java.io :as io]
[clojure.string :as str]
[config.core :refer [env]]
[datomic.api :as d]
[clojure.tools.logging :as log])
(: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-> {}
(:colspan cell) (assoc :colspan (:colspan cell))
(:align cell) (assoc :align (:align cell))
(= :dollar (:format cell)) (assoc :align :right)
(= :percent (:format cell)) (assoc :align :right)
(:bold cell) (assoc-in [:ttf-name] "fonts/calibri-bold.ttf")
(:color cell) (assoc :color (:color cell)))
(cond
(and (= :dollar (:format cell))
(dollars-0? (:value cell)))
"-"
(= :dollar (:format cell))
(.format (DecimalFormat. "$###,##0.00") (:value cell))
(= :percent (:format cell))
(.format (DecimalFormat. "0%") (:value cell))
:else
(str (:value cell)))])
(defn cell-count [table]
(let [counts (map count (:rows table))]
(if (seq counts)
(apply max counts)
0)))
(defn table->pdf [table widths]
(let [cell-count (cell-count table)]
(-> [:pdf-table {:header (mapv
(fn [header]
(map cell->pdf header))
(:header table))
:cell-border false
:width-percent (cond (= 5 cell-count)
50
(= 6 cell-count)
60
(= 7 cell-count)
70
(= 8 cell-count)
80
:else
100)}
widths
]
(into
(for [row (:rows table)]
(into []
(for [cell (take cell-count (concat row (repeat nil)))]
(cell->pdf cell)
))))
(conj (take cell-count (repeat (cell->pdf {:value " "})))))))
(defn split-table [table n]
(let [cell-count (cell-count table)]
(if (<= cell-count n)
[table]
(let [new-table (-> table
(update :rows (fn [rows]
(map
(fn [[header & rest]]
(into [header]
(take (dec n) rest)))
rows)))
(update :header (fn [headers]
(map
(fn [[title & header]]
(first
(reduce
(fn [[so-far a] next]
(let [new-a (+ a (or (:colspan next)
1))]
(if (<= new-a n)
[(conj so-far next) new-a]
[so-far new-a])))
[[title] 1]
header)))
headers))))
remaining (-> table
(update :rows (fn [rows]
(map
(fn [[header & rest]]
(into [header]
(drop (dec n) rest)))
rows)))
(update :header (fn [headers]
(map
(fn [[title & header]]
(first
(reduce
(fn [[so-far a] next]
(let [new-a (+ a (or (:colspan next)
1))]
(if (> new-a n)
[(conj so-far next) new-a]
[so-far new-a])))
[[title] 1]
header)))
headers))))]
(into [new-table]
(split-table remaining n))))))
(defn break-apart-tables [pnl-data tables]
(for [table tables
table (split-table table (if (:include-deltas (:args pnl-data))
10
9))]
table))
(defn make-pnl [args data]
(let [data (<-graphql data)
args (<-graphql args)
clients (d/pull-many (d/db conn) '[:client/name :db/id] (:client-ids args))
data (->> data
:periods
(mapcat (fn [p1 p2]
(map
(fn [a]
(assoc a :period p1)
)
(:accounts p2))
)
(:periods args)))
pnl-data (PNLData. args data (by :db/id clients))
report (summarize-pnl pnl-data)
output-stream (ByteArrayOutputStream.)]
(pdf/pdf
(into
[{:left-margin 10 :right-margin 10 :top-margin 15 :bottom-margin 15
:size (cond
(and (>= (count (-> pnl-data :args :periods)) 8 )
(-> pnl-data :args :include-deltas))
:a2
(>= (count (-> pnl-data :args :periods)) 4 )
:tabloid
:else
:letter)
:orientation :landscape
:font {:size 6
:ttf-name "fonts/calibri-light.ttf"}}
[:heading (str "Profit and Loss - " (str/join ", " (map :client/name clients)))]]
(for [table (concat (:summaries report)
(:details report))]
(table->pdf table
(into [20] (take (dec (cell-count table))
(mapcat identity
(repeat
(if (-> pnl-data :args :include-deltas)
[13 6 13]
[13 6]))))))))
output-stream)
(.toByteArray output-stream)))
(defn args->name [args]
(let [min-date (atime/unparse-local
(->> args :periods (map :start) first)
atime/iso-date)
max-date (atime/unparse-local
(->> args :periods (map :end) last)
atime/iso-date)
names (str/replace (->> args :client_ids (d/pull-many (d/db conn) [:client/name]) (map :client/name) (str/join "-")) #" " "_" )]
(format "Profit-and-loss-%s-to-%s-for-%s" min-date max-date names)))
(defn print-pnl [args data]
(let [uuid (str (UUID/randomUUID))
pdf-data (make-pnl args data)
name (args->name args)
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
:input-stream (io/make-input-stream pdf-data {})
:metadata {:content-length (count pdf-data)
:content-type "application/pdf"})
@(d/transact conn
[{:report/name name
:report/client (:client_ids args)
:report/key key
:report/url url
:report/created (java.util.Date.)}])
url
))