Compare commits
3 Commits
33cfbab54a
...
366781e818
| Author | SHA1 | Date | |
|---|---|---|---|
| 366781e818 | |||
| bcb1978f44 | |||
| 785c6b3731 |
@@ -12,9 +12,14 @@
|
|||||||
[hiccup2.core :as hiccup]))
|
[hiccup2.core :as hiccup]))
|
||||||
|
|
||||||
(defn header- [params & rest]
|
(defn header- [params & rest]
|
||||||
(into [:th.px-4.py-3 {:scope "col" :class (:class params)
|
;; NOTE: only attach the sort dispatcher when there is a :sort-key. Otherwise
|
||||||
"@click" (format "$dispatch('sorted', {key: '%s'})" (:sort-key params))
|
;; (format "%s" nil) renders the literal string "null", which sails through the
|
||||||
:style (:style params)}]
|
;; thead's `event.detail.key || ""` guard and reaches the server as
|
||||||
|
;; toggle-sort=null, ending in a Datomic unbound-variable error on ?sort-null.
|
||||||
|
(into [:th.px-4.py-3 (cond-> {:scope "col" :class (:class params)
|
||||||
|
:style (:style params)}
|
||||||
|
(:sort-key params)
|
||||||
|
(assoc "@click" (format "$dispatch('sorted', {key: '%s'})" (:sort-key params))))]
|
||||||
(if (:sort-key params)
|
(if (:sort-key params)
|
||||||
[(into [:a {:href "#"}] rest)]
|
[(into [:a {:href "#"}] rest)]
|
||||||
rest)))
|
rest)))
|
||||||
|
|||||||
@@ -159,7 +159,7 @@
|
|||||||
(fc/with-field :table
|
(fc/with-field :table
|
||||||
(when (seq (fc/field-value))
|
(when (seq (fc/field-value))
|
||||||
|
|
||||||
[:div {:x-data (hx/json {"showTable" false})}
|
[:div {:x-data (hx/json {"showTable" false "errorsOnly" false})}
|
||||||
[:form {:hx-post (bidi.bidi/path-for ssr-routes/only-routes ::route/external-import-import)
|
[:form {:hx-post (bidi.bidi/path-for ssr-routes/only-routes ::route/external-import-import)
|
||||||
:autocomplete "off"}
|
:autocomplete "off"}
|
||||||
(when (:just-parsed? request)
|
(when (:just-parsed? request)
|
||||||
@@ -180,8 +180,10 @@
|
|||||||
[:div.flex.gap-4.items-center
|
[:div.flex.gap-4.items-center
|
||||||
(com/checkbox {"@click" "showTable=!showTable"}
|
(com/checkbox {"@click" "showTable=!showTable"}
|
||||||
"Show table")
|
"Show table")
|
||||||
|
(com/checkbox {:x-model "errorsOnly"}
|
||||||
|
"Only show errors")
|
||||||
(com/button {:color :primary} "Import")]
|
(com/button {:color :primary} "Import")]
|
||||||
[:div {:x-show "showTable"}
|
[:div {:x-show "showTable || errorsOnly"}
|
||||||
(com/data-grid-card {:id "ledger-import-data"
|
(com/data-grid-card {:id "ledger-import-data"
|
||||||
:route nil
|
:route nil
|
||||||
:title "Data to import"
|
:title "Data to import"
|
||||||
@@ -199,14 +201,18 @@
|
|||||||
:rows
|
:rows
|
||||||
(fc/cursor-map
|
(fc/cursor-map
|
||||||
(fn [r]
|
(fn [r]
|
||||||
(let [entry-id (line->id (fc/field-value r))]
|
(let [entry-id (line->id (fc/field-value r))
|
||||||
|
row-errors (seq (fc/field-errors))]
|
||||||
;; A ledger entry spans several rows. Each row knows its own
|
;; A ledger entry spans several rows. Each row knows its own
|
||||||
;; entry id and drops itself when any of its siblings' remove
|
;; entry id and drops itself when any of its siblings' remove
|
||||||
;; buttons announces that id. Removing the row removes its
|
;; buttons announces that id. Removing the row removes its
|
||||||
;; inputs, so the entry is gone from the next import post.
|
;; inputs, so the entry is gone from the next import post.
|
||||||
(com/data-grid-row {:data-entry-id entry-id
|
;; Clean rows are only hidden (x-show), never removed, so
|
||||||
|
;; "Only show errors" never changes what gets posted.
|
||||||
|
(com/data-grid-row (cond-> {:data-entry-id entry-id
|
||||||
:x-data (hx/json {"entryId" entry-id})
|
:x-data (hx/json {"entryId" entry-id})
|
||||||
"@remove-import-entry.window" "if ($event.detail.entryId === entryId) $el.remove()"}
|
"@remove-import-entry.window" "if ($event.detail.entryId === entryId) $el.remove()"}
|
||||||
|
(not row-errors) (assoc :x-show "!errorsOnly"))
|
||||||
(com/data-grid-cell {}
|
(com/data-grid-cell {}
|
||||||
(fc/with-field :external-id
|
(fc/with-field :external-id
|
||||||
(com/validated-field
|
(com/validated-field
|
||||||
@@ -520,12 +526,23 @@
|
|||||||
ea)))
|
ea)))
|
||||||
line-items)))))
|
line-items)))))
|
||||||
|
|
||||||
|
(defn blank-amount?
|
||||||
|
"A row with neither a debit nor a credit carries no amount at all, so it is
|
||||||
|
skipped entirely on import rather than flagged."
|
||||||
|
[{:keys [debit credit]}]
|
||||||
|
(letfn [(blank? [v]
|
||||||
|
(or (nil? v)
|
||||||
|
(and (string? v) (str/blank? v))))]
|
||||||
|
(and (blank? debit) (blank? credit))))
|
||||||
|
|
||||||
(defn table->entries [table all-vendors all-accounts client-locked-lookup all-client-bank-accounts all-client-locations]
|
(defn table->entries [table all-vendors all-accounts client-locked-lookup all-client-bank-accounts all-client-locations]
|
||||||
(let [lines-with-indexes (for [[i l] (map vector (range) table)]
|
(let [lines-with-indexes (for [[i l] (map vector (range) table)]
|
||||||
(assoc l :index i))]
|
(assoc l :index i))]
|
||||||
(into []
|
(into []
|
||||||
(for [[_ lines] (group-by line->id lines-with-indexes)
|
(for [[_ grouped-lines] (group-by line->id lines-with-indexes)
|
||||||
:let [{:keys [source client-code date vendor-name note cleared-against] :as line} (first lines)]]
|
:let [lines (remove blank-amount? grouped-lines)
|
||||||
|
{:keys [source client-code date vendor-name note cleared-against] :as line} (first lines)]
|
||||||
|
:when (seq lines)]
|
||||||
(add-errors {:source source
|
(add-errors {:source source
|
||||||
:indices (map :index lines)
|
:indices (map :index lines)
|
||||||
:external-id (line->id line)
|
:external-id (line->id line)
|
||||||
|
|||||||
@@ -248,6 +248,19 @@
|
|||||||
[?v :vendor/name ?sort-vendor])
|
[?v :vendor/name ?sort-vendor])
|
||||||
(and [(missing? $ ?e :transaction/vendor)]
|
(and [(missing? $ ?e :transaction/vendor)]
|
||||||
[(ground "") ?sort-vendor]))]
|
[(ground "") ?sort-vendor]))]
|
||||||
|
"bank-account" '[(or-join [?e ?sort-bank-account]
|
||||||
|
(and [?e :transaction/bank-account ?sort-ba]
|
||||||
|
[?sort-ba :bank-account/name ?sort-bank-account])
|
||||||
|
(and [?e :transaction/bank-account ?sort-ba]
|
||||||
|
[(missing? $ ?sort-ba :bank-account/name)]
|
||||||
|
[?sort-ba :bank-account/numeric-code ?sort-ba-code]
|
||||||
|
[(str ?sort-ba-code) ?sort-bank-account])
|
||||||
|
(and [?e :transaction/bank-account ?sort-ba]
|
||||||
|
[(missing? $ ?sort-ba :bank-account/name)]
|
||||||
|
[(missing? $ ?sort-ba :bank-account/numeric-code)]
|
||||||
|
[(ground "") ?sort-bank-account])
|
||||||
|
(and [(missing? $ ?e :transaction/bank-account)]
|
||||||
|
[(ground "") ?sort-bank-account]))]
|
||||||
"date" ['[?e :transaction/date ?sort-date]]
|
"date" ['[?e :transaction/date ?sort-date]]
|
||||||
"amount" ['[?e :transaction/amount ?sort-amount]]
|
"amount" ['[?e :transaction/amount ?sort-amount]]
|
||||||
"description" ['[?e :transaction/description-original ?sort-description]]}
|
"description" ['[?e :transaction/description-original ?sort-description]]}
|
||||||
@@ -538,6 +551,7 @@
|
|||||||
:render-csv (fn [x] (-> x :transaction/client :client/name))}
|
:render-csv (fn [x] (-> x :transaction/client :client/name))}
|
||||||
{:key "bank-account"
|
{:key "bank-account"
|
||||||
:name "Bank Account"
|
:name "Bank Account"
|
||||||
|
:sort-key "bank-account"
|
||||||
:show-starting "lg"
|
:show-starting "lg"
|
||||||
:render (fn [x]
|
:render (fn [x]
|
||||||
(let [ba (:transaction/bank-account x)]
|
(let [ba (:transaction/bank-account x)]
|
||||||
|
|||||||
@@ -177,7 +177,7 @@
|
|||||||
(:form-params request) (:form-errors request)
|
(:form-params request) (:form-errors request)
|
||||||
(fc/with-field :table
|
(fc/with-field :table
|
||||||
(when (seq (fc/field-value))
|
(when (seq (fc/field-value))
|
||||||
[:div.mt-4 {:x-data (hx/json {"showTable" true})}
|
[:div.mt-4 {:x-data (hx/json {"showTable" true "errorsOnly" false})}
|
||||||
(when (:just-parsed? request)
|
(when (:just-parsed? request)
|
||||||
(parsed-banner request))
|
(parsed-banner request))
|
||||||
[:form {:hx-post (bidi/path-for ssr-routes/only-routes ::route/external-import-import)
|
[:form {:hx-post (bidi/path-for ssr-routes/only-routes ::route/external-import-import)
|
||||||
@@ -186,8 +186,9 @@
|
|||||||
:autocomplete "off"}
|
:autocomplete "off"}
|
||||||
[:div.flex.gap-4.items-center.my-2
|
[:div.flex.gap-4.items-center.my-2
|
||||||
(com/checkbox {"@click" "showTable=!showTable"} "Show table")
|
(com/checkbox {"@click" "showTable=!showTable"} "Show table")
|
||||||
|
(com/checkbox {:x-model "errorsOnly"} "Only show errors")
|
||||||
(com/button {:color :primary :type "submit"} "Import")]
|
(com/button {:color :primary :type "submit"} "Import")]
|
||||||
[:div {:x-show "showTable"}
|
[:div {:x-show "showTable || errorsOnly"}
|
||||||
(com/data-grid-card
|
(com/data-grid-card
|
||||||
{:id "transaction-import-data"
|
{:id "transaction-import-data"
|
||||||
:route nil
|
:route nil
|
||||||
@@ -204,8 +205,11 @@
|
|||||||
(fc/cursor-map
|
(fc/cursor-map
|
||||||
(fn [_]
|
(fn [_]
|
||||||
(let [row-errors (fc/field-errors)]
|
(let [row-errors (fc/field-errors)]
|
||||||
|
;; Clean rows are only hidden (x-show), never removed, so
|
||||||
|
;; "Only show errors" never changes what gets posted.
|
||||||
(com/data-grid-row
|
(com/data-grid-row
|
||||||
{}
|
(cond-> {}
|
||||||
|
(not (seq row-errors)) (assoc :x-show "!errorsOnly"))
|
||||||
(com/data-grid-cell {} (fc/with-field :raw-date
|
(com/data-grid-cell {} (fc/with-field :raw-date
|
||||||
(com/text-input {:value (fc/field-value) :name (fc/field-name) :class "w-28"})))
|
(com/text-input {:value (fc/field-value) :name (fc/field-name) :class "w-28"})))
|
||||||
(com/data-grid-cell {} (fc/with-field :description-original
|
(com/data-grid-cell {} (fc/with-field :description-original
|
||||||
|
|||||||
@@ -370,6 +370,55 @@
|
|||||||
(is (= "TEST" (:client-code (first entries))))
|
(is (= "TEST" (:client-code (first entries))))
|
||||||
(is (= 2 (count (:line-items (first entries))))))))
|
(is (= 2 (count (:line-items (first entries))))))))
|
||||||
|
|
||||||
|
(deftest table->entries-blank-amount-rows-test
|
||||||
|
(let [line (fn [external-id debit credit account-code]
|
||||||
|
{:source "manual"
|
||||||
|
:client-code "TEST"
|
||||||
|
:external-id external-id
|
||||||
|
:date (coerce/to-date-time #inst "2021-01-01")
|
||||||
|
:vendor-name "Vendor"
|
||||||
|
:debit debit
|
||||||
|
:credit credit
|
||||||
|
:account-code account-code
|
||||||
|
:location "HQ"})
|
||||||
|
entries (fn [table]
|
||||||
|
(sut/table->entries table
|
||||||
|
{"Vendor" {:db/id "vendor-1"}}
|
||||||
|
#{"1100" "1101"}
|
||||||
|
{"TEST" #inst "2000-01-01"}
|
||||||
|
{"TEST" #{}}
|
||||||
|
{"TEST" #{"HQ"}}))]
|
||||||
|
(testing "Should skip rows with no debit and no credit without flagging them"
|
||||||
|
(let [_ (setup-test-data [(test-client :db/id "blank-client-1"
|
||||||
|
:client/code "TEST"
|
||||||
|
:client/locations ["HQ"])])
|
||||||
|
result (entries [(line "ext-blank-1" 100.0 nil 1100)
|
||||||
|
(line "ext-blank-1" nil nil 1101)
|
||||||
|
(line "ext-blank-1" nil 100.0 1101)])]
|
||||||
|
(is (= 1 (count result)))
|
||||||
|
(is (= 2 (count (:line-items (first result)))))
|
||||||
|
(is (= [0 2] (vec (:indices (first result)))))
|
||||||
|
(is (= 100.0 (:amount (first result))))
|
||||||
|
(is (empty? (sut/entry-errors (first result))))))
|
||||||
|
|
||||||
|
(testing "Should treat blank strings in the amount columns as no amount"
|
||||||
|
(let [_ (setup-test-data [(test-client :db/id "blank-client-2"
|
||||||
|
:client/code "TEST"
|
||||||
|
:client/locations ["HQ"])])
|
||||||
|
result (entries [(line "ext-blank-2" 100.0 nil 1100)
|
||||||
|
(line "ext-blank-2" "" " " 1101)
|
||||||
|
(line "ext-blank-2" nil 100.0 1101)])]
|
||||||
|
(is (= 2 (count (:line-items (first result)))))
|
||||||
|
(is (empty? (sut/entry-errors (first result))))))
|
||||||
|
|
||||||
|
(testing "Should drop an entry entirely when every one of its rows is blank"
|
||||||
|
(let [_ (setup-test-data [(test-client :db/id "blank-client-3"
|
||||||
|
:client/code "TEST"
|
||||||
|
:client/locations ["HQ"])])
|
||||||
|
result (entries [(line "ext-blank-3" nil nil 1100)
|
||||||
|
(line "ext-blank-3" nil nil 1101)])]
|
||||||
|
(is (empty? result))))))
|
||||||
|
|
||||||
(deftest import-ledger-test
|
(deftest import-ledger-test
|
||||||
(testing "Should upsert hidden vendors and create transactions"
|
(testing "Should upsert hidden vendors and create transactions"
|
||||||
(let [_ (setup-test-data [(test-client :db/id "import-client-1"
|
(let [_ (setup-test-data [(test-client :db/id "import-client-1"
|
||||||
@@ -397,7 +446,44 @@
|
|||||||
(let [vendor-id (dc/q '[:find ?e .
|
(let [vendor-id (dc/q '[:find ?e .
|
||||||
:where [?e :vendor/name "New Vendor Import Unique"]]
|
:where [?e :vendor/name "New Vendor Import Unique"]]
|
||||||
db-after)]
|
db-after)]
|
||||||
(is vendor-id)))))
|
(is vendor-id))))
|
||||||
|
|
||||||
|
(testing "Should import the entry while ignoring its rows with no debit and no credit"
|
||||||
|
(let [_ (setup-test-data [(test-client :db/id "import-client-2"
|
||||||
|
:client/code "IMPORT-BLANK"
|
||||||
|
:client/locations ["HQ"])
|
||||||
|
{:db/id "import-blank-account-1100"
|
||||||
|
:account/numeric-code 1100
|
||||||
|
:account/account-set "default"
|
||||||
|
:account/name "Cash"}
|
||||||
|
{:db/id "import-blank-account-1101"
|
||||||
|
:account/numeric-code 1101
|
||||||
|
:account/account-set "default"
|
||||||
|
:account/name "Other Cash"}])
|
||||||
|
row (fn [debit credit account-code]
|
||||||
|
{:source "manual"
|
||||||
|
:client-code "IMPORT-BLANK"
|
||||||
|
:external-id "ext-import-blank-1"
|
||||||
|
:date (coerce/to-date-time #inst "2021-01-01")
|
||||||
|
:vendor-name "Blank Row Vendor"
|
||||||
|
:debit debit
|
||||||
|
:credit credit
|
||||||
|
:account-code account-code
|
||||||
|
:location "HQ"})
|
||||||
|
result (sut/import-ledger {:form-params {:table [(row 100.0 nil 1100)
|
||||||
|
(row nil nil 1101)
|
||||||
|
(row nil 100.0 1101)]}
|
||||||
|
:identity (admin-token)})
|
||||||
|
entry (dc/pull (dc/db conn)
|
||||||
|
[:journal-entry/amount
|
||||||
|
{:journal-entry/line-items [:journal-entry-line/debit
|
||||||
|
:journal-entry-line/credit]}]
|
||||||
|
[:journal-entry/external-id "IMPORT-BLANK-manual-ext-import-blank-1"])]
|
||||||
|
(is (= 1 (:successful result)))
|
||||||
|
(is (= 0 (:ignored result)))
|
||||||
|
(is (empty? (:form-errors result)))
|
||||||
|
(is (= 100.0 (:journal-entry/amount entry)))
|
||||||
|
(is (= 2 (count (:journal-entry/line-items entry)))))))
|
||||||
|
|
||||||
(deftest import-ledger-with-errors-test
|
(deftest import-ledger-with-errors-test
|
||||||
(testing "Should throw exception when entries have errors - client not found"
|
(testing "Should throw exception when entries have errors - client not found"
|
||||||
|
|||||||
Reference in New Issue
Block a user