Add vendor pre-population for bulk code and individual edit forms

- Add vendor-changed HTMX handlers for both bulk code and individual edit
- Pre-populate default account at 100% when vendor is selected and no accounts exist
- Fix render-accounts-section to render from step-params correctly
- Change bulk code vendor-changed from hx-get to hx-post to include form data
- Add routes for vendor-changed endpoints
- Update e2e tests to cover vendor pre-population
- Run lein cljfmt fix across codebase
This commit is contained in:
2026-05-21 14:45:19 -07:00
parent 8bd0cee1b1
commit ba87805d4c
210 changed files with 8694 additions and 9627 deletions

View File

@@ -2,9 +2,9 @@
(: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]]
: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]
@@ -24,24 +24,23 @@
[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])
: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])
(: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"}
@@ -108,7 +107,6 @@
: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)
@@ -131,8 +129,6 @@
(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]]}
@@ -144,7 +140,6 @@
'[?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)]]}
@@ -155,7 +150,6 @@
'[(<= ?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]]}
@@ -232,7 +226,6 @@
(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))
@@ -307,7 +300,6 @@
(assoc-in [:query-params :start] 0)
(assoc-in [:query-params :per-page] 250))))
:else
selected)
ids (->> (dc/q '[:find ?i
@@ -318,29 +310,27 @@
(map first))]
ids))
(def upload-schema
[:map
(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
[:force-chatgpt {:optional true :default false}
[:maybe [:boolean {:decode/string {:enter #(if (= % "on") true
(boolean %))}}]]]
(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.justify-between.items-center [:h1.text-2xl.mb-3.font-bold "Import new invoices"]]
[:div#page-notification.notification.block {:style {:display "none"}}]
[:form
{:hx-post (bidi/path-for ssr-routes/only-routes
::route/import-file)
@@ -351,7 +341,7 @@
(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)}
@@ -366,7 +356,7 @@
(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})))
@@ -382,15 +372,15 @@
: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)
(com/validated-field {:errors (fc/field-errors)
:label " "}
(com/checkbox {:name (fc/field-name)
:error? (fc/error?) }
: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})
{:x-data (hx/json {"files" nil
"hovering" false})
":class" "{'bg-blue-100': !hovering,
'border-blue-300': !hovering,
'text-blue-700': !hovering,
@@ -399,8 +389,6 @@
'text-green-700': hovering
}"
:x-ref "box"}
[:input {:type "file"
:name "file"
@@ -410,13 +398,12 @@
:x-on:dragover "hovering = true",
:x-on:dragleave "hovering = false",
:x-on:drop "hovering = false"}]
[:div.flex.flex-col.space-2
[:div
[: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"}) ]
]]]
[: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")]]))
@@ -435,14 +422,12 @@
:query-schema query-schema
:action-buttons (fn [request]
(let [[_ _ outstanding total] (:page-results request)]
[
(when (can? (:identity request) {:subject :invoice :activity :import})
[(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 "
}
":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))
@@ -463,8 +448,8 @@
(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))}
::route/approve
:db/id (:db/id entity))}
svg/thumbs-up))])
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
@@ -515,11 +500,11 @@
(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))
(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
@@ -528,13 +513,13 @@
(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))
(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)
(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"})
@@ -542,51 +527,49 @@
(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) )
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."
: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
(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 [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."
: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))
]
))
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
(if vendor-search
(throw (ex-info (format "No vendor found. Searched for '%s'. Please supply an forced vendor."
vendor-search)
{:vendor-code vendor-code}))
@@ -594,10 +577,10 @@
{: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)
{:find ['?vendor]
:in ['$ '?vendor-name]
:where ['[?vendor :vendor/name ?vendor-name]]}
(dc/db conn) vendor-code)
first
first))]
(when-not vendor-id
@@ -605,9 +588,9 @@
{: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)
{:find [(list 'pull '?vendor-id d-vendors/default-read)]
:in ['$ '?vendor-id]}
(dc/db conn) vendor-id)
first
first)]
matching-vendor
@@ -617,7 +600,7 @@
(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
(when-not date
(throw (Exception. "Couldn't parse date from file.")))
(let [matching-client (cond
client-override client-override
@@ -629,9 +612,9 @@
: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)
@@ -658,22 +641,20 @@
(defn validate-invoice [invoice user]
(let [missing-keys (for [k [:invoice/invoice-number :invoice/client :invoice/vendor :invoice/total :invoice/outstanding-balance :invoice/date]
:when (not (get invoice k))]
k
)]
(cond
(not (:invoice/client invoice))
(do
k)]
(cond
(not (:invoice/client invoice))
(do
(alog/warn ::no-client :invoice invoice)
(assoc invoice :error-message (str "Searched clients for '" (:invoice/client-identifier invoice) "'. No client found in file. Select a client first.")))
(not (can-see-client? user (:invoice/client invoice)))
(do
(alog/warn ::cant-see-client :invoice invoice )
(assoc invoice :error-message "No access for the client in this file.")
)
(do
(alog/warn ::cant-see-client :invoice invoice)
(assoc invoice :error-message "No access for the client in this file."))
(seq missing-keys)
(do
(do
(alog/warn ::mising-keys :keys missing-keys)
(assoc invoice :error-message (str "Missing the key " missing-keys)))
:else
@@ -686,33 +667,31 @@
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
)
errored-invoices (->> potential-invoices
admin-only-if-multiple-clients)
errored-invoices (->> potential-invoices
(filter #(:error-message %)))
successful-invoices (->> potential-invoices
(filter #(not (:error-message %))))
proposed-invoices (->> potential-invoices
(filter #(not (:error-message %)))
(mapv d-invoices/code-invoice)
(mapv (fn [i] [:propose-invoice i])))]
(mapv (fn [i] [:propose-invoice i])))]
(alog/info ::creating-invoice :invoices proposed-invoices)
(let [tx (audit-transact proposed-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))})))
: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 tx
:errored-invoices errored-invoices
:successful-invoices successful-invoices
@@ -730,7 +709,7 @@
"text/csv"
"application/pdf")
:content-length (.length tempfile)})
imports (->> (if force-chatgpt
imports (->> (if force-chatgpt
(parse/glimpse2 (.getPath tempfile))
(parse/parse-file (.getPath tempfile) filename :allow-glimpse? true))
(map #(assoc %
@@ -740,16 +719,16 @@
:source-url (str "https://" (:data-bucket env)
"/"
s3-location))))]
(try
(try
(import-uploaded-invoice identity imports)
(catch Exception e
(alog/warn ::couldnt-import-upload
:error e
:template (:template ( first imports)))
:template (:template (first imports)))
(throw (ex-info (ex-message e)
{:template (:template ( first imports))
{:template (:template (first imports))
:sample (first imports)}
e)))))
(catch Exception e
@@ -767,23 +746,21 @@
(fn [result {:keys [filename tempfile]}]
(try
(let [i (import-internal tempfile filename force-client force-location force-vendor force-chatgpt (:identity request))]
(alog/info ::failure-error-count :count (count (:errored-invoices i)) )
(-> result
(update :error? #(or %
(alog/info ::failure-error-count :count (count (:errored-invoices i)))
(-> result
(update :error? #(or %
(boolean (seq (:errored-invoices i)))))
(update :files conj {:filename filename
:error? (boolean (seq (:errored-invoices i)))
:successful-invoices (count (:successful-invoices i))
:errors [:div
[:p.text-green-500 [:b (count (:successful-invoices i)) " succeeded in total."]
]
[:p [:b (count (:errored-invoices i)) " failed in total."]
]
[:ul
:errors [:div
[:p.text-green-500 [:b (count (:successful-invoices i)) " succeeded in total."]]
[:p [:b (count (:errored-invoices i)) " failed in total."]]
[:ul
(for [e (take 5 (:errored-invoices i))]
[:li (:error-message e)]) ]]
[:li (:error-message e)])]]
:template (:template (first (:imports i)))})))
(catch Exception e
(-> result
@@ -793,11 +770,10 @@
:response (.getMessage e)
:sample (:sample (ex-data e))
:template (:template (ex-data e))})))))
{:error? false
:files []
}
{:error? false
:files []}
file)]
(html-response [:div#page-notification.p-4.rounded-lg
[:table
[:thead
@@ -835,34 +811,34 @@
{"hx-trigger" "invalidated"})))
#_(defn wrap-test [handler]
(fn [request]
(clojure.pprint/pprint (:multipart-params request))
(handler request )))
(fn [request]
(clojure.pprint/pprint (:multipart-params request))
(handler request)))
(def key->handler
(apply-middleware-to-all-handlers
(apply-middleware-to-all-handlers
{::route/import-page
(->
(helper/page-route grid-page)
(wrap-implied-route-param :status nil))
::route/import-table
::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]]))
(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))
(wrap-schema-enforce :form-schema query-schema))
::route/import-file (-> import-file
(wrap-schema-enforce :multipart-schema upload-schema))}
(fn [a]
(-> a
(-> a
(wrap-must {:subject :invoice :activity :import})))))