341 lines
20 KiB
Clojure
341 lines
20 KiB
Clojure
(ns auto-ap.ssr.transaction.insights
|
|
(:require
|
|
[auto-ap.client-routes :as client-routes]
|
|
[auto-ap.datomic :refer [conn visible-clients]]
|
|
[auto-ap.rule-matching :refer [spread-cents]]
|
|
[auto-ap.ssr-routes :as ssr-routes]
|
|
[auto-ap.ssr.components :as com]
|
|
[auto-ap.ssr.svg :as svg]
|
|
[auto-ap.ssr.ui :refer [base-page]]
|
|
[auto-ap.ssr.utils :refer [html-response modal-response]]
|
|
[auto-ap.time :as atime]
|
|
[bidi.bidi :as bidi]
|
|
[cemerick.url :as url]
|
|
[clj-http.client :as http]
|
|
[clj-time.coerce :as coerce]
|
|
[datomic.api :as dc]
|
|
[hiccup2.core :as hiccup]
|
|
[iol-ion.tx :refer [random-tempid]]))
|
|
|
|
(def pull-expr [:transaction/description-original
|
|
:db/id
|
|
|
|
:transaction/outcome-recommendation
|
|
:transaction/amount
|
|
{:transaction/client [:client/code]
|
|
:transaction/bank-account [:bank-account/code]}
|
|
:transaction/account-confidence
|
|
:transaction/date])
|
|
(defn parse-outcome [tx]
|
|
(update tx :transaction/outcome-recommendation
|
|
(fn [ors]
|
|
(map
|
|
(fn [[v a c s]]
|
|
{:vendor (dc/pull (dc/db conn)
|
|
[:vendor/name :db/id]
|
|
v)
|
|
:account (dc/pull (dc/db conn)
|
|
[:account/name :db/id]
|
|
a)
|
|
:count c
|
|
:seen-by-client? s})
|
|
ors))))
|
|
|
|
(defn transaction-recommendations [identity clients & {:keys [after]}]
|
|
(let [visible-clients (visible-clients identity)]
|
|
(->>
|
|
(dc/qseq {:query '[:find (pull ?t pull-expr)
|
|
:in $ ?starting [?c ...] pull-expr
|
|
:where
|
|
[?t :transaction/outcome-recommendation]
|
|
[?t :transaction/client ?c]
|
|
[?t :transaction/approval-status :transaction-approval-status/unapproved]
|
|
;; [?t :transaction/vendor] ;; should be not
|
|
[?t :transaction/date ?d]
|
|
[(>= ?d ?starting)]]
|
|
|
|
:args [(dc/db conn)
|
|
(iol-ion.query/recent-date 300)
|
|
(map :db/id clients)
|
|
|
|
pull-expr]})
|
|
(map first)
|
|
(drop-while (fn [x]
|
|
(if after
|
|
(not= (Long/parseLong after) (:db/id x))
|
|
false)))
|
|
(#(if after
|
|
(drop 1 %)
|
|
%))
|
|
(map parse-outcome)
|
|
(take 50)
|
|
(into []))))
|
|
|
|
|
|
(defn get-pinecone [transaction-id]
|
|
(->
|
|
(http/get (-> "https://transactions-a8257ba.svc.us-west4-gcp-free.pinecone.io/vectors/fetch"
|
|
url/url
|
|
(assoc :query {:ids transaction-id})
|
|
str)
|
|
{:headers {"Api-Key" "f2d3a78e-bcea-4fcd-88b6-2527b8423607"}
|
|
:as :json
|
|
:keywordize? false})
|
|
:body
|
|
:vectors
|
|
((keyword (str transaction-id)))
|
|
:values))
|
|
|
|
(defn get-pinecone-similarities [transaction-id]
|
|
(filter
|
|
(fn [{:keys [score]}]
|
|
(> score 0.95)
|
|
)
|
|
(->
|
|
(http/post (-> "https://transactions-a8257ba.svc.us-west4-gcp-free.pinecone.io/query"
|
|
url/url
|
|
str)
|
|
{:headers {"Api-Key" "f2d3a78e-bcea-4fcd-88b6-2527b8423607"}
|
|
:form-params {"vector" (get-pinecone transaction-id)
|
|
"topK" 100,
|
|
"includeMetadata" true
|
|
"namespace" ""}
|
|
:content-type :json
|
|
:as :json})
|
|
:body
|
|
:matches)))
|
|
|
|
(defn pinecone-similarity-list [transaction-id]
|
|
(for [{{:keys [amount date description vendor]} :metadata score :score id :id} (get-pinecone-similarities transaction-id)
|
|
:let [vendor-name (:vendor/name (:transaction/vendor (dc/pull (dc/db conn) [{:transaction/vendor [:vendor/name]} ] (Long/parseLong id))))
|
|
account-code (-> (dc/pull (dc/db conn) [{:transaction/accounts [{:transaction-account/account [:account/numeric-code]}]} ] (Long/parseLong id))
|
|
:transaction/accounts
|
|
first
|
|
:transaction-account/account
|
|
:account/numeric-code)]
|
|
:when (or vendor-name account-code)]
|
|
{:vendor-name vendor-name
|
|
:numeric-code account-code
|
|
:amount amount
|
|
:date date
|
|
:description description
|
|
:score score}))
|
|
|
|
|
|
(defn transaction-row [r & {:keys [hide-actions? class last?] hs "_"}]
|
|
(com/data-grid-row
|
|
(cond-> {:class class}
|
|
hs (assoc "_" hs)
|
|
|
|
last? (assoc :hx-get (bidi/path-for ssr-routes/only-routes
|
|
:transaction-insight-rows
|
|
:after (:db/id r))
|
|
:hx-trigger "intersect once"
|
|
:hx-indicator "#insight-table"
|
|
:hx-swap "afterend"))
|
|
(com/data-grid-cell {} (:client/code (:transaction/client r)))
|
|
(com/data-grid-cell {} (:bank-account/code (:transaction/bank-account r)))
|
|
(com/data-grid-cell {} (some-> (:transaction/date r) coerce/to-date-time (atime/unparse-local atime/normal-date)))
|
|
|
|
(com/data-grid-cell {} (str (:transaction/description-original r)))
|
|
(com/data-grid-cell {}
|
|
(if (> (:transaction/amount r) 0.0)
|
|
[:div.tag.is-success.is-light (str "$" (Math/round (:transaction/amount r)))]
|
|
[:div.tag.is-danger.is-light (str "$" (Math/round (:transaction/amount r)))]))
|
|
|
|
(com/data-grid-right-stack-cell {}
|
|
[:div.flex.gap-2.flex-col {:style {:width "25em"}}
|
|
(for [or (take 3 (sort-by (comp - :count)
|
|
(:transaction/outcome-recommendation r)))]
|
|
[:form {:hx-post (bidi/path-for ssr-routes/only-routes
|
|
:transaction-insight-code
|
|
:transaction-id (:db/id r))
|
|
:hx-target "closest tr"
|
|
:hx-swap "outerHTML"
|
|
:disabled hide-actions?}
|
|
(when-let [vendor-id (:db/id (:vendor or))]
|
|
[:input {:type :hidden :value vendor-id :name "vendor"}])
|
|
(when-let [account-id (:db/id (:account or))]
|
|
[:input {:type :hidden :value account-id :name "account"}])
|
|
|
|
(com/button {:color (if (:seen-by-client? or)
|
|
:primary
|
|
:secondary)
|
|
:style {:position "relative"
|
|
:display "block"
|
|
:width "100%"}}
|
|
(:vendor/name (:vendor or))
|
|
(when (:vendor/name (:vendor or))
|
|
" | ")
|
|
(:account/name (:account or))
|
|
(com/badge {:color :secondary}
|
|
(:count or)))])
|
|
[:div.flex.flex-row.gap-2
|
|
(com/button {:hx-get (bidi/path-for ssr-routes/only-routes
|
|
:transaction-insight-explain
|
|
:transaction-id (:db/id r))
|
|
:hx-target "#modal-holder"
|
|
:hx-swap "outerHTML"}
|
|
[:div.flex
|
|
svg/question
|
|
"Explain"])
|
|
(com/button {:hx-delete (bidi/path-for ssr-routes/only-routes
|
|
:transaction-insight-disapprove
|
|
:transaction-id (:db/id r))
|
|
:hx-target "closest tr"
|
|
:hx-swap "outerHTML"}
|
|
[:div.flex
|
|
svg/question
|
|
"Reject"])]])))
|
|
|
|
(defn code [{:keys [identity session] {:keys [transaction-id]} :route-params {:strs [vendor account]} :form-params}]
|
|
(let [approval-details (dc/pull (dc/db conn) [:transaction/recommended-vendor
|
|
:transaction/amount
|
|
:db/id
|
|
{:transaction/client [:client/locations]}]
|
|
(cond-> transaction-id
|
|
string? (Long/parseLong)))
|
|
account (dc/pull (dc/db conn) [:account/location :db/id]
|
|
(cond-> account
|
|
string? (Long/parseLong)))
|
|
cents-to-distribute (int (Math/round (Math/abs (* (:transaction/amount approval-details) 100))))
|
|
valid-locations (or
|
|
(some-> approval-details :transaction/recommended-account :account/location vector)
|
|
(->> approval-details
|
|
:transaction/client
|
|
:client/locations))
|
|
updated-transaction [:upsert-transaction {:db/id (:db/id approval-details)
|
|
:transaction/approval-status :transaction-approval-status/approved
|
|
:transaction/vendor (some-> vendor not-empty (Long/parseLong))
|
|
:transaction/accounts (->> valid-locations
|
|
(map
|
|
(fn [cents location]
|
|
{:db/id (random-tempid)
|
|
:transaction-account/account (-> account :db/id)
|
|
:transaction-account/amount (* 0.01 cents)
|
|
:transaction-account/location location})
|
|
(spread-cents cents-to-distribute (count valid-locations))))}]
|
|
db-before (dc/db conn)]
|
|
@(dc/transact conn [updated-transaction])
|
|
(html-response (transaction-row
|
|
(parse-outcome (dc/pull db-before
|
|
pull-expr
|
|
(Long/parseLong transaction-id)))
|
|
:hide-actions? true
|
|
:class "live-added"
|
|
"_" (hiccup/raw "init transition opacity to 0 then remove me")))))
|
|
|
|
(defn disapprove [{:keys [identity session] {:keys [transaction-id]} :route-params}]
|
|
(let [transaction-id (cond-> transaction-id string? (Long/parseLong))
|
|
db-before (dc/db conn)]
|
|
@(dc/transact conn [[:upsert-transaction {:db/id transaction-id :transaction/outcome-recommendation nil}]])
|
|
(html-response (transaction-row
|
|
(parse-outcome (dc/pull db-before pull-expr transaction-id))
|
|
:hide-actions? true
|
|
"_" (hiccup/raw "init transition opacity to 0 over 500ms then remove me")))))
|
|
(defn explain [{:keys [identity session] {:keys [transaction-id]} :route-params}]
|
|
(let [r (dc/pull (dc/db conn)
|
|
pull-expr
|
|
(Long/parseLong transaction-id))
|
|
similar (pinecone-similarity-list transaction-id)]
|
|
(modal-response
|
|
(com/modal {}
|
|
(com/modal-card {:style {:width "900px"}}
|
|
[:div.flex [:div.p-2 "Similar Transactions"]]
|
|
(com/data-grid {:headers [(com/data-grid-header {:name "Date"
|
|
:key "date"})
|
|
(com/data-grid-header {:name "Description"
|
|
:key "description"})
|
|
(com/data-grid-header {:name "Amount"
|
|
:key "amount"})
|
|
(com/data-grid-header {:name "Vendor"
|
|
:key "vendor"})
|
|
(com/data-grid-header {:name "Account"
|
|
:key "account"})
|
|
(com/data-grid-header {:name "Score"
|
|
:key "score"})]}
|
|
|
|
(com/data-grid-row {:class "bg-primary-200"}
|
|
(com/data-grid-cell {:class "text-left font-bold"} (some-> r :transaction/date coerce/to-date-time (atime/unparse-local atime/normal-date)))
|
|
(com/data-grid-cell {:class "text-left font-bold"} (-> r :transaction/description-original) )
|
|
(com/data-grid-cell {:class "font-bold"} (if (> (-> r :transaction/amount) 0.0)
|
|
[:div.tag.is-success.is-light (str "$" (Math/round (:transaction/amount r)))]
|
|
[:div.tag.is-danger.is-light (str "$" (Math/round (:transaction/amount r)))]))
|
|
(com/data-grid-cell {})
|
|
(com/data-grid-cell {})
|
|
(com/data-grid-cell {}))
|
|
|
|
(com/data-grid-row {}
|
|
(take 10
|
|
(for [{:keys [amount date description vendor-name numeric-code score]} similar]
|
|
(com/data-grid-row
|
|
{}
|
|
(com/data-grid-cell {:class "text-left"} (subs date 0 10))
|
|
(com/data-grid-cell {:class "text-left"} description )
|
|
(com/data-grid-cell {} (some->> amount double (format "$%.2f")))
|
|
(com/data-grid-cell {} vendor-name)
|
|
(com/data-grid-cell {} numeric-code)
|
|
(com/data-grid-cell {} (format "%.1f%%" (* 100 (double score)))))))))
|
|
[:div])))))
|
|
|
|
(defn transaction-rows* [{:keys [clients identity after]}]
|
|
(let [recommendations (transaction-recommendations identity clients :after after)]
|
|
(if (seq recommendations)
|
|
(for [r recommendations
|
|
:let [last? (= r (last recommendations))]]
|
|
(transaction-row r :last? last?))
|
|
[:tr [:td.has-text-centered.has-text-gray {:colspan 7 }
|
|
[:i "That's the last of 'em!"]]])))
|
|
|
|
(defn transaction-rows [{:keys [session identity route-params clients]}]
|
|
(html-response (transaction-rows* {:clients clients
|
|
:identity identity
|
|
:after (:after route-params)})))
|
|
|
|
(defn insight-table* [{:keys [clients identity]}]
|
|
(let [recommendations (transaction-recommendations identity clients)]
|
|
(com/data-grid-card {:id "insight-table"
|
|
:title "Transaction Insights"
|
|
:route :transaction-insight-table
|
|
:paginate? false
|
|
:total (count recommendations)
|
|
:action-buttons nil
|
|
:rows (for [r recommendations
|
|
:let [last? (= r (last recommendations))]]
|
|
(transaction-row r :last? last?))
|
|
:headers [(com/data-grid-header {:style {:width "10em"}} "Client")
|
|
(com/data-grid-header {:style {:width "15em"}} "Account")
|
|
(com/data-grid-header {:style {:width "8em"}} "Date")
|
|
(com/data-grid-header {} "Description")
|
|
(com/data-grid-header {:style {:width "8em"}} "Amount")
|
|
(com/data-grid-header {})]})))
|
|
|
|
(defn insight-table [{:keys [session identity clients]}]
|
|
(html-response (insight-table* {:clients clients
|
|
:identity identity})))
|
|
|
|
(defn page [{:keys [identity matched-route session clients] :as request}]
|
|
(base-page
|
|
request
|
|
(com/page {:nav com/main-aside-nav
|
|
:client-selection (:client-selection (:session request))
|
|
:client (:client request)
|
|
:identity (:identity request)
|
|
:app-params {:hx-get (bidi/path-for ssr-routes/only-routes
|
|
:transaction-insights)
|
|
:hx-trigger "clientSelected from:body"
|
|
:hx-select "#app-contents"
|
|
:hx-swap "outerHTML swap:300ms"}
|
|
:request request}
|
|
(com/breadcrumbs {}
|
|
[:a {:href (bidi/path-for client-routes/routes
|
|
:transactions)}
|
|
"Transactions"]
|
|
[:a {:href (bidi/path-for ssr-routes/only-routes
|
|
:transaction-insights)}
|
|
"Insights"])
|
|
(insight-table* {:clients clients
|
|
:identity identity}))
|
|
|
|
"Transaction Insights"))
|