Files
integreat/src/clj/auto_ap/ssr/invoice/import.clj

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})))))