profit and loss now works.
This commit is contained in:
@@ -94,7 +94,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} (: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} (:matched-route request))
|
||||
"ledger"
|
||||
:else
|
||||
nil)]
|
||||
@@ -297,8 +297,20 @@
|
||||
(when (is-admin? (:identity request))
|
||||
(menu-button- {:href (bidi/path-for client-routes/routes
|
||||
:ledger)} "Old Register"))
|
||||
(menu-button- {:href (bidi/path-for client-routes/routes
|
||||
:profit-and-loss)} "Profit & Loss")
|
||||
(if (is-admin? (:identity request))
|
||||
(menu-button- {:href (hu/url (bidi/path-for ssr-routes/only-routes
|
||||
::ledger-routes/profit-and-loss))
|
||||
:active? (= ::ledger-routes/profit-and-loss (:matched-route request))
|
||||
:hx-boost "true"}
|
||||
[:div.flex.gap-2
|
||||
"Profit and loss"
|
||||
(tags/pill- {:color :secondary} "WIP")])
|
||||
(menu-button- {:href (bidi/path-for client-routes/routes
|
||||
:profit-and-loss)}
|
||||
"Profit and Loss"))
|
||||
(when (is-admin? (:identity request))
|
||||
(menu-button- {:href (bidi/path-for client-routes/routes
|
||||
: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 client-routes/routes
|
||||
@@ -306,7 +318,7 @@
|
||||
(if (is-admin? (:identity request))
|
||||
(menu-button- {:href (hu/url (bidi/path-for ssr-routes/only-routes
|
||||
::ledger-routes/cash-flows))
|
||||
:active? (= ::ledger-routes/balance-sheet (:matched-route request))
|
||||
:active? (= ::ledger-routes/cash-flows (:matched-route request))
|
||||
:hx-boost "true"}
|
||||
[:div.flex.gap-2
|
||||
"Cash flows"
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
[auto-ap.ssr.ledger.common :refer [bank-account-filter default-read
|
||||
fetch-ids grid-page query-schema]]
|
||||
[auto-ap.ssr.ledger.investigate :as investigate]
|
||||
[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]
|
||||
[auto-ap.ssr.ui :refer [base-page]]
|
||||
@@ -729,5 +730,6 @@
|
||||
(wrap-must {:activity :import :subject :ledger})
|
||||
(wrap-client-redirect-unauthenticated))))
|
||||
balance-sheet/key->handler
|
||||
profit-and-loss/key->handler
|
||||
cash-flows/key->handler
|
||||
investigate/key->handler))
|
||||
@@ -176,7 +176,6 @@
|
||||
:x-model "v.end"}]))]]
|
||||
(com/a-button {"x-tooltip.on.click.theme.dropdown.placement.bottom.interactive" "{content: ()=> $refs.tooltip.innerHTML, allowHTML: true, appendTo: $root}"
|
||||
:indicator? false}
|
||||
|
||||
[:template {:x-if "periods.length == 0"}
|
||||
[:span.text-left.text-gray-400 "None selected"]]
|
||||
[:template { :x-if "periods.length < 3 && periods.length > 0"}
|
||||
@@ -228,9 +227,7 @@
|
||||
"Cash Flows"))
|
||||
|
||||
(defn make-cash-flows-pdf [request report]
|
||||
|
||||
(let [ output-stream (ByteArrayOutputStream.)
|
||||
|
||||
date (:periods (:form-params request))]
|
||||
(pdf/pdf
|
||||
(-> [{:left-margin 10 :right-margin 10 :top-margin 15 :bottom-margin 15
|
||||
|
||||
343
src/clj/auto_ap/ssr/ledger/profit_and_loss.clj
Normal file
343
src/clj/auto_ap/ssr/ledger/profit_and_loss.clj
Normal file
@@ -0,0 +1,343 @@
|
||||
(ns auto-ap.ssr.ledger.profit-and-loss
|
||||
(:require
|
||||
[amazonica.aws.s3 :as s3]
|
||||
[auto-ap.datomic
|
||||
:refer [conn pull-many]]
|
||||
[auto-ap.graphql.utils :refer [assert-can-see-client]]
|
||||
[auto-ap.ledger :refer [build-account-lookup]]
|
||||
[auto-ap.ledger.reports :as l-reports]
|
||||
[auto-ap.logging :as alog]
|
||||
[auto-ap.pdf.ledger :refer [table->pdf]]
|
||||
[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.hx :as hx]
|
||||
[auto-ap.ssr.ledger.report-table :refer [cell-count concat-tables table]]
|
||||
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.ui :refer [base-page]]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [apply-middleware-to-all-handlers clj-date-schema html-response
|
||||
modal-response wrap-form-4xx-2 wrap-schema-enforce]]
|
||||
[auto-ap.time :as atime]
|
||||
[bidi.bidi :as bidi]
|
||||
[clj-pdf.core :as pdf]
|
||||
[clj-time.coerce :as coerce]
|
||||
[clj-time.core :as time]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.string :as str]
|
||||
[config.core :refer [env] :as env]
|
||||
[datomic.api :as dc]
|
||||
[iol-ion.utils :refer [by]]
|
||||
[malli.core :as mc])
|
||||
(:import
|
||||
[java.util UUID]
|
||||
[org.apache.commons.io.output ByteArrayOutputStream]))
|
||||
|
||||
|
||||
(def query-schema (mc/schema
|
||||
[:maybe [:map
|
||||
[:client {:unspecified/value :all}
|
||||
[:or
|
||||
[:enum :all]
|
||||
[:vector {:coerce? true :min 1}
|
||||
[:entity-map {:pull [:db/id :client/name]}]]]]
|
||||
[:column-per-location {:default false}
|
||||
[:boolean {:decode/string {:enter #(if (= % "on") true
|
||||
|
||||
(boolean %))}}]]
|
||||
[:include-deltas {:default false}
|
||||
[:boolean {:decode/string {:enter #(if (= % "on") true
|
||||
|
||||
(boolean %))}}]]
|
||||
|
||||
[:periods {:unspecified/fn (fn [] (let [now (atime/local-now)]
|
||||
[{:start (atime/as-local-time (time/date-time (time/year now)
|
||||
1
|
||||
1))
|
||||
:end (atime/local-now)}]))}
|
||||
[:vector {:coerce? true}
|
||||
[:map
|
||||
[:start clj-date-schema]
|
||||
[:end clj-date-schema]]]]]]))
|
||||
;; TODO
|
||||
;; 1. Rerender form when running
|
||||
;; 2. Don't throw crazy errors when missing a field
|
||||
;; 3. General cleanup of the patterns in run-balance-sheet
|
||||
;; 4. Review ledger dialog
|
||||
|
||||
(defn get-report [{ {:keys [periods client] :as qp} :form-params :as request}]
|
||||
(when (and (seq periods) client)
|
||||
(let [client (if (= :all client) (take 5 (:clients request)) client)
|
||||
client-ids (map :db/id client)
|
||||
_ (doseq [client-id client-ids]
|
||||
(assert-can-see-client (:identity request) client-id))
|
||||
|
||||
lookup-account (->> client-ids
|
||||
(map (fn build-lookup [client-id]
|
||||
[client-id (build-account-lookup client-id)]))
|
||||
(into {}))
|
||||
data (into []
|
||||
(for [client-id client-ids
|
||||
p periods
|
||||
[client-id account-id location debits credits balance count] (iol-ion.query/detailed-account-snapshot (dc/db conn) client-id (coerce/to-date (:end p)))
|
||||
:let [account ((or (lookup-account client-id) {}) account-id)]]
|
||||
{:client-id client-id
|
||||
:account-id account-id
|
||||
:location location
|
||||
:debits debits
|
||||
:credits credits
|
||||
:count count
|
||||
:amount balance
|
||||
:account-type (:account_type account)
|
||||
:numeric-code (:numeric_code account)
|
||||
:name (:name account)
|
||||
:period {:start ( coerce/to-date (:start p)) :end ( coerce/to-date (:end p))}}))
|
||||
args (assoc (:form-params request)
|
||||
:periods (map (fn [d] {:start ( coerce/to-date (:start d)) :end ( coerce/to-date (:end d))}) periods))
|
||||
clients (pull-many (dc/db conn) [:client/code :client/name :db/id] client-ids)
|
||||
|
||||
pnl-data (l-reports/->PNLData args data (by :db/id :client/code clients))
|
||||
report (l-reports/summarize-pnl pnl-data)]
|
||||
(alog/info ::profit-and-loss :params args)
|
||||
{:data report
|
||||
:report report})))
|
||||
|
||||
(defn maybe-trim-clients [request client]
|
||||
(if (= :all client)
|
||||
(cond-> {:client (take 20 (:clients request))}
|
||||
(> (count (:clients request)) 20)
|
||||
(assoc :warning "You requested a report with more than 20 clients. This report will only contain the first 20."))
|
||||
{:client client}))
|
||||
|
||||
(defn profit-and-loss* [{ {:keys [periods client] } :form-params :as request}]
|
||||
[:div#report
|
||||
(when (and periods client)
|
||||
(let [{:keys [client warning]} (maybe-trim-clients request client)
|
||||
{:keys [data report]} (get-report (assoc-in request [:form-params :client] client))
|
||||
client-count (count (set (map :client-id (:data data))))
|
||||
table-contents (concat-tables (:details report))]
|
||||
(list
|
||||
[:div.text-2xl.font-bold.text-gray-600 (str "Cash flows - " (str/join ", " (map :client/name client)))]
|
||||
(table {:widths (into [20] (take (dec (cell-count table-contents))
|
||||
(mapcat identity
|
||||
(repeat
|
||||
(if (-> data :args :include-deltas)
|
||||
[13 6 13]
|
||||
[13 6])))))
|
||||
:investigate-url (bidi.bidi/path-for ssr-routes/only-routes ::route/investigate)
|
||||
:table table-contents
|
||||
:warning (not-empty (str/join "\n " (filter not-empty [warning (:warning report)])))}))))])
|
||||
|
||||
(defn form* [request & children]
|
||||
(let [params (or (:query-params request) {})]
|
||||
(fc/start-form
|
||||
params
|
||||
(:form-errors request)
|
||||
[:div#profit-and-loss-form.flex.flex-col.gap-4.mt-4
|
||||
[:div.flex.gap-8
|
||||
[:form {:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/run-profit-and-loss)
|
||||
:hx-target "#profit-and-loss-form"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-disabled-elt "find fieldset"}
|
||||
[:fieldset
|
||||
[:div.flex.gap-8 {:x-data (hx/json {})}
|
||||
(fc/with-field :client
|
||||
(com/validated-inline-field
|
||||
{:label "Customers" :errors (fc/field-errors)}
|
||||
(com/multi-typeahead {:name (fc/field-name)
|
||||
:placeholder "Search for companies..."
|
||||
:class "w-64"
|
||||
:id "client"
|
||||
:url (bidi/path-for ssr-routes/only-routes :company-search)
|
||||
:value (fc/field-value)
|
||||
:value-fn :db/id
|
||||
:content-fn :client/name})))
|
||||
(fc/with-field :periods
|
||||
(com/validated-inline-field {:label "Periods"
|
||||
:errors (fc/field-errors)}
|
||||
[:div {:x-data (hx/json {:periods (map (fn [p]
|
||||
{:start (atime/unparse-local (:start p) atime/normal-date)
|
||||
:end (atime/unparse-local (:end p) atime/normal-date)})
|
||||
(fc/field-value))
|
||||
:source_date (-> (fc/field-value)
|
||||
first
|
||||
:end
|
||||
(atime/unparse-local atime/normal-date))})
|
||||
|
||||
:x-init "$watch('periods', ds => source_date= ds.length > 0 ? ds[0].end : null)"}
|
||||
[:template {:x-for "(v,n) in periods"}
|
||||
[:div
|
||||
(fc/with-field 0
|
||||
(fc/with-field :start
|
||||
[:input {:type "hidden"
|
||||
":name" "'periods[' + n + '][start]'"
|
||||
:x-model "v.start"}]))
|
||||
(fc/with-field 0
|
||||
(fc/with-field :end
|
||||
[:input {:type "hidden"
|
||||
":name" "'periods[' + n + '][end]'"
|
||||
:x-model "v.end"}]))]]
|
||||
(com/a-button {"x-tooltip.on.click.theme.dropdown.placement.bottom.interactive" "{content: ()=> $refs.tooltip.innerHTML, allowHTML: true, appendTo: $root}"
|
||||
:indicator? false}
|
||||
[:template {:x-if "periods.length == 0"}
|
||||
[:span.text-left.text-gray-400 "None selected"]]
|
||||
[:template {:x-if "periods.length < 3 && periods.length > 0"}
|
||||
[:span.inline-flex.gap-2
|
||||
[:template {:x-for "p in periods"}
|
||||
(com/pill {:color :secondary}
|
||||
[:span {:x-text "p.start"}]
|
||||
" - "
|
||||
[:span {:x-text "p.end"}])]]]
|
||||
[:template {:x-if "periods.length >= 3"}
|
||||
(com/pill {:color :secondary}
|
||||
[:span {:x-text "periods.length"}]
|
||||
" periods selected")]
|
||||
[:div {:class "w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center"}
|
||||
svg/drop-down])
|
||||
[:template {:x-ref "tooltip"}
|
||||
[:div.p-4 {:class "bg-gray-100 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 ring-1 p-4"}
|
||||
[:div.flex.flex-col.gap-2
|
||||
(com/calendar-input {:placeholder "12/21/2020" :x-model "source_date"})
|
||||
(com/a-button {"@click" "periods=getFourWeekPeriodsPeriods(source_date)"} "13 periods")
|
||||
(com/a-button {"@click" "periods=[calendarYearPeriod(source_date)]"} "Calendar year")
|
||||
(com/a-button {"@click" "periods=[lastYearPeriod(source_date)]"} "Full year")
|
||||
(com/a-button {"@click" "periods=[]"} "Clear")]]]]))
|
||||
(fc/with-field :column-per-location
|
||||
(com/toggle {:name (fc/field-name)
|
||||
:checked (fc/field-value)}
|
||||
"Column per location"))
|
||||
(fc/with-field :include-deltas
|
||||
(com/toggle {:name (fc/field-name)
|
||||
:checked (fc/field-value)}
|
||||
"Include deltas"))
|
||||
|
||||
(com/button {:color :primary :class "w-32"}
|
||||
"Run")
|
||||
(com/button {:formaction (bidi.bidi/path-for ssr-routes/only-routes ::route/export-profit-and-loss)} "Export PDF")]]]]
|
||||
children])))
|
||||
|
||||
(defn form [request]
|
||||
(html-response (form* (assoc request :query-params (:form-params request))
|
||||
(profit-and-loss* request))
|
||||
:headers {"hx-retarget" "#profit-and-loss-form"
|
||||
"hx-push-url" (str "?" (:query-string request))}))
|
||||
|
||||
(defn profit-and-loss [request]
|
||||
(base-page
|
||||
request
|
||||
(com/page {:nav com/main-aside-nav
|
||||
|
||||
:client-selection (:client-selection request)
|
||||
:clients (:clients request)
|
||||
:client (:client request)
|
||||
:identity (:identity request)
|
||||
:request request}
|
||||
(apply com/breadcrumbs {} [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
|
||||
"Ledger"]])
|
||||
(form* request))
|
||||
"Cash Flows"))
|
||||
|
||||
(defn make-profit-and-loss-pdf [request report]
|
||||
(let [ output-stream (ByteArrayOutputStream.)
|
||||
date (:periods (:form-params request))
|
||||
table (concat-tables (:details report))]
|
||||
(pdf/pdf
|
||||
(-> [{:left-margin 10 :right-margin 10 :top-margin 15 :bottom-margin 15
|
||||
:size :letter
|
||||
:font {:size 6
|
||||
:ttf-name "fonts/calibri-light.ttf"}}
|
||||
[:heading (str "Balance Sheet - " (str/join ", " (map :client/name (seq (:client (:form-params request))))))]]
|
||||
|
||||
(conj [:paragraph {:color [128 0 0] :size 9} (:warning report)])
|
||||
(conj
|
||||
(table->pdf table
|
||||
(into [20] (take (dec (cell-count table))
|
||||
(mapcat identity
|
||||
(repeat
|
||||
(if (-> (:form-params request) :include-deltas)
|
||||
[13 6 13]
|
||||
[13 6]))))))))
|
||||
output-stream)
|
||||
(.toByteArray output-stream)))
|
||||
|
||||
(defn join-names [client-ids]
|
||||
(str/replace (->> client-ids (pull-many (dc/db conn) [:client/name]) (map :client/name) (str/join "-")) #"[^\w]" "_"))
|
||||
|
||||
(defn profit-and-loss-args->name [request]
|
||||
(let [date (atime/unparse-local
|
||||
(:date (:query-params request))
|
||||
atime/iso-date)
|
||||
name (->> request :query-params :client (map :db/id) join-names)]
|
||||
(format "Balance-sheet-%s-for-%s" date name)))
|
||||
|
||||
(defn print-profit-and-loss [request]
|
||||
(let [uuid (str (UUID/randomUUID))
|
||||
{:keys [client warning]} (maybe-trim-clients request (:client (:form-params request)))
|
||||
request (assoc-in request [:form-params :client] client)
|
||||
pdf-data (make-profit-and-loss-pdf request (:report (get-report request)))
|
||||
name (profit-and-loss-args->name request)
|
||||
key (str "reports/profit-and-loss/" uuid "/" name ".pdf")
|
||||
url (str "https://" (:data-bucket env) "/" key)]
|
||||
(s3/put-object :bucket-name (:data-bucket env/env)
|
||||
:key key
|
||||
:input-stream (io/make-input-stream pdf-data {})
|
||||
:metadata {:content-length (count pdf-data)
|
||||
:content-type "application/pdf"})
|
||||
@(dc/transact conn
|
||||
[{:report/name name
|
||||
:report/client (map :db/id client)
|
||||
:report/key key
|
||||
:report/url url
|
||||
:report/creator (:user (:identity request))
|
||||
:report/created (java.util.Date.)}])
|
||||
{:report/name name
|
||||
:report/url url}))
|
||||
|
||||
;; TODO PRINT WARNING
|
||||
(defn export [request]
|
||||
(modal-response
|
||||
(com/modal {}
|
||||
(com/modal-card
|
||||
{}
|
||||
"Ready!"
|
||||
(com/modal-body {}
|
||||
(let [bs (print-profit-and-loss request)]
|
||||
[:div.flex.flex-col.mt-4.space-y-4.items-center
|
||||
[:a {:href (:report/url bs)}
|
||||
[:div.w-24.h-24.bg-green-50.rounded-full.p-4.text-green-300 {:class " hover:scale-110 transition duration-100"}
|
||||
|
||||
svg/download]]
|
||||
[:span.text-gray-800
|
||||
"Click "
|
||||
(com/link {:href (:report/url bs)} "here")
|
||||
" to download"]]))
|
||||
|
||||
nil))
|
||||
:headers (-> {}
|
||||
(assoc "hx-retarget" ".modal-stack")
|
||||
(assoc "hx-reswap" "beforeend"))))
|
||||
|
||||
(def key->handler
|
||||
(apply-middleware-to-all-handlers
|
||||
(->
|
||||
{::route/profit-and-loss (-> profit-and-loss
|
||||
(wrap-schema-enforce :query-schema query-schema)
|
||||
(wrap-form-4xx-2 profit-and-loss))
|
||||
::route/run-profit-and-loss (-> form
|
||||
(wrap-schema-enforce :form-schema query-schema)
|
||||
(wrap-form-4xx-2 form))
|
||||
::route/export-profit-and-loss (-> export
|
||||
(wrap-schema-enforce :form-schema query-schema)
|
||||
(wrap-form-4xx-2 form))})
|
||||
|
||||
(fn [h]
|
||||
(-> h
|
||||
#_(wrap-merge-prior-hx)
|
||||
(wrap-must {:activity :read :subject :profit-and-loss})
|
||||
(wrap-nested-form-params)
|
||||
(wrap-client-redirect-unauthenticated)))))
|
||||
@@ -14,4 +14,7 @@
|
||||
"/export" ::export-balance-sheet}
|
||||
"/reports/cash-flows" {"" ::cash-flows
|
||||
"/run" ::run-cash-flows
|
||||
"/export" ::export-cash-flows}})
|
||||
"/export" ::export-cash-flows}
|
||||
"/reports/profit-and-loss" {"" ::profit-and-loss
|
||||
"/run" ::run-profit-and-loss
|
||||
"/export" ::export-profit-and-loss}})
|
||||
Reference in New Issue
Block a user