diff --git a/dev-resources/sample-ezcater.xlsx b/dev-resources/sample-ezcater.xlsx new file mode 100644 index 00000000..e1d06403 Binary files /dev/null and b/dev-resources/sample-ezcater.xlsx differ diff --git a/scratch-sessions/cash-shift.repl b/scratch-sessions/cash-shift.repl index 8b7719d3..2082d61e 100644 --- a/scratch-sessions/cash-shift.repl +++ b/scratch-sessions/cash-shift.repl @@ -5,7 +5,7 @@ ;; You can also press C-u C-j to evaluate the expression and pretty-print its result. (def c - (first (get-square-clients "NGAK"))) + (first (get-square-clients "NGOP"))) (def l {:db/id 17592186051554, :square-location/name "Almaden", @@ -14,12 +14,13 @@ @(cash-drawer-shifts c l ) @(get-cash-shift c "ac34dc1e-5d2e-4a83-929c-c33e24ba2882") +@(get-cash-shift-events c "ac34dc1e-5d2e-4a83-929c-c33e24ba2882") @(cash-drawer-shifts c l) @(upsert-cash-shifts c) -@(d/transact conn [{:db/ident :cash-drawer-shift/external-id +@(d/transact auto-ap.datomic/conn [{:db/ident :cash-drawer-shift/external-id :db/doc "The client for the sale" :db/valueType :db.type/string :db/cardinality :db.cardinality/one @@ -66,3 +67,13 @@ :db/cardinality :db.cardinality/one} ]) + +(defn get-cash-shift-events [client id] + (de/chain (manifold-api-call {:url (str (url/url "https://connect.squareup.com/v2/cash-drawers/shifts" id "events" + + )) + :method :get + + :headers (client-base-headers client "2023-04-19") + :as :json}) + :body)) diff --git a/scratch-sessions/ezcater_xls.clj b/scratch-sessions/ezcater_xls.clj new file mode 100644 index 00000000..6c192131 --- /dev/null +++ b/scratch-sessions/ezcater_xls.clj @@ -0,0 +1,118 @@ +(ns ezcater-xls + (:require + [auto-ap.datomic :refer [conn]] + [auto-ap.time :as atime] + [clj-time.coerce :as coerce] + [datomic.api :as dc] + [clojure.data.json :as json] + [dk.ative.docjure.spreadsheet :as doc] + [auto-ap.parse :as parse] + [com.brunobonacci.mulog :as mu] + [auto-ap.logging :as alog] + [clojure.java.io :as io])) + +(defn fmt-amount [a] + (with-precision 2 + (some-> a + bigdec + (.setScale 2 java.math.RoundingMode/HALF_UP) + (double)))) + +(defn extract-sheet-details [f] + (into [] + (for [row (->> (doc/load-workbook f) + (doc/sheet-seq) + first + (doc/row-seq) + )] + (mapv doc/read-cell (doc/cell-seq row)) + ))) + +(defn rows->maps [rows] + (let [[headers & rows] rows] + (for [r rows] + (into {} + (map vector headers r))))) + + +(defn map->sales-order [r clients] + (let [order-number (get r "Order Number") + event-date (get r "Event Date") + store-name (get r "Store Name") + adjustments (get r "Adjustments") + tax (get r "Sales Tax") + food-total (get r "Food Total") + commission (get r "Commission") + fee (get r "Payment Transaction Fee") + tip (get r "Tip") + caterer-name (get r "Caterer Name") + client (some->> caterer-name + (parse/exact-match clients)) + client-id (:db/id client) + location (first (:client/locations client))] + (if (and event-date client-id location) + #:sales-order + {:date (atime/unparse-local (atime/localize (coerce/to-date-time event-date)) atime/iso-date) + :order-number order-number + :external-id (str "ezcater/order/" client-id "-" location "-" order-number) + :client client + :location location + :reference-link (str order-number) + :line-items [#:order-line-item + {:external-id (str "ezcater/order/" client-id "-" location "-" order-number "-" 0) + :item-name "EZCater Catering" + :category "EZCater Catering" + :discount (fmt-amount (or adjustments 0.0)) + :tax (fmt-amount tax) + :total (fmt-amount (+ food-total + tax + tip))}] + + :charges [#:charge + {:type-name "CARD" + :date (atime/unparse-local (atime/localize (coerce/to-date-time event-date)) atime/iso-date) + :client client-id + :location location + :external-id (str "ezcater/charge/" client-id "-" location "-" order-number "-" 0) + :processor :ccp-processor/ezcater + :total (fmt-amount (+ food-total + tax + tip)) + :tip (fmt-amount tip)}] + :total (fmt-amount (+ food-total + tax + tip)) + :discount (fmt-amount (or adjustments 0.0)) + :service-charge (fmt-amount (+ fee commission)) + :tax (fmt-amount tax) + :tip (fmt-amount tip) + :returns 0.0 + :vendor :vendor/ccp-ezcater + } + (alog/warn ::missing-client + :order order-number + :store-name store-name + :caterer-name caterer-name)))) + +(defn stream->sales-orders [s] + (let [clients (map first (dc/q '[:find (pull ?c [:client/code + :db/id + :client/feature-flags + {:client/location-matches [:location-match/matches :location-match/location]} + :client/name + :client/matches + :client/locations]) + :where [?c :client/code]] + (dc/db conn)))] + (into [] + (->> s + extract-sheet-details + rows->maps + (map #(map->sales-order % clients)) + (filter identity))))) + +(defn import-stuff [] + (with-open [s (io/input-stream "/home/brycecovert/Downloads/test_2023-04-01_2023-04-30_2023-05-04.xlsx")] + (stream->sales-orders s))) + + diff --git a/src/clj/auto_ap/parse.clj b/src/clj/auto_ap/parse.clj index 124d4752..6d54d260 100644 --- a/src/clj/auto_ap/parse.clj +++ b/src/clj/auto_ap/parse.clj @@ -127,7 +127,10 @@ (filter (fn [{:keys [:client/matches :client/name] :as client :or {matches []}}] (seq (filter (fn [m] - (= (.toLowerCase invoice-client-name) (.toLowerCase m))) + (and + m + invoice-client-name + (= (.toLowerCase invoice-client-name) (.toLowerCase m)))) (conj matches name))))) first))) diff --git a/src/clj/auto_ap/routes/ezcater_xls.clj b/src/clj/auto_ap/routes/ezcater_xls.clj new file mode 100644 index 00000000..a78b01c0 --- /dev/null +++ b/src/clj/auto_ap/routes/ezcater_xls.clj @@ -0,0 +1,193 @@ +(ns auto-ap.routes.ezcater-xls + (:require + [auto-ap.datomic :refer [audit-transact conn]] + [auto-ap.logging :as alog] + [auto-ap.parse :as parse] + [auto-ap.shared-views.admin.side-bar :refer [admin-side-bar]] + [auto-ap.ssr-routes :as ssr-routes] + [auto-ap.ssr.ui :refer [base-page]] + [auto-ap.ssr.utils :refer [html-response]] + [auto-ap.time :as atime] + [bidi.bidi :as bidi] + [clj-time.coerce :as coerce] + [clojure.java.io :as io] + [com.brunobonacci.mulog :as mu] + [datomic.api :as dc] + [dk.ative.docjure.spreadsheet :as doc] + [hiccup2.core :as hiccup])) + +(defn fmt-amount [a] + (with-precision 2 + (some-> a + bigdec + (.setScale 2 java.math.RoundingMode/HALF_UP) + (double)))) + +(defn extract-sheet-details [f] + (into [] + (for [row (->> (doc/load-workbook f) + (doc/sheet-seq) + first + (doc/row-seq) + )] + (mapv doc/read-cell (doc/cell-seq row)) + ))) + +(defn rows->maps [rows] + (let [[headers & rows] rows] + (for [r rows] + (into {} + (map vector headers r))))) + + +(defn map->sales-order [r clients] + (let [order-number (get r "Order Number") + event-date (get r "Event Date") + store-name (get r "Store Name") + adjustments (get r "Adjustments") + tax (get r "Sales Tax") + food-total (get r "Food Total") + commission (get r "Commission") + fee (get r "Payment Transaction Fee") + tip (get r "Tip") + caterer-name (get r "Caterer Name") + client (some->> caterer-name + not-empty + (parse/exact-match clients)) + client-id (:db/id client) + location (first (:client/locations client))] + (cond (and event-date client-id location ) + [:order #:sales-order + {:date (coerce/to-date (atime/localize (coerce/to-date-time event-date))) + :external-id (str "ezcater/order/" client-id "-" location "-" order-number) + :client client-id + :location location + :reference-link (str order-number) + :line-items [#:order-line-item + {:external-id (str "ezcater/order/" client-id "-" location "-" order-number "-" 0) + :item-name "EZCater Catering" + :category "EZCater Catering" + :discount (fmt-amount (or adjustments 0.0)) + :tax (fmt-amount tax) + :total (fmt-amount (+ food-total + tax + tip))}] + + :charges [#:charge + {:type-name "CARD" + :date (coerce/to-date (atime/localize (coerce/to-date-time event-date))) + :client client-id + :location location + :external-id (str "ezcater/charge/" client-id "-" location "-" order-number "-" 0) + :processor :ccp-processor/ezcater + :total (fmt-amount (+ food-total + tax + tip)) + :tip (fmt-amount tip)}] + :total (fmt-amount (+ food-total + tax + tip)) + :discount (fmt-amount (or adjustments 0.0)) + :service-charge (fmt-amount (+ fee commission)) + :tax (fmt-amount tax) + :tip (fmt-amount tip) + :returns 0.0 + :vendor :vendor/ccp-ezcater}] + + caterer-name + (do + (alog/warn ::missing-client + :order order-number + :store-name store-name + :caterer-name caterer-name) + [:missing caterer-name]) + + :else + nil))) + +(defn stream->sales-orders [s] + (let [clients (map first (dc/q '[:find (pull ?c [:client/code + :db/id + :client/feature-flags + {:client/location-matches [:location-match/matches :location-match/location]} + :client/name + :client/matches + :client/locations]) + :where [?c :client/code]] + (dc/db conn)))] + (into [] + (->> s + extract-sheet-details + rows->maps + (map #(map->sales-order % clients)) + (filter identity))))) + +(defn import-stuff [] + (with-open [s (io/input-stream "/home/brycecovert/Downloads/test_2023-04-01_2023-04-30_2023-05-04.xlsx")] + (stream->sales-orders s))) + +(defn page* [] + [:div + [:h1.title "EZCater XLS Import"] + [:div.card.block {:style {:width "500px"}} + [:div.card-content + "Please go to " + [:a {:href "https://www.ezcater.com/ez_manage/reports/new" :target "_blank"} "EZCater's report page"] + " to generate a new report. Then drop it below."]] + [:div#page-notification.notification.block {:style {:display "none"}}] + [:div.card.block + [:div.card-content + [:form {:action (bidi/path-for ssr-routes/only-routes + :admin-ezcater-xls) + :method "POST" + :class "dropzone" + :id "ezcater"}]]] + [:script + (hiccup/raw + " + Dropzone.options.ezcater = { + success: function (file, response) { + document.getElementById(\"page-notification\").innerHTML = response; + document.getElementById(\"page-notification\").style[\"display\"] = \"block\"; + } + }")]]) + +(defn upload-xls [{:keys [identity] :as request}] + + (let [file (or (get (:params request) :file) + (get (:params request) "file"))] + (mu/log ::uploading-file + :file file) + (with-open [s (io/input-stream (:tempfile file))] + (try + (let [parse-results (stream->sales-orders s) + new-orders (->> parse-results + (filter (comp #{:order} first)) + (map last)) + + missing-location (->> parse-results + (filter (comp #{:missing} first)) + (map last))] + (audit-transact new-orders identity) + (html-response [:div (format "Successfully imported %d orders." (count new-orders)) + (when (seq missing-location) + [:div "Missing the following locations" + [:ul.ul + (for [ml (into #{} missing-location)] + [:li ml])]])])) + (catch Exception e + (alog/error ::import-error + :error e) + (html-response [:div (.getMessage e)])))))) + +(defn page [{:keys [matched-route request-method] :as request}] + (mu/log ::method + :method request-method) + (if (= :post request-method) + (upload-xls request) + (base-page + request + (page*) + + (admin-side-bar matched-route)))) + diff --git a/src/clj/auto_ap/ssr/core.clj b/src/clj/auto_ap/ssr/core.clj index 43e4f2b1..e5d344d4 100644 --- a/src/clj/auto_ap/ssr/core.clj +++ b/src/clj/auto_ap/ssr/core.clj @@ -7,7 +7,8 @@ [auto-ap.ssr.transaction.insights :as insights] [auto-ap.ssr.company.company-1099 :as company-1099] [auto-ap.ssr.search :as search] - [auto-ap.ssr.company-dropdown :as company-dropdown])) + [auto-ap.ssr.company-dropdown :as company-dropdown] + [auto-ap.routes.ezcater-xls :as ezcater-xls])) ;; from auto-ap.ssr-routes, because they're shared @@ -27,5 +28,6 @@ :transaction-insight-rows (wrap-client-redirect-unauthenticated (wrap-secure insights/transaction-rows)) :transaction-insight-approve (wrap-client-redirect-unauthenticated (wrap-secure insights/approve)) :transaction-insight-explain (wrap-client-redirect-unauthenticated (wrap-secure insights/explain)) + :admin-ezcater-xls (wrap-client-redirect-unauthenticated (wrap-admin ezcater-xls/page)) :search (wrap-client-redirect-unauthenticated (wrap-secure search/dialog-contents))}) diff --git a/src/clj/auto_ap/ssr/ui.clj b/src/clj/auto_ap/ssr/ui.clj index 79b092fc..98adeefb 100644 --- a/src/clj/auto_ap/ssr/ui.clj +++ b/src/clj/auto_ap/ssr/ui.clj @@ -43,6 +43,8 @@ :crossorigin= "anonymous"}] [:script {:type "text/javascript", :src "https://cdn.yodlee.com/fastlink/v4/initialize.js", :async "async" }]] [:script {:type "text/javascript", :src "https://cdn.jsdelivr.net/npm/@tarekraafat/autocomplete.js@10.2.7/dist/autoComplete.min.js"}] + [:script {:src "https://unpkg.com/dropzone@5/dist/min/dropzone.min.js"}] + [:link {:rel "stylesheet" :href "https://unpkg.com/dropzone@5/dist/min/dropzone.min.css" :type "text/css"}] [:body [:div {:id "app"} [:div diff --git a/src/cljc/auto_ap/shared_views/admin/side_bar.cljc b/src/cljc/auto_ap/shared_views/admin/side_bar.cljc index 721633c2..a85aff5e 100644 --- a/src/cljc/auto_ap/shared_views/admin/side_bar.cljc +++ b/src/cljc/auto_ap/shared_views/admin/side_bar.cljc @@ -73,23 +73,30 @@ :active-route active-route :route :admin-history :icon-style {:font-size "25px"}}) + (menu-item {:label "Background Jobs" + :icon-class "icon icon-cog-play-1" + :test-route #{:admin-jobs} + :active-route active-route + :route :admin-jobs + :icon-style {:font-size "25px"}}) [:p.menu-label "Import"] (menu-item {:label "Excel Invoices" :icon-class "fa fa-download" :test-route #{:admin-excel-import} :active-route active-route :route :admin-excel-import}) - (menu-item {:label "Excel Invoices" + (menu-item {:label "Import Batches" :icon-class "fa fa-download" :test-route #{:admin-import-batches} :active-route active-route :route :admin-import-batches}) - (menu-item {:label "Background Jobs" + (menu-item {:label "EZCater XLS Import" :icon-class "icon icon-cog-play-1" - :test-route #{:admin-jobs} + :test-route #{:admin-ezcater-xls} :active-route active-route - :route :admin-jobs + :route :admin-ezcater-xls :icon-style {:font-size "25px"}}) + (into [:div ] children)]) #?(:clj diff --git a/src/cljc/auto_ap/ssr_routes.cljc b/src/cljc/auto_ap/ssr_routes.cljc index 1a18a21b..9f4cdfba 100644 --- a/src/cljc/auto_ap/ssr_routes.cljc +++ b/src/cljc/auto_ap/ssr_routes.cljc @@ -2,11 +2,12 @@ (def routes {"logout" :logout "search" :search - "admin" {"/history" {"" :admin-history - "/" :admin-history - #"/search/?" :admin-history-search - ["/" [#"\d+" :entity-id] #"/?"] :admin-history-search - ["/inspect/" [#"\d+" :entity-id] #"/?"] :admin-history-inspect}} + "admin" {"/history" {"" :admin-history + "/" :admin-history + #"/search/?" :admin-history-search + ["/" [#"\d+" :entity-id] #"/?"] :admin-history-search + ["/inspect/" [#"\d+" :entity-id] #"/?"] :admin-history-inspect} + "/ezcater-xls" :admin-ezcater-xls} "transaction" {"/insights" {"" :transaction-insights "/table" :transaction-insight-table ["/approve/" [#"\d+" :transaction-id]] {:post :transaction-insight-approve} diff --git a/test/clj/auto_ap/integration/routes/ezcater_xls.clj b/test/clj/auto_ap/integration/routes/ezcater_xls.clj new file mode 100644 index 00000000..0e4eecbf --- /dev/null +++ b/test/clj/auto_ap/integration/routes/ezcater_xls.clj @@ -0,0 +1,59 @@ +(ns auto-ap.integration.routes.ezcater-xls + (:require + [auto-ap.integration.util + :refer [setup-test-data test-client wrap-setup]] + [auto-ap.routes.ezcater-xls :as sut] + [clojure.java.io :as io] + [clojure.test :refer [deftest is testing use-fixtures]])) + +(use-fixtures :each wrap-setup) + +(deftest stream->sales-ordersx + (testing "Should import nothing when there are no clients" + (with-open [s (io/input-stream (io/resource "sample-ezcater.xlsx"))] + (is (= [:missing "Nick The Greek (Santa Cruz)"] (first (sut/stream->sales-orders s)))))) + (testing "should import for a single client" + (let [{:strs [test-client]} (setup-test-data [(test-client + :db/id "test-client" + :client/code "NGOP" + :client/locations ["DT"] + :client/name "The client" + :client/matches ["Nick the Greek (Elk Grove)"])])] + (with-open [s (io/input-stream (io/resource "sample-ezcater.xlsx"))] + (is (seq (sut/stream->sales-orders s))) + ) + (with-open [s (io/input-stream (io/resource "sample-ezcater.xlsx"))] + (is (= #:sales-order + {:vendor :vendor/ccp-ezcater + :service-charge -95.9 + :date #inst "2023-04-03T18:30:00" + :reference-link "ZA2-320" + :charges + [#:charge{:type-name "CARD" + :date #inst "2023-04-03T18:30:00" + :client test-client + :location "DT" + :external-id + "ezcater/charge/17592186045501-DT-ZA2-320-0" + :processor :ccp-processor/ezcater + :total 516.12 + :tip 0.0}] + :client test-client + :tip 0.0 + :tax 37.12 + :external-id "ezcater/order/17592186045501-DT-ZA2-320" + :total 516.12 + :line-items + [#:order-line-item{:external-id + "ezcater/order/17592186045501-DT-ZA2-320-0" + :item-name "EZCater Catering" + :category "EZCater Catering" + :discount 0.0 + :tax 37.12 + :total 516.12}] + :discount 0.0 + :location "DT" + :returns 0.0} + (last (first (filter (comp #{:order} first) + (sut/stream->sales-orders s)))))))))) + diff --git a/test/clj/auto_ap/routes/invoice_test.clj b/test/clj/auto_ap/integration/routes/invoice_test.clj similarity index 98% rename from test/clj/auto_ap/routes/invoice_test.clj rename to test/clj/auto_ap/integration/routes/invoice_test.clj index facbce6e..7b9c29b7 100644 --- a/test/clj/auto_ap/routes/invoice_test.clj +++ b/test/clj/auto_ap/integration/routes/invoice_test.clj @@ -1,4 +1,4 @@ -(ns auto-ap.routes.invoice-test +(ns auto-ap.integration.routes.invoice-test (:require [auto-ap.datomic :refer [conn]] [auto-ap.datomic.clients :refer [rebuild-search-index]]