840 lines
45 KiB
Clojure
840 lines
45 KiB
Clojure
(ns auto-ap.ssr.invoice.import
|
|
(:require [amazonica.aws.s3 :as s3]
|
|
[auto-ap.datomic
|
|
:refer [add-sorter-fields apply-pagination apply-sort-3
|
|
audit-transact conn merge-query observable-query
|
|
pull-attr pull-many random-tempid]]
|
|
[auto-ap.datomic.clients :as d-clients]
|
|
[auto-ap.datomic.invoices :as d-invoices]
|
|
[auto-ap.datomic.vendors :as d-vendors]
|
|
[auto-ap.graphql.utils :refer [assert-can-see-client
|
|
assert-not-locked
|
|
exception->notification
|
|
extract-client-ids]]
|
|
[auto-ap.logging :as alog]
|
|
[auto-ap.parse :as parse]
|
|
[auto-ap.permissions :refer [can? wrap-must]]
|
|
[auto-ap.routes.invoice :as route]
|
|
[auto-ap.ssr-routes :as ssr-routes]
|
|
[auto-ap.ssr.components :as com]
|
|
[auto-ap.ssr.form-cursor :as fc]
|
|
[auto-ap.ssr.grid-page-helper :as helper]
|
|
[auto-ap.ssr.hx :as hx]
|
|
[auto-ap.ssr.invoice.common :refer [default-read]]
|
|
[auto-ap.ssr.pos.common :refer [date-range-field*]]
|
|
[auto-ap.ssr.svg :as svg]
|
|
[auto-ap.ssr.utils
|
|
:refer [apply-middleware-to-all-handlers clj-date-schema
|
|
entity-id html-response main-transformer ref->enum-schema
|
|
strip wrap-entity wrap-implied-route-param
|
|
wrap-schema-enforce]]
|
|
[auto-ap.time :as atime]
|
|
[auto-ap.utils :refer [dollars=]]
|
|
[bidi.bidi :as bidi]
|
|
[clj-time.coerce :as coerce :refer [to-date]]
|
|
[clojure.java.io :as io]
|
|
[clojure.string :as str]
|
|
[com.brunobonacci.mulog :as mu]
|
|
[config.core :refer [env]]
|
|
[datomic.api :as dc]
|
|
[hiccup2.core :as hiccup]
|
|
[malli.core :as mc]
|
|
[auto-ap.client-routes :as client-routes])
|
|
(:import [java.util UUID]))
|
|
|
|
|
|
(defn exact-match-id* [request]
|
|
(if (nat-int? (:exact-match-id (:query-params request)))
|
|
[:div {:x-data (hx/json {:exact_match (:exact-match-id (:query-params request))}) :id "exact-match-id-tag"}
|
|
(com/hidden {:name "exact-match-id"
|
|
"x-model" "exact_match"})
|
|
(com/pill {:color :primary}
|
|
[:span.inline-flex.space-x-2.items-center
|
|
[:div "exact match"]
|
|
[:div.w-3.h-3
|
|
(com/link {"@click" "exact_match=null; $nextTick(() => $dispatch('change'))"}
|
|
svg/x)]])]
|
|
[:div {:id "exact-match-id-tag"}]))
|
|
|
|
(defn filters [request]
|
|
[:form#invoice-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
|
"hx-get" (bidi/path-for ssr-routes/only-routes
|
|
::route/import-table)
|
|
"hx-target" "#entity-table"
|
|
"hx-indicator" "#entity-table"}
|
|
|
|
(com/hidden {:name "status"
|
|
:value (some-> (:status (:query-params request)) name)})
|
|
[:fieldset.space-y-6
|
|
(com/field {:label "Vendor"}
|
|
(com/typeahead {:name "vendor"
|
|
:id "vendor"
|
|
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
|
|
:value (:vendor (:query-params request))
|
|
:value-fn :db/id
|
|
:content-fn :vendor/name}))
|
|
(date-range-field* request)
|
|
(com/field {:label "Check #"}
|
|
(com/text-input {:name "check-number"
|
|
:id "check-number"
|
|
:class "hot-filter"
|
|
:value (:check-number (:query-params request))
|
|
:placeholder "e.g., 10001"
|
|
:size :small}))
|
|
(com/field {:label "Invoice #"}
|
|
(com/text-input {:name "invoice-number"
|
|
:id "invoice-number"
|
|
:class "hot-filter"
|
|
:value (:invoice-number (:query-params request))
|
|
:placeholder "e.g., ABC-456"
|
|
:size :small}))
|
|
|
|
(com/field {:label "Amount"}
|
|
[:div.flex.space-x-4.items-baseline
|
|
(com/money-input {:name "amount-gte"
|
|
:id "amount-gte"
|
|
:hx-preserve "true"
|
|
:class "hot-filter w-20"
|
|
:value (:amount-gte (:query-params request))
|
|
:placeholder "0.01"
|
|
:size :small})
|
|
[:div.align-baseline
|
|
"to"]
|
|
(com/money-input {:name "amount-lte"
|
|
:hx-preserve "true"
|
|
:id "amount-lte"
|
|
:class "hot-filter w-20"
|
|
:value (:amount-lte (:query-params request))
|
|
:placeholder "9999.34"
|
|
:size :small})])
|
|
(exact-match-id* request)]])
|
|
|
|
|
|
(defn fetch-ids [db {:keys [query-params route-params] :as request}]
|
|
(let [valid-clients (extract-client-ids (:clients request)
|
|
(:client-id request)
|
|
(when (:client-code request)
|
|
[:client/code (:client-code request)]))
|
|
query
|
|
(if (:exact-match-id query-params)
|
|
{:query {:find '[?e]
|
|
:in '[$ ?e [?c ...]]
|
|
:where '[[?e :invoice/client ?c]]}
|
|
:args [db
|
|
(:exact-match-id query-params)
|
|
valid-clients]}
|
|
(cond-> {:query {:find []
|
|
:in '[$ [?clients ?start ?end]]
|
|
:where '[[(iol-ion.query/scan-invoices $ ?clients ?start ?end) [[?e _ ?sort-default] ...]]
|
|
[?e :invoice/import-status :import-status/pending]]}
|
|
:args [db
|
|
[valid-clients
|
|
(some-> (:start-date query-params) coerce/to-date)
|
|
(some-> (:end-date query-params) coerce/to-date)]]}
|
|
|
|
|
|
|
|
(:client-id query-params)
|
|
(merge-query {:query {:in ['?client-id]
|
|
:where ['[?e :invoice/client ?client-id]]}
|
|
:args [(:client-id query-params)]})
|
|
|
|
(:client-code query-params)
|
|
(merge-query {:query {:in ['?client-code]
|
|
:where ['[?e :invoice/client ?client-id]
|
|
'[?client-id :client/code ?client-code]]}
|
|
:args [(:client-code query-params)]})
|
|
|
|
|
|
(:start (:due-range query-params)) (merge-query {:query {:in '[?start-due]
|
|
:where ['[?e :invoice/due ?due]
|
|
'[(>= ?due ?start-due)]]}
|
|
:args [(coerce/to-date (:start (:due-range query-params)))]})
|
|
|
|
(:end (:due-range query-params)) (merge-query {:query {:in '[?end-due]
|
|
:where ['[?e :invoice/due ?due]
|
|
'[(<= ?due ?end-due)]]}
|
|
:args [(coerce/to-date (:end (:due-range query-params)))]})
|
|
|
|
|
|
(:import-status query-params)
|
|
(merge-query {:query {:in ['?import-status]
|
|
:where ['[?e :invoice/import-status ?import-status]]}
|
|
:args [(:import-status query-params)]})
|
|
(:status route-params)
|
|
(merge-query {:query {:in ['?status]
|
|
:where ['[?e :invoice/status ?status]]}
|
|
:args [(:status route-params)]})
|
|
(:vendor query-params)
|
|
(merge-query {:query {:in ['?vendor-id]
|
|
:where ['[?e :invoice/vendor ?vendor-id]]}
|
|
:args [(:db/id (:vendor query-params))]})
|
|
|
|
(:account-id query-params)
|
|
(merge-query {:query {:in ['?account-id]
|
|
:where ['[?e :invoice/expense-accounts ?iea ?]
|
|
'[?iea :invoice-expense-account/account ?account-id]]}
|
|
:args [(:account-id query-params)]})
|
|
|
|
(:amount-gte query-params)
|
|
(merge-query {:query {:in ['?amount-gte]
|
|
:where ['[?e :invoice/total ?total-filter]
|
|
'[(>= ?total-filter ?amount-gte)]]}
|
|
:args [(:amount-gte query-params)]})
|
|
|
|
(:amount-lte query-params)
|
|
(merge-query {:query {:in ['?amount-lte]
|
|
:where ['[?e :invoice/total ?total-filter]
|
|
'[(<= ?total-filter ?amount-lte)]]}
|
|
:args [(:amount-lte query-params)]})
|
|
|
|
(not-empty (:invoice-number query-params))
|
|
(merge-query {:query {:in ['?invoice-number-like]
|
|
:where ['[?e :invoice/invoice-number ?invoice-number]
|
|
'[(.contains ^String ?invoice-number ?invoice-number-like)]]}
|
|
:args [(:invoice-number query-params)]})
|
|
|
|
(:scheduled-payments query-params)
|
|
(merge-query {:query {:in []
|
|
:where ['[?e :invoice/scheduled-payment]]}
|
|
:args []})
|
|
|
|
(:unresolved query-params)
|
|
(merge-query {:query {:in []
|
|
:where ['(or-join [?e]
|
|
(not [?e :invoice/expense-accounts])
|
|
(and [?e :invoice/expense-accounts ?ea]
|
|
(not [?ea :invoice-expense-account/account])))]}
|
|
:args []})
|
|
|
|
(seq (:location query-params))
|
|
(merge-query {:query {:in ['?location]
|
|
:where ['[?e :invoice/expense-accounts ?eas]
|
|
'[?eas :invoice-expense-account/location ?location]]}
|
|
:args [(:location query-params)]})
|
|
|
|
(:sort query-params) (add-sorter-fields {"client" ['[?e :invoice/client ?c]
|
|
'[?c :client/name ?sort-client]]
|
|
"vendor" ['[?e :invoice/vendor ?v]
|
|
'[?v :vendor/name ?sort-vendor]]
|
|
"description-original" ['[?e :transaction/description-original ?sort-description-original]]
|
|
"location" ['[?e :invoice/expense-accounts ?iea]
|
|
'[?iea :invoice-expense-account/location ?sort-location]]
|
|
"date" ['[?e :invoice/date ?sort-date]]
|
|
"due" ['[(get-else $ ?e :invoice/due #inst "2050-01-01") ?sort-due]]
|
|
"invoice-number" ['[?e :invoice/invoice-number ?sort-invoice-number]]
|
|
"total" ['[?e :invoice/total ?sort-total]]
|
|
"outstanding-balance" ['[?e :invoice/outstanding-balance ?sort-outstanding-balance]]}
|
|
query-params)
|
|
true
|
|
(merge-query {:query {:find ['?sort-default '?e]}})))]
|
|
|
|
(->> (observable-query query)
|
|
(apply-sort-3 (assoc query-params :default-asc? false))
|
|
(apply-pagination query-params))))
|
|
|
|
|
|
(defn hydrate-results [ids db _]
|
|
(let [results (->> (pull-many db default-read ids)
|
|
(group-by :db/id))
|
|
refunds (->> ids
|
|
(map results)
|
|
(map first))]
|
|
refunds))
|
|
|
|
(defn sum-outstanding [ids]
|
|
|
|
(->>
|
|
(dc/q {:find ['?id '?o]
|
|
:in ['$ '[?id ...]]
|
|
:where ['[?id :invoice/outstanding-balance ?o]]}
|
|
(dc/db conn)
|
|
ids)
|
|
(map last)
|
|
(reduce
|
|
+
|
|
0.0)))
|
|
|
|
(defn sum-total-amount [ids]
|
|
|
|
(->>
|
|
(dc/q {:find ['?id '?o]
|
|
:in ['$ '[?id ...]]
|
|
:where ['[?id :invoice/total ?o]]}
|
|
(dc/db conn)
|
|
ids)
|
|
(map last)
|
|
(reduce
|
|
+
|
|
0.0)))
|
|
|
|
(defn fetch-page [request]
|
|
(let [db (dc/db conn)
|
|
{ids-to-retrieve :ids matching-count :count
|
|
all-ids :all-ids} (fetch-ids db request)]
|
|
|
|
[(->> (hydrate-results ids-to-retrieve db request))
|
|
matching-count
|
|
(sum-outstanding all-ids)
|
|
(sum-total-amount all-ids)]))
|
|
|
|
(def query-schema (mc/schema
|
|
[:maybe [:map {:date-range [:date-range :start-date :end-date]}
|
|
[:sort {:optional true} [:maybe [:any]]]
|
|
[:per-page {:optional true :default 25} [:maybe :int]]
|
|
[:start {:optional true :default 0} [:maybe :int]]
|
|
[:amount-gte {:optional true} [:maybe :double]]
|
|
[:amount-lte {:optional true} [:maybe :double]]
|
|
[:vendor {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :vendor/name]}]]]
|
|
[:check-number {:optional true} [:maybe [:string {:decode/string strip}]]]
|
|
[:invoice-number {:optional true} [:maybe [:string {:decode/string strip}]]]
|
|
[:status {:optional true} [:maybe (ref->enum-schema "invoice-status")]]
|
|
[:exact-match-id {:optional true} [:maybe entity-id]]
|
|
[:all-selected {:optional true :default nil} [:maybe :boolean]]
|
|
[:selected {:optional true :default nil} [:maybe [:vector {:coerce? true}
|
|
entity-id]]]
|
|
[:start-date {:optional true}
|
|
[:maybe clj-date-schema]]
|
|
[:end-date {:optional true}
|
|
[:maybe clj-date-schema]]]]))
|
|
|
|
(defn selected->ids [request params]
|
|
(let [all-selected (:all-selected params)
|
|
selected (:selected params)
|
|
ids (cond
|
|
all-selected
|
|
(:ids (fetch-ids (dc/db conn) (-> request
|
|
(assoc :query-params params)
|
|
(assoc-in [:query-params :start] 0)
|
|
(assoc-in [:query-params :per-page] 250))))
|
|
|
|
|
|
:else
|
|
selected)
|
|
ids (->> (dc/q '[:find ?i
|
|
:in $ [?i ...]
|
|
:where [?i :invoice/import-status :import-status/pending]]
|
|
(dc/db conn)
|
|
ids)
|
|
(map first))]
|
|
ids))
|
|
|
|
(def upload-schema
|
|
[:map
|
|
[:force-client {:optional true}
|
|
[:maybe entity-id]]
|
|
[:force-vendor {:optional true}
|
|
[:maybe entity-id]]
|
|
[:force-chatgpt {:optional true :default false}
|
|
[:maybe [ :boolean {:decode/string {:enter #(if (= % "on") true
|
|
|
|
(boolean %))}}]]]
|
|
[:force-location {:optional true}
|
|
[:maybe [:string {:decode/string strip :min 2 :max 2}]]]])
|
|
|
|
(defn upload-form [{:keys [form-params form-errors] :as request}]
|
|
(com/content-card {}
|
|
|
|
[:div.px-4.py-3.space-y-4
|
|
|
|
[:div.flex.justify-between.items-center [:h1.text-2xl.mb-3.font-bold "Import new invoices"]
|
|
|
|
#_[:div.flex.gap-2.items-baseline "Trouble with the new upload experience?"
|
|
[:a {:href (bidi/path-for client-routes/routes :import-invoices)}
|
|
(com/pill {:color :secondary}
|
|
"Go back to previous version")]]]
|
|
[:div#page-notification.notification.block {:style {:display "none"}}]
|
|
|
|
|
|
[:form
|
|
{:hx-post (bidi/path-for ssr-routes/only-routes
|
|
::route/import-file)
|
|
:hx-encoding "multipart/form-data"
|
|
:hx-target "#page-notification"
|
|
:hx-swap "outerHTML"
|
|
:id "upload"}
|
|
(fc/start-form
|
|
form-params form-errors
|
|
[:div.flex.gap-4.items-center
|
|
|
|
(fc/with-field :force-client
|
|
(com/validated-field {:label "Force client"
|
|
:errors (fc/field-errors)}
|
|
[:div.w-96
|
|
(com/typeahead {:name (fc/field-name)
|
|
:error? (fc/error?)
|
|
:class "w-96"
|
|
:placeholder "Search..."
|
|
:url (bidi/path-for ssr-routes/only-routes :company-search)
|
|
:value (fc/field-value)
|
|
:content-fn (fn [c] (pull-attr (dc/db conn) :client/name c))})]))
|
|
(fc/with-field :force-location
|
|
(com/validated-field {:label "Force location"
|
|
:errors (fc/field-errors)}
|
|
|
|
(com/text-input {:name (fc/field-name)
|
|
:value (fc/field-value)
|
|
:size 2})))
|
|
(fc/with-field :force-vendor
|
|
(com/validated-field {:label "Force vendor"
|
|
:errors (fc/field-errors)}
|
|
[:div.w-96
|
|
(com/typeahead {:name (fc/field-name)
|
|
:error? (fc/error?)
|
|
:class "w-96"
|
|
:placeholder "Search..."
|
|
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
|
|
:value (fc/field-value)
|
|
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})]))
|
|
(fc/with-field :force-chatgpt
|
|
(com/validated-field { :errors (fc/field-errors)
|
|
:label " "}
|
|
(com/checkbox {:name (fc/field-name)
|
|
:error? (fc/error?) }
|
|
"Only use ChatGPT")))])
|
|
|
|
[:div.border-2.border-dashed.rounded-lg.p-4.w-full.text-center.cursor-pointer.h-64.flex.items-center.justify-center.text-lg.relative
|
|
{ :x-data (hx/json {"files" nil
|
|
"hovering" false})
|
|
":class" "{'bg-blue-100': !hovering,
|
|
'border-blue-300': !hovering,
|
|
'text-blue-700': !hovering,
|
|
'bg-green-100': hovering,
|
|
'border-green-300': hovering,
|
|
'text-green-700': hovering
|
|
}"
|
|
:x-ref "box"}
|
|
|
|
|
|
|
|
[:input {:type "file"
|
|
:name "file"
|
|
:multiple "multiple"
|
|
:class "absolute inset-0 m-0 p-0 w-full h-full outline-none opacity-0",
|
|
:x-on:change "files = $event.target.files; console.log($event.target.files);",
|
|
:x-on:dragover "hovering = true",
|
|
:x-on:dragleave "hovering = false",
|
|
:x-on:drop "hovering = false"}]
|
|
[:div.flex.flex-col.space-2
|
|
[:div
|
|
[:ul {:x-show "files != null"}
|
|
[:template {:x-for "f in files" }
|
|
[:li (com/pill {:color :primary :x-text "f.name"}) ]
|
|
]]]
|
|
|
|
[:div.htmx-indicator-hidden "Drop files to upload here"]]]
|
|
(com/button {:color :primary :class "w-32 mt-3"} "Upload")]]))
|
|
|
|
(def grid-page
|
|
(helper/build {:id "entity-table"
|
|
:nav com/main-aside-nav
|
|
:check-boxes? true
|
|
:page-specific-nav filters
|
|
:above-grid upload-form
|
|
|
|
:fetch-page fetch-page
|
|
:oob-render
|
|
(fn [request]
|
|
[(assoc-in (date-range-field* request) [1 :hx-swap-oob] true)
|
|
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)])
|
|
:query-schema query-schema
|
|
:action-buttons (fn [request]
|
|
(let [[_ _ outstanding total] (:page-results request)]
|
|
[
|
|
(when (can? (:identity request) {:subject :invoice :activity :import})
|
|
(com/button {:hx-put (str (bidi/path-for ssr-routes/only-routes ::route/bulk-approve))
|
|
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
|
"hx-include" "#invoice-filters"
|
|
:color :primary
|
|
":disabled" "$data.all_selected || ($data.selected && $data.selected.length > 0) ? false: true "
|
|
}
|
|
"Approve selected"))
|
|
(when (can? (:identity request) {:subject :invoice :activity :import})
|
|
(com/button {:hx-delete (str (bidi/path-for ssr-routes/only-routes ::route/bulk-disapprove))
|
|
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
|
"x-init" ""
|
|
"hx-include" "#invoice-filters"
|
|
":disabled" "$data.all_selected || ($data.selected && $data.selected.length > 0) ? false: true "
|
|
:color :red}
|
|
"Disapprove selected"))]))
|
|
:row-buttons (fn [request entity]
|
|
[(when (and (= :import-status/pending (:invoice/import-status entity))
|
|
(can? (:identity request) {:subject :invoice :activity :import}))
|
|
(com/icon-button {:hx-delete (bidi/path-for ssr-routes/only-routes
|
|
::route/disapprove
|
|
:db/id (:db/id entity))
|
|
:hx-confirm "Are you sure you want to remove this invoice?"}
|
|
svg/thumbs-down))
|
|
(when (and (= :import-status/pending (:invoice/import-status entity))
|
|
(can? (:identity request) {:subject :invoice :activity :import}))
|
|
(com/icon-button {:hx-put (bidi/path-for ssr-routes/only-routes
|
|
::route/approve
|
|
:db/id (:db/id entity))}
|
|
svg/thumbs-up))])
|
|
|
|
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
|
|
"Invoices"]]
|
|
:title (fn [r]
|
|
(str
|
|
(some-> r :route-params :status name str/capitalize (str " "))
|
|
"Invoices"))
|
|
:entity-name "invoices"
|
|
:route ::route/import-table
|
|
:headers [{:key "client"
|
|
:name "Client"
|
|
:sort-key "client"
|
|
:hide? (fn [args]
|
|
(and (= (count (:clients args)) 1)
|
|
(= 1 (count (:client/locations (:client args))))))
|
|
:render (fn [x] [:div.flex.items-center.gap-2 (-> x :invoice/client :client/name)
|
|
(com/pill {:color :primary} (-> x :invoice/location))])}
|
|
{:key "uploader"
|
|
:name "Uploaded by"
|
|
:sort-key "uploader"
|
|
:render #(-> % :invoice/uploader :user/name)}
|
|
{:key "vendor"
|
|
:name "Vendor"
|
|
:sort-key "vendor"
|
|
:render #(-> % :invoice/vendor :vendor/name)}
|
|
{:key "invoice-number"
|
|
:name "Invoice number"
|
|
:sort-key "invoice-number"
|
|
:render :invoice/invoice-number}
|
|
{:key "date"
|
|
:sort-key "date"
|
|
:name "Date"
|
|
:show-starting "lg"
|
|
:render (fn [{:invoice/keys [date]}]
|
|
(some-> date (atime/unparse-local atime/normal-date)))}
|
|
{:key "outstanding"
|
|
:name "Outstanding"
|
|
:sort-key "outstanding-balance"
|
|
:class "text-right"
|
|
:render (fn [{:invoice/keys [outstanding-balance total]}]
|
|
[:div
|
|
(some->> outstanding-balance (format "$%,.2f"))
|
|
(when-not (dollars= outstanding-balance total)
|
|
[:div.text-xs.text-gray-400 (format "of $%,.2f" total)])])}]}))
|
|
|
|
(def row* (partial helper/row* grid-page))
|
|
|
|
(defn disapprove [{invoice :entity :as request identity :identity}]
|
|
(when-not (= :import-status/pending (:invoice/import-status invoice))
|
|
(throw (ex-info (str "Cannot disapprove an invoice if it is not pending." (:invoice/import-status invoice))
|
|
{:type :notification})))
|
|
(exception->notification
|
|
#(assert-can-see-client identity (:db/id (:invoice/client invoice))))
|
|
|
|
(audit-transact [[:db/retractEntity (:db/id invoice)]] identity)
|
|
|
|
(html-response (row* (:identity request) invoice
|
|
{:class "live-removed"})
|
|
:headers {"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id invoice))}))
|
|
|
|
(defn approve [{invoice :entity :as request identity :identity}]
|
|
(when-not (= :import-status/pending (:invoice/import-status invoice))
|
|
(throw (ex-info (str "Cannot approve an invoice if it is not pending." (:invoice/import-status invoice))
|
|
{:type :notification})))
|
|
(exception->notification
|
|
#(do (assert-can-see-client identity (:db/id (:invoice/client invoice)))
|
|
(assert-not-locked (-> invoice :invoice/client :db/id) (-> invoice :invoice/date))))
|
|
|
|
(audit-transact [ [:upsert-invoice {:db/id (:db/id invoice) :invoice/import-status :import-status/imported}]] identity)
|
|
|
|
(html-response (row* (:identity request) invoice
|
|
{:class "live-added"})
|
|
:headers {"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id invoice))}))
|
|
|
|
(defn bulk-disapprove [request]
|
|
(let [ids (selected->ids request (:form-params request))
|
|
updates (map
|
|
(fn [i] [:db/retractEntity i])
|
|
ids) ]
|
|
(audit-transact updates (:identity request) )
|
|
|
|
(html-response [:div]
|
|
:headers {"hx-trigger" (hx/json { :notification (format "Successfully disapproved %d invoices."
|
|
(count ids))
|
|
:invalidated "invalidated"})})))
|
|
|
|
(defn bulk-approve [request]
|
|
(let [ids (selected->ids request (:form-params request))]
|
|
(exception->notification
|
|
#(doseq [i ids
|
|
:let [invoice (dc/pull (dc/db conn) '[{:invoice/client [:db/id]}
|
|
:invoice/date] i)]]
|
|
(assert-can-see-client (:identity request) (-> invoice :invoice/client :db/id))
|
|
(assert-not-locked (-> invoice :invoice/client :db/id) (-> invoice :invoice/date))))
|
|
(let [transactions (map (fn [i] [:upsert-invoice {:db/id i :invoice/import-status :import-status/imported}]) ids)]
|
|
(audit-transact transactions (:identity request)))
|
|
(html-response [:div]
|
|
:headers {"hx-trigger" (hx/json { :notification (format "Successfully approved %d invoices."
|
|
(count ids))
|
|
:invalidated "invalidated"})})))
|
|
|
|
#_(defn upload-invoices [{{files :file
|
|
files-2 "file"
|
|
client :client
|
|
client-2 "client"
|
|
location :location
|
|
location-2 "location"
|
|
vendor :vendor
|
|
vendor-2 "vendor"} :params
|
|
user :identity}]
|
|
(let [files (or files files-2)
|
|
client (or client client-2)
|
|
location (or location location-2)
|
|
vendor (some-> (or vendor vendor-2)
|
|
(Long/parseLong))
|
|
]
|
|
))
|
|
|
|
(defn match-vendor [vendor-code forced-vendor vendor-search]
|
|
(when (and (not forced-vendor) (str/blank? vendor-code))
|
|
(if vendor-search
|
|
(throw (ex-info (format "No vendor found. Searched for '%s'. Please supply an forced vendor."
|
|
vendor-search)
|
|
{:vendor-code vendor-code}))
|
|
(throw (ex-info (str "No vendor found. Please supply an forced vendor.")
|
|
{:vendor-code vendor-code}))))
|
|
(let [vendor-id (or forced-vendor
|
|
(->> (dc/q
|
|
{:find ['?vendor]
|
|
:in ['$ '?vendor-name]
|
|
:where ['[?vendor :vendor/name ?vendor-name]]}
|
|
(dc/db conn) vendor-code)
|
|
first
|
|
first))]
|
|
(when-not vendor-id
|
|
(throw (ex-info (str "Vendor matching name \"" vendor-code "\" not found.")
|
|
{:vendor-code vendor-code})))
|
|
|
|
(if-let [matching-vendor (->> (dc/q
|
|
{:find [(list 'pull '?vendor-id d-vendors/default-read)]
|
|
:in ['$ '?vendor-id]}
|
|
(dc/db conn) vendor-id)
|
|
first
|
|
first)]
|
|
matching-vendor
|
|
(throw (ex-info (str "No vendor with the name " vendor-code " was found.")
|
|
{:vendor-code vendor-code})))))
|
|
|
|
(defn import->invoice [{:keys [invoice-number source-url customer-identifier account-number total date vendor-code text full-text client-override vendor-search vendor-override location-override import-status]} user]
|
|
(when-not total
|
|
(throw (Exception. "Couldn't parse total from file.")))
|
|
(when-not date
|
|
(throw (Exception. "Couldn't parse date from file.")))
|
|
(let [matching-client (cond
|
|
client-override client-override
|
|
account-number (:db/id (d-clients/exact-match account-number))
|
|
customer-identifier (:db/id (d-clients/best-match customer-identifier)))
|
|
_ (alog/info ::client-matched
|
|
:account-number account-number
|
|
:customer-identifier customer-identifier
|
|
:client-override client-override
|
|
:matching (when matching-client
|
|
(dc/pull (dc/db conn) [:client/name :client/code] matching-client)))
|
|
|
|
matching-vendor (match-vendor vendor-code vendor-override vendor-search)
|
|
|
|
matching-location (or (when-not (str/blank? location-override)
|
|
location-override)
|
|
(parse/best-location-match (dc/pull (dc/db conn)
|
|
[{:client/location-matches [:location-match/location :location-match/matches]}
|
|
:client/default-location
|
|
:client/locations]
|
|
matching-client)
|
|
text
|
|
full-text))]
|
|
#:invoice {:db/id (random-tempid)
|
|
:invoice/uploader (-> user :db/id)
|
|
:invoice/client matching-client
|
|
:invoice/client-identifier (or account-number customer-identifier)
|
|
:invoice/vendor (:db/id matching-vendor)
|
|
:invoice/source-url source-url
|
|
:invoice/invoice-number invoice-number
|
|
:invoice/total (Double/parseDouble total)
|
|
:invoice/date (to-date date)
|
|
:invoice/location matching-location
|
|
:invoice/import-status (or import-status :import-status/pending)
|
|
:invoice/outstanding-balance (Double/parseDouble total)
|
|
:invoice/status :invoice-status/unpaid}))
|
|
|
|
(defn validate-invoice [invoice user]
|
|
(when-not (:invoice/client invoice)
|
|
(throw (ex-info (str "Searched clients for '" (:invoice/client-identifier invoice) "'. No client found in file. Select a client first.")
|
|
{:invoice-number (:invoice/invoice-number invoice)
|
|
:customer-identifier (:invoice/client-identifier invoice)})))
|
|
(assert-can-see-client user (:invoice/client invoice))
|
|
(doseq [k [:invoice/invoice-number :invoice/client :invoice/vendor :invoice/total :invoice/outstanding-balance :invoice/date]]
|
|
(when (not (get invoice k))
|
|
(throw (ex-info (str (name k) "not found on invoice " invoice)
|
|
invoice))))
|
|
|
|
invoice)
|
|
|
|
(defn admin-only-if-multiple-clients [is]
|
|
(let [client-count (->> is
|
|
(map :invoice/client)
|
|
set
|
|
count)]
|
|
(map #(assoc % :invoice/source-url-admin-only (boolean (> client-count 1))) is)))
|
|
|
|
|
|
(defn import-uploaded-invoice [user imports]
|
|
(alog/info ::importing-uploaded :count (count imports)
|
|
:bc (or user "NOO"))
|
|
(let [potential-invoices (->> imports
|
|
(map #(import->invoice % user))
|
|
(map #(validate-invoice % user))
|
|
admin-only-if-multiple-clients
|
|
(mapv d-invoices/code-invoice)
|
|
(mapv (fn [i] [:propose-invoice i])))]
|
|
|
|
(alog/info ::creating-invoice :invoices potential-invoices)
|
|
(let [tx (audit-transact potential-invoices user)]
|
|
(when-not (seq (dc/q '[:find ?i
|
|
:in $ [?i ...]
|
|
:where [?i :invoice/invoice-number]]
|
|
(:db-after tx)
|
|
(map :e (:tx-data tx))))
|
|
(throw (ex-info "No new invoices found."
|
|
{:template (:template (first imports))})))
|
|
tx)))
|
|
|
|
(defn import-internal [tempfile filename force-client force-location force-vendor force-chatgpt identity]
|
|
(mu/with-context {:parsing-file filename}
|
|
(try
|
|
(let [extension (last (str/split (.getName (io/file filename)) #"\."))
|
|
s3-location (str "invoice-files/" (str (UUID/randomUUID)) "." extension)
|
|
_ (s3/put-object (:data-bucket env)
|
|
s3-location
|
|
(io/input-stream tempfile)
|
|
{:content-type (if (= "csv" extension)
|
|
"text/csv"
|
|
"application/pdf")
|
|
:content-length (.length tempfile)})
|
|
imports (->> (if force-chatgpt
|
|
(parse/glimpse2 (.getPath tempfile))
|
|
(parse/parse-file (.getPath tempfile) filename :allow-glimpse? true))
|
|
(map #(assoc %
|
|
:client-override force-client
|
|
:location-override force-location
|
|
:vendor-override force-vendor
|
|
:source-url (str "https://" (:data-bucket env)
|
|
"/"
|
|
s3-location))))]
|
|
(try
|
|
|
|
(import-uploaded-invoice identity imports)
|
|
(catch Exception e
|
|
(alog/warn ::couldnt-import-upload
|
|
:error e
|
|
:template (:template ( first imports)))
|
|
(throw (ex-info (ex-message e)
|
|
{:template (:template ( first imports))
|
|
:sample (first imports)}
|
|
e))))
|
|
imports)
|
|
(catch Exception e
|
|
(alog/warn ::couldnt-import-upload
|
|
:error e)
|
|
(throw e)))))
|
|
|
|
(defn import-file [request]
|
|
#_(html-response [:div])
|
|
(let [{:keys [file force-client force-vendor force-location force-chatgpt]} (:multipart-params request)
|
|
file (if (vector? file)
|
|
file
|
|
[file])
|
|
results (reduce
|
|
(fn [result {:keys [filename tempfile]}]
|
|
(try
|
|
(let [i (import-internal tempfile filename force-client force-location force-vendor force-chatgpt (:identity request) )]
|
|
(update result :results conj {:filename filename
|
|
:response "success!"
|
|
:template (:template (first i))}))
|
|
(catch Exception e
|
|
(-> result
|
|
(assoc :error? true)
|
|
(update :results conj {:filename filename
|
|
:response (.getMessage e)
|
|
:sample (:sample (ex-data e))
|
|
:template (:template (ex-data e))})))))
|
|
{:error? false :results []}
|
|
file)]
|
|
(html-response [:div#page-notification.p-4.rounded-lg
|
|
{:class (if (:error? results)
|
|
"bg-red-50 text-red-700"
|
|
"bg-primary-50 text-primary-700")}
|
|
[:table
|
|
[:thead
|
|
[:tr [:td "File"] [:td "Result"]
|
|
[:td "Template"]
|
|
(if (:error? results)
|
|
[:td "Sample match"])]
|
|
#_[:tr "Result"]
|
|
#_[:tr "Template"]
|
|
]
|
|
(for [r (:results results)]
|
|
[:tr
|
|
[:td.p-2.border
|
|
{:class (if (:error? results)
|
|
"bg-red-50 text-red-700 border-red-300"
|
|
"bg-primary-50 text-primary-700 border-green-500")}
|
|
(:filename r)]
|
|
[:td.p-2.border
|
|
{:class (if (:error? results)
|
|
"bg-red-50 text-red-700 border-red-300"
|
|
"bg-primary-50 text-primary-700 border-green-500")}
|
|
(:response r)]
|
|
[:td.p-2.border
|
|
{:class (if (:error? results)
|
|
"bg-red-50 text-red-700 border-red-300"
|
|
"bg-primary-50 text-primary-700 border-green-500")}
|
|
"Template: " (:template r)]
|
|
(if (:error? results )
|
|
[:td.p-2.border
|
|
{:class "bg-red-50 text-red-700 border-red-300"}
|
|
|
|
[:ul
|
|
(for [[k v] (dissoc (:sample r) :template :source-url :full-text :text)]
|
|
[:li (name k) ": " (str v)])]
|
|
#_(:template r)])])]]
|
|
:headers
|
|
{"hx-trigger" "invalidated"})
|
|
))
|
|
|
|
#_(defn wrap-test [handler]
|
|
(fn [request]
|
|
(clojure.pprint/pprint (:multipart-params request))
|
|
(handler request )))
|
|
|
|
(def key->handler
|
|
(apply-middleware-to-all-handlers
|
|
{::route/import-page
|
|
(->
|
|
(helper/page-route grid-page)
|
|
(wrap-implied-route-param :status nil))
|
|
::route/import-table
|
|
(-> (helper/table-route grid-page)
|
|
(wrap-implied-route-param :status nil))
|
|
|
|
::route/disapprove (-> disapprove
|
|
(wrap-entity [:route-params :db/id] default-read)
|
|
(wrap-schema-enforce :route-params [:map [:db/id entity-id]]))
|
|
::route/approve (-> approve
|
|
(wrap-entity [:route-params :db/id] default-read)
|
|
(wrap-schema-enforce :route-params [:map [:db/id entity-id]]))
|
|
::route/bulk-disapprove (-> bulk-disapprove
|
|
(wrap-schema-enforce :form-schema query-schema))
|
|
::route/bulk-approve (-> bulk-approve
|
|
(wrap-schema-enforce :form-schema query-schema))
|
|
::route/import-file (-> import-file
|
|
(wrap-schema-enforce :multipart-schema upload-schema))}
|
|
(fn [a]
|
|
(-> a
|
|
(wrap-must {:subject :invoice :activity :import})))))
|
|
|
|
|