Compare commits
75 Commits
integreat-
...
ab27d3a4da
| Author | SHA1 | Date | |
|---|---|---|---|
| ab27d3a4da | |||
| 19d936693a | |||
| e9970bd41a | |||
| 366781e818 | |||
| bcb1978f44 | |||
| 785c6b3731 | |||
| 33cfbab54a | |||
| e99ac6e978 | |||
| b43f107610 | |||
| 379bfa78f7 | |||
| 0575c88c4d | |||
| bc2abf4cb1 | |||
| acc5a7aa1b | |||
| 46fdc29712 | |||
| 0df9a29022 | |||
| a17e16e31b | |||
| e6507d5387 | |||
| 5126f3799d | |||
| 7a0e256f07 | |||
| 7155e199e8 | |||
| 2b5fbaca00 | |||
| 473556a45d | |||
| 604d1ee1cf | |||
| e095cb94e4 | |||
| 9d007ee8e2 | |||
| fa25620b7a | |||
| d012283362 | |||
| 14726d0208 | |||
| 6429cf823d | |||
| 111eca413e | |||
| 80e45c026e | |||
| 9cd3d9c962 | |||
| 5f5686c39c | |||
| d0846f91aa | |||
| 56addf8c70 | |||
| ebb48a0de4 | |||
| e2ccfc8d2c | |||
| e8cbd2760c | |||
| e0da8e1866 | |||
| 2e3c1e3646 | |||
| a7e9fbaf6b | |||
| 8a676718a7 | |||
| 3ffb661da3 | |||
| f9438ba983 | |||
| 7d34b8a5f6 | |||
| c09d85ede6 | |||
| ec4f88b7fc | |||
| 8ca5e75c4d | |||
| 4aed27b204 | |||
| d0028f403c | |||
| 6b4392b74b | |||
| cdc87d3710 | |||
| 1e3952a7fb | |||
| e099714af1 | |||
| 11024b7b89 | |||
| de2a1ab850 | |||
| fc54b92ddb | |||
| 019a1b4cd8 | |||
| 38575aa5bd | |||
| 85aaf7b759 | |||
| 3641846f70 | |||
| d360316590 | |||
| 8215e6376d | |||
| 3759258ebe | |||
| 0e02c489e0 | |||
| 917b7f3857 | |||
| a8d8a8d111 | |||
| 360847fa58 | |||
| 55650c2dab | |||
| 19186097d5 | |||
| 1f6395382d | |||
| d52159637e | |||
| 3648597031 | |||
| 901d9eb508 | |||
| 569e52d1c1 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -51,3 +51,6 @@ sysco-poller/**/*.csv
|
||||
.tmp/**
|
||||
playwright-report/**
|
||||
test-results/**
|
||||
# Scratch dir for temp files (screenshots, logs, etc.); keep the dir, ignore contents
|
||||
/tmp/*
|
||||
!/tmp/.gitkeep
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Integreat Development Guide
|
||||
|
||||
## Temporary Files
|
||||
|
||||
Write any temporary files (screenshots, scratch logs, generated artifacts, etc.) to the `./tmp/` directory at the repo root. Its contents are gitignored (only `.gitkeep` is tracked), so nothing there will be accidentally committed. Do not scatter temp files elsewhere in the repo or in the system `/tmp`.
|
||||
|
||||
## Build & Run Commands
|
||||
|
||||
### Build
|
||||
|
||||
@@ -173,4 +173,4 @@
|
||||
:token "EAAAEO2xSqesDutZz71hz3eulKmrlKTiEqG3uZ4j25x5GYlOluQ2cj2JxNUXqXD7"}}
|
||||
:plaid {:base-url "https://production.plaid.com"
|
||||
:client-id "61bfab05f7e762001b323f79"
|
||||
:secret-key "2be026ca5e7f7e9f23f2fb4d7c914d"}}
|
||||
:secret-key "44a05fbe9f33a2975b3b3ac06b0b62"}}
|
||||
|
||||
@@ -31,5 +31,5 @@
|
||||
:yodlee2-proxy-port 8888
|
||||
:plaid {:base-url "https://production.plaid.com"
|
||||
:client-id "61bfab05f7e762001b323f79"
|
||||
:secret-key "2be026ca5e7f7e9f23f2fb4d7c914d"}
|
||||
:secret-key "44a05fbe9f33a2975b3b3ac06b0b62"}
|
||||
}
|
||||
|
||||
@@ -34,5 +34,5 @@
|
||||
:yodlee2-proxy-port 8888
|
||||
:plaid {:base-url "https://production.plaid.com"
|
||||
:client-id "61bfab05f7e762001b323f79"
|
||||
:secret-key "2be026ca5e7f7e9f23f2fb4d7c914d"}
|
||||
:secret-key "44a05fbe9f33a2975b3b3ac06b0b62"}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
:scheme "https"
|
||||
:dd-env "prod"
|
||||
:dd-service "integreat-app"
|
||||
:jwt-secret "auto ap invoices are awesome"
|
||||
:jwt-secret "rotated secrets are the best"
|
||||
:invoice-import-queue-url "https://sqs.us-east-1.amazonaws.com/679918342773/integreat-mail-prod"
|
||||
:requests-queue-url "https://sqs.us-east-1.amazonaws.com/679918342773/integreat-background-request-prod"
|
||||
:invoice-email "invoices@mail.app.integreatconsult.com"
|
||||
@@ -25,12 +25,12 @@
|
||||
:run-background? false
|
||||
:run-web? true
|
||||
:yodlee2-integreat-user "integreat-main"
|
||||
:yodlee2-client-id "3AATcwfPsWP1rP9oDoo4HvZhtaroGVcA"
|
||||
:yodlee2-client-secret "cXTBmKbGfkaBFIpM"
|
||||
:yodlee2-client-id "lNgzSdjkVXSkXyOBlResXUsCpCpBBDlG"
|
||||
:yodlee2-client-secret "U3qjZP2gErZfbuTPud+LNJF9jHbNRzCWCZbEi6dDiHsziCNwI5yNNNrBAUsnjcu7VFsUVNmkxNKSW85Qf1YMOITC7q0kdv7MGv/ZqRLfQV5odkiPbLHgOrE7UE6//MtjU0jTznGA70WTPS+wwmugg8ArNnx+4QCHrrBrkRfFVOE="
|
||||
:yodlee2-base-url "https://production.api.yodlee.com/ysl"
|
||||
:yodlee2-fastlink "https://fl4.prod.yodlee.com/authenticate/USDevexProd2-319/fastlink/?channelAppName=usdevexprod2"
|
||||
:yodlee2-proxy-host "172.31.10.83"
|
||||
:yodlee2-proxy-port 8888
|
||||
:plaid {:base-url "https://production.plaid.com"
|
||||
:client-id "61bfab05f7e762001b323f79"
|
||||
:secret-key "2be026ca5e7f7e9f23f2fb4d7c914d"}}
|
||||
:client-id "61bfab05f7e762001b323f79"
|
||||
:secret-key "44a05fbe9f33a2975b3b3ac06b0b62"}}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
:scheme "https"
|
||||
:dd-env "prod"
|
||||
:dd-service "integreat-app"
|
||||
:jwt-secret "auto ap invoices are awesome"
|
||||
:jwt-secret "rotated secrets are the best"
|
||||
:invoice-import-queue-url "https://sqs.us-east-1.amazonaws.com/679918342773/integreat-mail-prod"
|
||||
:requests-queue-url "https://sqs.us-east-1.amazonaws.com/679918342773/integreat-background-request-prod"
|
||||
:invoice-email "invoices@mail.app.integreatconsult.com"
|
||||
@@ -25,13 +25,13 @@
|
||||
:run-background? false
|
||||
:run-web? true
|
||||
:yodlee2-integreat-user "integreat-main"
|
||||
:yodlee2-client-id "3AATcwfPsWP1rP9oDoo4HvZhtaroGVcA"
|
||||
:yodlee2-client-secret "cXTBmKbGfkaBFIpM"
|
||||
:yodlee2-client-id "lNgzSdjkVXSkXyOBlResXUsCpCpBBDlG"
|
||||
:yodlee2-client-secret "U3qjZP2gErZfbuTPud+LNJF9jHbNRzCWCZbEi6dDiHsziCNwI5yNNNrBAUsnjcu7VFsUVNmkxNKSW85Qf1YMOITC7q0kdv7MGv/ZqRLfQV5odkiPbLHgOrE7UE6//MtjU0jTznGA70WTPS+wwmugg8ArNnx+4QCHrrBrkRfFVOE="
|
||||
:yodlee2-base-url "https://production.api.yodlee.com/ysl"
|
||||
:yodlee2-fastlink "https://fl4.prod.yodlee.com/authenticate/USDevexProd2-319/fastlink/?channelAppName=usdevexprod2"
|
||||
:yodlee2-proxy-host "172.31.10.83"
|
||||
:yodlee2-proxy-port 8888
|
||||
:plaid {:base-url "https://production.plaid.com"
|
||||
:client-id "61bfab05f7e762001b323f79"
|
||||
:secret-key "2be026ca5e7f7e9f23f2fb4d7c914d"}
|
||||
:secret-key "44a05fbe9f33a2975b3b3ac06b0b62"}
|
||||
}
|
||||
|
||||
BIN
dev-resources/Bonanza Sample Reno 2.pdf
Executable file
BIN
dev-resources/Bonanza Sample Reno 2.pdf
Executable file
Binary file not shown.
BIN
dev-resources/Bonanza Sample Reno.pdf
Executable file
BIN
dev-resources/Bonanza Sample Reno.pdf
Executable file
Binary file not shown.
BIN
dev-resources/Statement1_from_REEL_Produce_Inc.28676.pdf
Normal file
BIN
dev-resources/Statement1_from_REEL_Produce_Inc.28676.pdf
Normal file
Binary file not shown.
@@ -0,0 +1,777 @@
|
||||
# SSR Form & Wizard Simplification — Migration Plan
|
||||
|
||||
> **Status:** Planning / for execution by an agent or engineer.
|
||||
> **Owner:** Bryce
|
||||
> **Type:** Refactor (no user-facing behavior change; parity required).
|
||||
|
||||
This plan describes a series of low-risk migrations that make the server-side
|
||||
rendered (SSR) forms and wizards substantially simpler. It is self-contained:
|
||||
every concept needed to execute is stated here, illustrated with code snippets.
|
||||
The work is sequenced so each migration is small, reversible, and *teaches a
|
||||
skill* that makes the next migration cheaper.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals
|
||||
|
||||
1. **Render forms by re-rendering the whole form** (or a precise, isolated
|
||||
fragment) over HTMX, using hx-select to choose elements, instead of mutating
|
||||
the DOM in place. This removes the class of bugs around stale state, lost
|
||||
focus/caret, and out-of-band patching.
|
||||
2. **Root cursors at the top; never fake their position.** Cursors are fine and
|
||||
stay — a render function may take an explicit data map *or* a cursor. What we
|
||||
remove is the practice of **faking a cursor to start deeper** in the tree to
|
||||
satisfy a partial render, and the duplicate `*-no-cursor*` variants that
|
||||
fakery forces. The target: a cursor always begins at the top level of what the
|
||||
form consumes and walks down naturally from there. (Because the whole form is
|
||||
re-rendered each time, there is no longer any reason to fake a deep starting
|
||||
position.)
|
||||
3. **Stop forcing single-step forms through wizard machinery.** Most "wizards"
|
||||
are single-step; they become plain forms. Genuine multi-step flows use a
|
||||
small data-driven engine instead of protocols + middleware stacking, and
|
||||
**store each step's data in the session** (combined only at the end) instead
|
||||
of round-tripping and merging an EDN snapshot — the Django `formtools` model.
|
||||
4. **Render HTML with Selmer templates** (Jinja-style) instead of Hiccup for the
|
||||
interactive, attribute-heavy components, so Alpine/HTMX attributes are
|
||||
first-class HTML rather than a mix of Clojure keywords and strings.
|
||||
5. **Capture the migration method in a skill** that is created after the first
|
||||
successful migration and extended by every migration thereafter.
|
||||
|
||||
Net effect target: large reduction in lines of code, route count, and branching
|
||||
complexity, with measurably more reuse across similar forms.
|
||||
|
||||
---
|
||||
|
||||
## 2. Why — the current pain (rationale)
|
||||
|
||||
### 2.1 In-place DOM mutation is fragile
|
||||
Re-rendering only fragments and patching the rest (via morph or out-of-band
|
||||
swaps) means the server and the DOM can disagree. Keeping a focused input alive
|
||||
through a patch requires keying tricks and guards. Re-rendering the **whole
|
||||
form** and letting the typed value ride along in the form is simpler and
|
||||
correct, *provided the input the user is typing in is never inside the region
|
||||
being swapped*.
|
||||
|
||||
### 2.2 Faking cursor positions forces duplicate functions
|
||||
A "form cursor" itself is fine. The pain comes from **faking the cursor's
|
||||
starting position** — rebinding the dynamic root deeper in the tree so a deeply
|
||||
nested render function can run against a fragment. That fakery is fragile and
|
||||
hard to follow, and it has spawned duplicate render functions: one that reads the
|
||||
faked cursor and one that takes plain params for the cases where the fake can't
|
||||
be set up.
|
||||
|
||||
```clojure
|
||||
;; SMELL: this render fn assumes the cursor was faked to start deep at an account,
|
||||
;; so it only works when *current*/*prefix* were rebound to point there first.
|
||||
(defn account-row* [{:keys [value client-id]}]
|
||||
(com/data-grid-row
|
||||
(fc/with-field :transaction-account/account
|
||||
(com/data-grid-cell
|
||||
(account-typeahead* {:value (fc/field-value) :name (fc/field-name)})))
|
||||
...))
|
||||
|
||||
;; SMELL: a second copy of the same markup, just to avoid the faked-deep cursor
|
||||
(defn account-row-no-cursor* [{:keys [account index client-id]}]
|
||||
...)
|
||||
```
|
||||
|
||||
**Target:** the cursor starts at the top of the form's data and walks down
|
||||
naturally; a row render either takes explicit row data or receives a cursor the
|
||||
caller advanced step-by-step from the root — never one teleported to a deep node.
|
||||
|
||||
### 2.3 Single-step forms wear wizard costumes
|
||||
Several forms implement a multi-step wizard protocol (5 protocols, 15+ methods),
|
||||
serialize an EDN snapshot with custom readers into hidden fields, and register
|
||||
10–20 routes with stacked middleware — all for a single-step form. That is pure
|
||||
overhead.
|
||||
|
||||
### 2.4 Multi-step wizards round-trip and merge a snapshot
|
||||
The genuine multi-step wizards carry the whole accumulating form state as an EDN
|
||||
snapshot in hidden fields, then rebuild it each request by merging the posted
|
||||
pieces back into the snapshot. The serialization needs custom readers, the merge
|
||||
logic is error-prone, and the page payload grows with every step. The fix is to
|
||||
**store each step's data in the session under its own key and combine only at the
|
||||
end** — the Django `formtools` model (§3.3) — so no snapshot is built or merged.
|
||||
|
||||
### 2.5 Hiccup makes Alpine/HTMX attributes ambiguous
|
||||
The same attribute is sometimes a keyword and sometimes a string in the same
|
||||
file, and event handlers must be strings while structural Alpine attrs are
|
||||
keywords. There is no rule a reader (or an LLM) can rely on:
|
||||
|
||||
```clojure
|
||||
;; Both of these appear in one component file today:
|
||||
:x-ref "input" ; keyword key
|
||||
"x-ref" "hidden" ; string key
|
||||
:x-model "value.value"
|
||||
"x-model" "search"
|
||||
"@keydown.down.prevent.stop" "tippy.show();" ; handlers must be strings
|
||||
:x-init "..." ; structural attrs are keywords
|
||||
```
|
||||
|
||||
In a Selmer template the same markup is unambiguous plain HTML:
|
||||
|
||||
```html
|
||||
<input x-ref="input" x-model="value.value"
|
||||
@keydown.down.prevent.stop="tippy?.show()" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Target state (the patterns, with snippets)
|
||||
|
||||
These four patterns are what every migration moves code *toward*. The skill
|
||||
(§5) holds the canonical, growing version of each.
|
||||
|
||||
### 3.1 Whole-form HTMX swap doctrine
|
||||
|
||||
Decide per interactive control, in this priority order:
|
||||
|
||||
1. **No request** when the field affects nothing else. Its value rides along in
|
||||
the form and is read on submit.
|
||||
```html
|
||||
<!-- a memo / free-text field that influences nothing -->
|
||||
<input name="memo" /> <!-- no hx-* at all -->
|
||||
```
|
||||
2. **Targeted swap of a single isolated cell** when a field's effect is purely
|
||||
local. Give the cell a stable id and keep it out of the typed input's subtree.
|
||||
```html
|
||||
<!-- selecting an account only changes the valid Location options -->
|
||||
<select name="accounts[0][account]"
|
||||
hx-post="/transaction/edit-form-changed"
|
||||
hx-target="#account-location-0"
|
||||
hx-select="#account-location-0"
|
||||
hx-swap="outerHTML" hx-trigger="changed">
|
||||
</select>
|
||||
<div id="account-location-0"> ...location options... </div>
|
||||
```
|
||||
3. **Whole-form swap** when the change touches interdependent state (vendor,
|
||||
add/remove row, mode toggle, $/% radio). The form's hidden state rides along,
|
||||
so one swap keeps everything consistent — **no out-of-band swaps**.
|
||||
```html
|
||||
<form id="wizard-form"
|
||||
hx-post="/transaction/edit-form-changed"
|
||||
hx-target="#wizard-form" hx-select="#wizard-form" hx-swap="outerHTML">
|
||||
...
|
||||
</form>
|
||||
```
|
||||
4. **Out-of-band (OOB) swap only for genuinely disjoint DOM regions** — a global
|
||||
flash/toast, a nav badge, a modal mounted at the document root. If you are
|
||||
tempted to OOB something *inside the same feature*, that is a signal to
|
||||
**restructure the DOM so the dependent element shares a common ancestor** with
|
||||
the trigger, and use an ordinary swap. Example: put running totals in a
|
||||
sibling `<tbody>` so an amount edit can swap totals without replacing the
|
||||
amount input:
|
||||
```clojure
|
||||
;; totals live in their own tbody, a sibling of the input rows
|
||||
(com/data-grid- {:rows ...
|
||||
:footer-tbody [:tbody {:id "account-totals"} ...]})
|
||||
|
||||
;; the amount input swaps ONLY the totals tbody (never itself)
|
||||
[:input {:name "accounts[0][amount]"
|
||||
:hx-post "/transaction/edit-form-changed"
|
||||
:hx-target "#account-totals" :hx-select "#account-totals"
|
||||
:hx-swap "outerHTML" :hx-trigger "keyup changed delay:300ms"}]
|
||||
```
|
||||
|
||||
**Focus invariant (must always hold):** the input the user is typing in is never
|
||||
inside the region its own request swaps.
|
||||
|
||||
**Alpine components must survive swaps.** Null-guard every reference that depends
|
||||
on Alpine/tippy being initialised, and key a component by its server-provided
|
||||
value so a server-driven change re-initialises it instead of preserving stale
|
||||
state:
|
||||
```clojure
|
||||
;; null-guard:
|
||||
"@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); ..."
|
||||
;; key by current value so morph/replace re-inits on server change:
|
||||
(assoc attrs :key (str id "--" current-value))
|
||||
```
|
||||
|
||||
**Selector strategy for targeted swaps (a consideration, not a mandate).**
|
||||
Rules 2 and 4 above need a stable `hx-target`/`hx-select`. The obvious approach
|
||||
— a unique `id` on every swappable element — gets noisy in repeated structures
|
||||
(e.g. a table of financial accounts where choosing an account must swap *that
|
||||
row's* dropdown). When you reach those advanced cases, consider a more
|
||||
consistent scheme instead of hand-minting ids everywhere:
|
||||
|
||||
- **Semantic markup + data-attributes** to craft a fine-grained selector without
|
||||
per-element ids. For example, mark rows/cells with their identity and target
|
||||
by attribute:
|
||||
```html
|
||||
<tr data-row="account" data-index="0">
|
||||
<td data-cell="account">
|
||||
<select hx-post="/transaction/edit-form-changed"
|
||||
hx-target="[data-row='account'][data-index='0'] [data-cell='location']"
|
||||
hx-select="[data-row='account'][data-index='0'] [data-cell='location']"
|
||||
hx-swap="outerHTML" hx-trigger="changed">…</select>
|
||||
</td>
|
||||
<td data-cell="location">…</td>
|
||||
</tr>
|
||||
```
|
||||
- **A `form-path -> id` (or `-> selector`) function**, derived the same way a
|
||||
cursor path is, so the server and the markup agree on the target by
|
||||
construction rather than by convention. A render fn at form-path
|
||||
`[:accounts 0 :location]` would compute its own stable selector (id or
|
||||
data-attribute query) from that path, mirroring §3.2's top-rooted cursor.
|
||||
|
||||
The aim is *consistency and predictability* of swap targets in repeated/nested
|
||||
structures — pick whichever keeps targets unambiguous and easy to generate. Note
|
||||
this in `reference/swap-doctrine.md` and let the first modal that hits nested
|
||||
repeated swaps (Phase 5 / the wizards) settle on a convention for the cookbook.
|
||||
|
||||
### 3.2 Render functions: explicit data, or a top-rooted cursor
|
||||
|
||||
One function, data in, markup out. The data can arrive as a plain map or via a
|
||||
cursor — **as long as the cursor was rooted at the top of the form and walked
|
||||
down to here**, never faked to start at this depth.
|
||||
|
||||
```clojure
|
||||
;; GOOD: pure, works everywhere, testable without setup
|
||||
(defn account-row [{:keys [account index client-id amount-mode]}]
|
||||
(com/data-grid-row
|
||||
(com/hidden {:name (str "accounts[" index "][db/id]")
|
||||
:value (or (:db/id account) "")})
|
||||
(com/data-grid-cell
|
||||
(account-typeahead* {:value (:transaction-account/account account)
|
||||
:name (str "accounts[" index "][account]")
|
||||
:client-id client-id}))
|
||||
...))
|
||||
```
|
||||
|
||||
```clojure
|
||||
;; ALSO FINE: a cursor that started at the form root and was advanced naturally.
|
||||
;; The top-level render walks the cursor; the row fn receives the dereferenced
|
||||
;; row (or the advanced cursor) — no rebinding of *current*/*prefix* to fake depth.
|
||||
(defn account-rows [accounts-cursor]
|
||||
(for [row-cursor (fc/each accounts-cursor)] ; advanced from the root, not faked
|
||||
(account-row {:account @row-cursor :index (fc/index row-cursor) ...})))
|
||||
```
|
||||
|
||||
The rule is about *where the cursor starts*, not whether you use one. If a caller
|
||||
already holds a top-rooted cursor, advance it and hand the row data (or the
|
||||
advanced cursor) to one render function. Never rebind the cursor to teleport to a
|
||||
deep node, and never keep a second `*-no-cursor*` copy of the markup.
|
||||
|
||||
### 3.3 Forms vs. wizards (and the data-driven wizard engine)
|
||||
|
||||
- **Single-step → plain form.** Two routes: `GET` (render) and `POST` (validate
|
||||
+ save). State is plain form fields + an entity id. No snapshot, no server
|
||||
state, no protocol.
|
||||
```clojure
|
||||
{::route/edit (fn [req] (html-response (render-edit-form {:entity (get-entity req)})))
|
||||
::route/edit-submit (fn [req] (validate-and-save req))}
|
||||
```
|
||||
|
||||
- **Genuinely multi-step → data-driven engine with session-stored step state.**
|
||||
|
||||
> **Inspiration — Django `formtools` `WizardView`.** Django's wizard does *not*
|
||||
> round-trip a serialized blob of the whole form through the page. Each step's
|
||||
> validated (cleaned) data is written to a **storage backend (the user session
|
||||
> by default)** under that step's key, and the steps are combined only at the
|
||||
> very end via `get_all_cleaned_data()`. We adopt the same model: **replace the
|
||||
> EDN snapshot + piecewise merging with per-step form state stored in the
|
||||
> session.** A step writes its own data under its own key; nothing is merged
|
||||
> into a snapshot and nothing about other steps rides through the form.
|
||||
> Refs: `formtools.wizard.views.WizardView`, its `storage` backends
|
||||
> (`SessionStorage`), and `get_all_cleaned_data()`
|
||||
> (https://django-formtools.readthedocs.io/en/latest/wizard.html).
|
||||
|
||||
A wizard is *data*:
|
||||
```clojure
|
||||
(def vendor-wizard-config
|
||||
{:steps [{:key :info :schema info-schema :fields [...] :render render-info-step
|
||||
:next (fn [data] :terms)}
|
||||
{:key :terms :schema terms-schema :fields [...] :render render-terms-step
|
||||
:next (fn [data] :done)}]
|
||||
:init-fn (fn [req] {...})
|
||||
:submit-route "/admin/vendor/wizard/submit"
|
||||
:done-fn (fn [all-data req] (save! all-data) (html-response "Saved"))})
|
||||
```
|
||||
with a tiny engine (no protocols) whose state lives **in the session**, keyed
|
||||
by a wizard instance id, with each step's data stored under its own step key —
|
||||
the formtools `SessionStorage` model. No snapshot, no custom EDN readers, no
|
||||
merge-into-snapshot:
|
||||
```clojure
|
||||
;; Storage backed by the Ring session (replaces the hidden EDN snapshot).
|
||||
;; Path in session: [:wizards <wizard-id> :step-data <step-key>]
|
||||
(defn create-wizard! [session config]
|
||||
(let [id (str (java.util.UUID/randomUUID))]
|
||||
[id (assoc-in session [:wizards id]
|
||||
{:current-step (-> config :steps first :key) :step-data {}})]))
|
||||
|
||||
(defn put-step [session id k data] (assoc-in session [:wizards id :step-data k] data)) ; replace, not merge
|
||||
(defn set-step [session id k] (assoc-in session [:wizards id :current-step] k))
|
||||
(defn get-all [session id] (->> (get-in session [:wizards id :step-data]) vals (apply merge)))
|
||||
(defn forget [session id] (update session :wizards dissoc id))
|
||||
|
||||
(defn render-wizard [{:keys [wizard-id config session request]}]
|
||||
(let [{:keys [current-step step-data]} (get-in session [:wizards wizard-id])
|
||||
step (first (filter #(= (:key %) current-step) (:steps config)))]
|
||||
[:form#wizard-form {:hx-post (:submit-route config)
|
||||
:hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML"}
|
||||
;; only a reference token rides in the form -- not the form's state
|
||||
(com/hidden {:name "wizard-id" :value wizard-id})
|
||||
(com/hidden {:name "current-step" :value (name current-step)})
|
||||
((:render step) (assoc request :step-data (get step-data current-step {})))]))
|
||||
|
||||
;; Handlers thread the (possibly updated) session back into the Ring response.
|
||||
(defn handle-step-submit [config {:keys [session] :as request}]
|
||||
(let [{:strs [wizard-id current-step]} (:form-params request)
|
||||
step (first (filter #(= (:key %) (keyword current-step)) (:steps config)))
|
||||
data (select-keys (:form-params request) (map name (:fields step)))]
|
||||
(if-let [errors (mc/explain (:schema step) data)]
|
||||
(-> (render-wizard {:wizard-id wizard-id :config config :session session
|
||||
:request (assoc request :errors errors)})
|
||||
html-response)
|
||||
(let [session' (put-step session wizard-id (keyword current-step) data)
|
||||
nxt ((:next step) data)]
|
||||
(if (= nxt :done)
|
||||
(-> ((:done-fn config) (get-all session' wizard-id) request) ; combine only at the end
|
||||
(assoc :session (forget session' wizard-id)))
|
||||
(let [session'' (set-step session' wizard-id nxt)]
|
||||
(-> (html-response (render-wizard {:wizard-id wizard-id :config config
|
||||
:session session'' :request request}))
|
||||
(assoc :session session''))))))))
|
||||
```
|
||||
Two routes per wizard: open (`partial open-wizard config`) and submit
|
||||
(`partial handle-step-submit config`). State is namespaced by `wizard-id` inside
|
||||
the session, so multiple in-flight wizards (and tabs) don't collide, and it is
|
||||
discarded on completion (`forget`). See Open decision 1 for the storage-backend
|
||||
choice (Ring session store vs. a durable store for long-lived wizards).
|
||||
|
||||
### 3.4 Selmer templates
|
||||
|
||||
Interactive components render from Selmer templates with plain-HTML attributes.
|
||||
Selmer composes via `{% include %}` and `{% block %}`; an interop bridge lets a
|
||||
Selmer template embed Hiccup output (and vice versa) during the transition.
|
||||
|
||||
```html
|
||||
{# templates/components/typeahead.html #}
|
||||
<div class="relative" x-data="{{ x_data|safe }}" x-model="{{ x_model }}">
|
||||
<a class="{{ classes }}" x-ref="input" tabindex="0"
|
||||
@keydown.down.prevent.stop="tippy?.show()"
|
||||
@keydown.backspace="tippy?.hide(); value = {value:'', label:''}">
|
||||
<span x-text="value.label"></span>
|
||||
</a>
|
||||
...
|
||||
</div>
|
||||
```
|
||||
|
||||
```clojure
|
||||
;; render helper + interop bridge
|
||||
(defn render [tpl ctx] (selmer/render-file tpl ctx))
|
||||
(defn hiccup->html [h] (hiccup/html h)) ; embed hiccup inside selmer via {{ frag|safe }}
|
||||
;; selmer fragment inside hiccup: [:div (hiccup/raw (render "..." ctx))]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Principles
|
||||
|
||||
1. **Strangler, not big-bang.** New engine, Selmer renderer, and the swap
|
||||
doctrine live alongside the old code. Migrate one modal at a time behind its
|
||||
own route. Old machinery is deleted only when its last caller is gone.
|
||||
2. **Simplest first.** Each migration is small and reversible (one commit).
|
||||
Start with the already-proven modal, then the smallest fresh ones, and leave
|
||||
the largest/most complex for last — by which point the skill is mature.
|
||||
3. **Skill-driven and self-reinforcing.** After the first successful migration,
|
||||
distil the method into a skill (§5). Every subsequent migration *reads* the
|
||||
skill first and *extends* it last.
|
||||
4. **Quality must measurably improve.** Each migration records a scorecard (§6);
|
||||
no metric may regress for the touched modal.
|
||||
5. **Behavior parity is proven by tests, not by reading** (§7). The full e2e
|
||||
suite must stay green after every migration.
|
||||
|
||||
---
|
||||
|
||||
## 5. The skill: `ssr-form-migration`
|
||||
|
||||
**When it is created:** in **Phase 1**, immediately after — and distilled from —
|
||||
the first successful modal migration (the transaction-edit modal, whose
|
||||
whole-form swap implementation already exists and serves as the reference). The
|
||||
skill is *not* written speculatively; it encodes a method that already worked.
|
||||
|
||||
**Where:** `.claude/skills/ssr-form-migration/` (matches the existing project
|
||||
convention, e.g. `.claude/skills/testing-conventions/SKILL.md`).
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
.claude/skills/ssr-form-migration/
|
||||
SKILL.md # the playbook (§8): classify → migrate → verify → record
|
||||
reference/
|
||||
swap-doctrine.md # §3.1 rules, focus invariant, OOB-vs-hoist, Alpine hardening,
|
||||
# target-selector strategy (semantic/data-attr/form-path->id)
|
||||
render-functions.md # §3.2 explicit-data or top-rooted cursor; no faked positions
|
||||
form-vs-wizard.md # §3.3 classification + the data-driven engine
|
||||
selmer-conventions.md # §3.4 attr style, interop bridge, include/block patterns
|
||||
component-cookbook.md # GROWS: typeahead, account-row, totals, money-input, mode-toggle…
|
||||
gotchas.md # GROWS: stale $refs, key-by-value, wizard-id GC, coercion…
|
||||
test-recipes.md # GROWS: how to e2e a swap; assert a Selmer render; fixture a wizard-id
|
||||
scorecard.md # the §6 heuristics + a running table of every migration's numbers
|
||||
```
|
||||
|
||||
**Growth contract — the last task of every migration:**
|
||||
- Converted a component? → add its before/after to `component-cookbook.md`.
|
||||
- Hit a surprise? → one entry in `gotchas.md`.
|
||||
- Found a test pattern? → `test-recipes.md`.
|
||||
- Playbook step missing/wrong? → fix `SKILL.md`.
|
||||
- Measured the scorecard? → append the row to `scorecard.md`.
|
||||
|
||||
**Success signal:** each migration should reuse more cookbook entries and start
|
||||
from a better scorecard baseline than the previous one. If migration N+1 is not
|
||||
easier than N, the skill-update step is being skipped — treat that as a bug.
|
||||
|
||||
---
|
||||
|
||||
## 6. Quality scorecard (the ratchet)
|
||||
|
||||
Cheap to measure (`grep -c`, `wc -l`, `clj-kondo`), recorded before/after each
|
||||
migration in the commit message and `scorecard.md`. **No metric may regress for
|
||||
the touched modal.**
|
||||
|
||||
| # | Heuristic | Measure | Target |
|
||||
|---|-----------|---------|--------|
|
||||
| 1 | Faked cursor positions (not cursors themselves) | count cursor-root rebinds (`binding` of `*current*`/`*prefix*`/`*form-data*`, or `with-field`/`with-*` used to *re-root* deeper) + `grep -c '\-no-cursor'` | → 0 (top-rooted cursors are fine) |
|
||||
| 2 | Implicit state merges (snapshot/cursor) | count merge sites | → 0 (forms); explicit `update-step!` only (wizards) |
|
||||
| 3 | Branching complexity | `clj-kondo`, or count `cond`/`condp`/`case`/nested `if` + max depth | net ↓ |
|
||||
| 4 | Lines of code | `wc -l` on the modal's file(s) | net ↓ |
|
||||
| 5 | Reuse / cross-form similarity | cookbook components reused; duplicated-block count | reuse ↑, dup ↓ |
|
||||
| 6 | Route count | count routes for the modal | → 2 (+1 for add-row) |
|
||||
| 7 | OOB swaps | `grep -c hx-swap-oob` | → 0 unless a justified disjoint-region case is documented |
|
||||
| 8 | Attribute consistency | mixed `:x-`/`"x-"` encodings in migrated template | → 0 |
|
||||
|
||||
These are directional evidence, not targets to game. Pair them with the e2e
|
||||
parity gate (§7) so "simpler" can never mean "broken."
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing strategy
|
||||
|
||||
Consistent with the project's `testing-conventions` skill (test user-observable
|
||||
behavior; assert DB state directly; don't test the means).
|
||||
|
||||
1. **Characterization e2e first.** Before changing a modal, write/confirm a
|
||||
Playwright spec capturing its current behavior — focus/caret survival across
|
||||
swaps, the field round-trip, validation errors, and the actual save. This
|
||||
spec is the parity contract the refactor must keep green.
|
||||
2. **Pure-function checks via REPL.** Once render fns are pure, exercise the
|
||||
data-prep functions with `clojure-eval` / `clj-nrepl-eval`. Assert on returned
|
||||
data; for markup use string matches (`(re-find #"accounts\[0\]\[account\]" (str html))`)
|
||||
— this style survives the Selmer switch. Avoid brittle structural assertions.
|
||||
3. **DB-state assertions for mutations.** If a submit writes Datomic, verify by
|
||||
querying the DB, not by asserting on markup.
|
||||
|
||||
**Regression gate:** the full e2e suite must stay green after every migration.
|
||||
Record the current pass/fail baseline in `test-recipes.md` at the first
|
||||
migration and never drop below it.
|
||||
|
||||
---
|
||||
|
||||
## 8. Per-migration playbook (the repeatable loop)
|
||||
|
||||
This is the canonical loop each modal phase follows; it lives in `SKILL.md`.
|
||||
Modal phases below list only what is *specific* to that modal plus this loop.
|
||||
|
||||
1. [ ] **Read the skill.** Note applicable cookbook entries and gotchas.
|
||||
2. [ ] **Classify.** Single-step → plain form (no server state). Multi-step →
|
||||
wizard (engine + server state). When in doubt, it's a form.
|
||||
3. [ ] **Baseline the scorecard (§6).** Record before-numbers.
|
||||
4. [ ] **Characterize behavior (test-first).** Write/confirm the e2e spec.
|
||||
5. [ ] **Consolidate render functions** so they take explicit data or a
|
||||
top-rooted cursor — remove faked cursor positions and `*-no-cursor*`
|
||||
duplicates (heuristics 1, 2). Using a cursor is fine; faking its start is not.
|
||||
6. [ ] **Templatize in Selmer**; reuse cookbook bits, add new ones back
|
||||
(heuristics 5, 8).
|
||||
7. [ ] **Wire HTMX per the swap doctrine** (§3.1); focus invariant intact; OOB
|
||||
only for disjoint regions (heuristic 7).
|
||||
8. [ ] **Collapse routes** to 2 (+1 for add-row) (heuristic 6).
|
||||
9. [ ] **Verify:** modal e2e + full suite green; assert DB mutations; REPL-check
|
||||
pure fns. Re-measure scorecard — no regressions.
|
||||
10. [ ] **Commit** one reversible feature commit; message includes the scorecard
|
||||
delta and reused/new cookbook entries.
|
||||
11. [ ] **Feed the skill** (cookbook / gotchas / test-recipes / scorecard /
|
||||
SKILL.md). *Not optional.*
|
||||
|
||||
---
|
||||
|
||||
## 9. Phases & tasks
|
||||
|
||||
> Migration target inventory (verify line counts at execution time):
|
||||
|
||||
| Modal | File | Steps | Target | Phase |
|
||||
|-------|------|-------|--------|-------|
|
||||
| Transaction Edit | `transaction/edit.clj` | 1 (mode toggle) | form | 2 (skill trial) |
|
||||
| Transaction Bulk Code | `transaction/bulk_code.clj` | 1 | form | 3 |
|
||||
| Sales Summary Edit | `pos/sales_summaries.clj` | 1 | form | 4 |
|
||||
| Invoice Bulk Edit | `invoices.clj` | 1 | form | 5 |
|
||||
| Transaction Rule | `admin/transaction_rules.clj` | 2 | wizard | 6 |
|
||||
| Invoice Pay | `invoices.clj` | 2 | wizard | 7 |
|
||||
| New Invoice | `invoice/new_invoice_wizard.clj` | 3 | wizard | 8 |
|
||||
| Vendor | `admin/vendors.clj` | 5 | wizard | 9 |
|
||||
| Client | `admin/clients.clj` | 7 | wizard | 10 |
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 — Distil the skill (no app code changes)
|
||||
|
||||
**Rationale:** the transaction-edit modal has already been migrated to the
|
||||
whole-form swap approach successfully. Capture that working method as a skill
|
||||
*now*, so every later migration is cheaper and consistent. (If the reference
|
||||
implementation is not yet on the working branch, merge it first — that is an
|
||||
acceptable prerequisite.)
|
||||
|
||||
- [ ] Create `.claude/skills/ssr-form-migration/SKILL.md` with the playbook (§8).
|
||||
- [ ] Write `reference/swap-doctrine.md` from §3.1 (the four rules, focus
|
||||
invariant, OOB-vs-hoist, Alpine hardening), using the real transaction-edit
|
||||
swaps as worked examples.
|
||||
- [ ] Write `reference/render-functions.md` from §3.2 (explicit data or a
|
||||
top-rooted cursor; remove faked positions and `*-no-cursor*` duplicates).
|
||||
- [ ] Write `reference/form-vs-wizard.md` from §3.3 (classification + engine).
|
||||
- [ ] Stub `reference/selmer-conventions.md` from §3.4, marked "validated in
|
||||
Phase 2."
|
||||
- [ ] Seed `component-cookbook.md` with whatever transaction-edit already proved
|
||||
(e.g. the hardened typeahead, the totals-in-sibling-`<tbody>` pattern).
|
||||
- [ ] Seed `gotchas.md` (stale `$refs`, key-by-value).
|
||||
- [ ] Seed `test-recipes.md`; record the **current full e2e pass/fail baseline**.
|
||||
- [ ] Create `scorecard.md` with the §6 table and an empty results table.
|
||||
- [ ] **Exit criteria:** an agent can read `SKILL.md` and the references and
|
||||
understand the whole method without this plan.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Trial the skill on Transaction Edit (first test subject)
|
||||
|
||||
**Rationale:** validate the freshly written skill against the one modal whose
|
||||
"correct" outcome we already know. This is also where Selmer + pure functions
|
||||
are completed for this modal and the Selmer conventions get written from a real,
|
||||
verified example. Target type: **plain form** (single step with a mode toggle —
|
||||
the toggle is just a `GET` with a `?mode=` query param that re-renders the form).
|
||||
|
||||
**Foundation (do once, here):**
|
||||
- [ ] Add the `selmer` dependency to `project.clj`.
|
||||
- [ ] Build the render helper (`selmer/render-file`) and the **interop bridge**
|
||||
(Hiccup→string for embedding in Selmer, and Selmer fragment inside Hiccup).
|
||||
- [ ] Prove interop: a throwaway Selmer page renders inside the existing layout,
|
||||
and a Hiccup component renders inside a Selmer template.
|
||||
|
||||
**Modal migration (run the §8 loop), specifics:**
|
||||
- [ ] Confirm/author the characterization e2e spec covering: typing in memo keeps
|
||||
focus; selecting an account updates only its Location options; changing vendor
|
||||
/ adding / removing a row / toggling mode / toggling $-vs-% re-renders the
|
||||
whole form correctly; amount edits update totals without losing the amount
|
||||
caret; save round-trips.
|
||||
- [ ] Extract pure render fns: `render-simple-fields`, `render-advanced-fields`,
|
||||
`account-row`, `account-totals` (remove any `*-no-cursor*` duplicates).
|
||||
- [ ] Convert those render fns to Selmer templates; record each as a cookbook
|
||||
entry; finalize `selmer-conventions.md`.
|
||||
- [ ] Verify the swaps match the doctrine (whole-form for structural changes,
|
||||
targeted cell for account→location, sibling-`<tbody>` for totals, no request
|
||||
for memo); confirm `grep -c hx-swap-oob` is 0.
|
||||
- [ ] Collapse routes: `GET /transaction/edit` (with `?mode=`), `POST
|
||||
/transaction/edit`, plus the single `edit-form-changed` re-render endpoint.
|
||||
- [ ] Verify (modal e2e + full suite green; DB save asserted).
|
||||
- [ ] **Feed the skill:** refine `SKILL.md` and references from anything the
|
||||
trial revealed; append the scorecard row (this is the baseline others beat).
|
||||
- [ ] **Exit criteria:** skill-driven migration reproduces the known-good
|
||||
behavior; Selmer conventions are validated; cookbook has ≥3 reusable entries.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Transaction Bulk Code (plain form)
|
||||
|
||||
**Rationale:** the smallest *fresh* modal — first real test of "read the skill,
|
||||
apply it cold." Single-step form currently wearing a wizard costume.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Classify as plain form; delete the wizard protocol/record and snapshot.
|
||||
- [ ] Extract `render-bulk-code-fields`; reuse cookbook typeahead/money-input.
|
||||
- [ ] Search params preserved as plain hidden fields (no EDN snapshot).
|
||||
- [ ] Collapse 4 wizard routes → 2 (`GET` open, `POST` submit).
|
||||
- [ ] Verify bulk-code applies correctly (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
- [ ] **Exit criteria:** ≥2 cookbook entries reused; LOC, route-count, and
|
||||
faked-cursor count all down vs. baseline.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Sales Summary Edit (plain form)
|
||||
|
||||
**Rationale:** another single-step form; reinforces the cold-apply loop.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Classify as plain form; remove wizard record + `wrap-init-multi-form-state`.
|
||||
- [ ] Extract `render-sales-summary-fields` (pure); reuse cookbook entries.
|
||||
- [ ] Collapse 3 wizard routes → 2.
|
||||
- [ ] Verify edit saves (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 — Invoice Bulk Edit (plain form with rows + totals)
|
||||
|
||||
**Rationale:** first single-step form with dynamic account rows and live totals
|
||||
— exercises the add-row endpoint and the totals-in-sibling-`<tbody>` swap
|
||||
(instead of OOB).
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Extract `bulk-edit-account-row` (pure); reuse the `account-row`/`totals`
|
||||
cookbook entries from Phase 2.
|
||||
- [ ] Add-row: a `POST` that appends a fresh row; totals re-render via the
|
||||
sibling-`<tbody>` swap, **not** OOB.
|
||||
- [ ] **Settle a target-selector convention** for repeated/nested rows (§3.1
|
||||
"Selector strategy"): semantic data-attributes and/or a `form-path -> selector`
|
||||
helper, rather than hand-minted ids per element. Record the chosen convention
|
||||
in `reference/swap-doctrine.md` + `component-cookbook.md` so later wizards reuse it.
|
||||
- [ ] Collapse 4 wizard routes → 3 (open, submit, add-row).
|
||||
- [ ] Verify add/remove rows + totals + apply (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
- [ ] **Exit criteria:** `grep -c hx-swap-oob` is 0; row/totals patterns are
|
||||
confirmed reusable across two modals now.
|
||||
|
||||
---
|
||||
|
||||
### Phase 6 — Build the wizard engine + migrate Transaction Rule (2-step wizard)
|
||||
|
||||
**Rationale:** the first genuinely multi-step modal, and the simplest one — the
|
||||
right place to introduce the data-driven engine (§3.3) and **session-stored
|
||||
per-step state** (the Django `formtools` model), replacing the EDN snapshot +
|
||||
merge.
|
||||
|
||||
**Engine (do once, here):**
|
||||
- [ ] Create `components/wizard_state.clj` backed by the **Ring session**:
|
||||
`create-wizard!`, `put-step` (replace step data, do **not** merge into a
|
||||
snapshot), `set-step`, `get-all` (combine only at the end), `forget`. State is
|
||||
namespaced by `wizard-id` inside the session (`[:wizards <id> ...]`) so tabs
|
||||
and concurrent wizards don't collide. Each fn returns the updated session for
|
||||
the handler to thread into the Ring response. Test the lifecycle via REPL.
|
||||
- [ ] Create `components/wizard2.clj` (`render-wizard`, `handle-step-submit`,
|
||||
`open-wizard`) — engine threads session through and only `wizard-id` rides in
|
||||
the form. Test render + step navigation + that no snapshot is emitted.
|
||||
- [ ] Document the engine usage and the formtools inspiration in
|
||||
`reference/form-vs-wizard.md`.
|
||||
|
||||
**Modal migration (run the §8 loop), specifics:**
|
||||
- [ ] Extract `render-edit-step` and `render-test-step` (the test step shows a
|
||||
results table); keep `validate-transaction-rule` as the step `:schema`/custom check.
|
||||
- [ ] Define `transaction-rule-wizard-config` with both steps + `:done-fn`.
|
||||
- [ ] Collapse routes → 2 (open, submit).
|
||||
- [ ] Verify create / edit / run-test (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
- [ ] **Exit criteria:** engine proven on a real 2-step flow; state TTL works.
|
||||
|
||||
---
|
||||
|
||||
### Phase 7 — Invoice Pay (2-step wizard)
|
||||
|
||||
**Rationale:** 2 steps with conditional rendering by payment method (e.g.,
|
||||
handwrite-check fields) — exercises the engine's `:next`/conditional branching.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Extract `render-choose-method-step` and `render-payment-details-step`.
|
||||
- [ ] Build `pay-wizard-config`; move setup logic into `:init-fn` (e.g. the
|
||||
`invoice-by-id` lookup); branch `:next` on payment method.
|
||||
- [ ] Collapse routes → 2.
|
||||
- [ ] Verify each payment method path (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 8 — New Invoice (3-step wizard)
|
||||
|
||||
**Rationale:** a true 3-step wizard with a conditional accounts step — the
|
||||
reference multi-step shape.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Extract `render-basic-details-step`, `render-accounts-step`,
|
||||
`render-submit-step`; reuse the expense-account row cookbook entry.
|
||||
- [ ] Define step schemas separately; `:next` from basic-details skips accounts
|
||||
when not customizing.
|
||||
- [ ] `:init-fn` sets defaults (e.g. date = now).
|
||||
- [ ] Add-row for expense accounts via the sibling-`<tbody>` totals pattern.
|
||||
- [ ] Collapse routes → 2 (+1 add-row).
|
||||
- [ ] Verify create with/without custom accounts (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 9 — Vendor (5-step wizard)
|
||||
|
||||
**Rationale:** larger multi-step; by now the engine and cookbook are mature.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Extract the 5 step render fns: `render-info-step`, `render-terms-step`,
|
||||
`render-account-step`, `render-address-step`, `render-legal-step`.
|
||||
- [ ] Build `vendor-wizard-config`; handle `:new` vs `:edit` via `:init-fn`
|
||||
(empty vs. loaded entity).
|
||||
- [ ] Replace the conditional `hx-post`/`hx-put` logic with the engine's submit.
|
||||
- [ ] Collapse routes → 2.
|
||||
- [ ] Verify create + edit across all steps (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 10 — Client (7-step wizard) — largest, last
|
||||
|
||||
**Rationale:** the biggest, most complex modal (nested bank accounts, location
|
||||
matches, emails, contact methods). Deliberately last, when the skill is richest.
|
||||
|
||||
- [ ] Run the §8 loop; split extraction into sub-tasks per step.
|
||||
- [ ] Extract the 7 step render fns (`:info`, `:matches`, `:contact`,
|
||||
`:bank-accounts`, `:integrations`, `:cash-flow`, `:other-settings`).
|
||||
- [ ] Convert each `add-new-entity-handler` (bank accounts, location matches,
|
||||
emails, contact methods) to an add-row `POST` using the cookbook row pattern;
|
||||
drop `fc/with-field-default` nesting.
|
||||
- [ ] Build `client-wizard-config`; `:new` vs `:edit` via `:init-fn`.
|
||||
- [ ] Collapse routes → 2 (+ add-row endpoints as needed).
|
||||
- [ ] Verify create + edit across all 7 steps thoroughly (assert DB) + full
|
||||
suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 11 — Cleanup
|
||||
|
||||
**Rationale:** remove the now-dead old machinery.
|
||||
|
||||
- [ ] Delete the legacy wizard module (protocols + middleware) once no caller
|
||||
remains; remove any v1→v2 shim.
|
||||
- [ ] Remove the Alpine morph dependency/extension if unreferenced.
|
||||
- [ ] Decide (Open decision 3) whether to extend Selmer to the remaining static
|
||||
Hiccup, now that the skill makes it cheap.
|
||||
- [ ] Promote recurring cookbook entries into shared Selmer partials/components.
|
||||
- [ ] Final scorecard review: confirm the suite-wide LOC/route/complexity drop.
|
||||
|
||||
---
|
||||
|
||||
## 10. Risks & mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| In-flight wizard state lost (restart / session expiry) | State lives in the session (formtools model), scoped to true multi-step wizards; plain forms hold none. Lifetime follows the session; for long-lived wizards choose a durable session backend or store (Open decision 1). `forget` on completion prevents session bloat. |
|
||||
| Mixed Hiccup/Selmer interop gets messy | Build + prove the interop bridge in Phase 2 before broad use; strangler keeps both valid. |
|
||||
| Selmer loses Hiccup's structural testability | Lean on e2e + DB assertions; unit-test the data-prep functions, not markup. |
|
||||
| Large files hide behavior (`clients.clj`, `edit.clj`) | They go last, after the skill is rich; characterization e2e first; split per step. |
|
||||
| Alpine components break across swaps | Codify hardening (null-guarded `tippy?`/`$refs`, key-by-value) as a cookbook entry applied everywhere. |
|
||||
| Heuristics get gamed (LOC golfing, fake route counts) | Directional evidence only; always paired with the e2e parity gate; review the trend, not single numbers. |
|
||||
| Skill-update step skipped under pressure | Required commit-message line (scorecard delta + reused/new entries); if N+1 isn't easier, flag it. |
|
||||
| Quality regresses silently | Ratchet rule: no metric may regress for a touched modal without a written exception in `gotchas.md`. |
|
||||
|
||||
---
|
||||
|
||||
## 11. Open decisions
|
||||
|
||||
1. **Wizard state storage** — store multi-step state in the **Ring session**
|
||||
(Django `formtools` `SessionStorage` model), keyed by `wizard-id`, none for
|
||||
plain forms? Confirm the session backend in use (in-memory vs. durable) is
|
||||
acceptable for in-flight wizard lifetime, or pick a durable store for
|
||||
long-lived flows. *(recommended: session storage, scoped to multi-step
|
||||
wizards only)*
|
||||
2. **Selmer scope** — convert only interactive/attribute-heavy components first
|
||||
(hybrid), or all SSR files (full sweep)? *(recommended: hybrid, revisit in
|
||||
Phase 11)*
|
||||
3. **Whole-form vs. targeted granularity defaults** — confirm the §3.1 priority
|
||||
order (no-request → targeted cell → whole-form → OOB-only-if-disjoint) as the
|
||||
project default. *(recommended: yes)*
|
||||
4. **First step** — start by distilling the skill (Phase 1) with the reference
|
||||
implementation merged as a prerequisite, rather than treating the merge
|
||||
itself as step one. *(recommended: yes)*
|
||||
@@ -1,5 +1,11 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Reset the shared test-server dataset before each test so tests are isolated
|
||||
// from one another (and from other spec files) regardless of run order.
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/test-reset');
|
||||
});
|
||||
|
||||
let testInfoCache: any = null;
|
||||
|
||||
async function getTestInfo(page: any) {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Reset the shared test-server dataset before each test so tests are isolated
|
||||
// from one another (and from other spec files) regardless of run order.
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/test-reset');
|
||||
});
|
||||
|
||||
async function openEditModal(page: any, transactionIndex: number = 0) {
|
||||
// Navigate to transactions page
|
||||
await page.goto('/transaction2');
|
||||
@@ -18,8 +24,17 @@ async function openEditModal(page: any, transactionIndex: number = 0) {
|
||||
// The modal is now single-page (Edit Transaction). Click "Manual" tab to ensure
|
||||
// the manual account coding form is active.
|
||||
await page.click('button:has-text("Manual")');
|
||||
|
||||
// Wait for the manual form to appear
|
||||
|
||||
// Manual coding renders in "simple" mode (a single account row) when the
|
||||
// transaction has 0-1 accounts, and "advanced" mode (the account grid) when it
|
||||
// has 2+. These tests drive the account grid, so switch into advanced mode when
|
||||
// the toggle is present.
|
||||
const switchToAdvanced = page.locator('text=Switch to advanced mode');
|
||||
if (await switchToAdvanced.count()) {
|
||||
await switchToAdvanced.click();
|
||||
}
|
||||
|
||||
// Wait for the manual form (account grid) to appear
|
||||
await page.waitForSelector('#account-grid-body');
|
||||
}
|
||||
|
||||
@@ -71,29 +86,21 @@ async function selectAccountFromTypeahead(page: any, rowIndex: number, accountNa
|
||||
throw new Error(`Could not find account with name ${accountName}`);
|
||||
}
|
||||
|
||||
// Set the hidden input value and trigger change
|
||||
// Also update Alpine.js data to prevent it from overwriting our value
|
||||
// Replace the Alpine-managed hidden input with a plain one. Setting el.value
|
||||
// directly is not enough: the account input is bound via `:value="value.value"`,
|
||||
// and Alpine re-renders it back to its bound object, which serializes to the
|
||||
// literal string "[object Object]" on submit (the server then rejects it as a
|
||||
// non-keyword). Swapping in a plain input detaches it from that binding.
|
||||
await hiddenInput.evaluate((el: HTMLInputElement, value: string) => {
|
||||
// Set the DOM value
|
||||
el.value = value;
|
||||
|
||||
// Update Alpine.js component data
|
||||
const alpineEl = el.closest('[x-data]');
|
||||
if (alpineEl && (alpineEl as any).__x) {
|
||||
(alpineEl as any).__x.$data.value.value = parseInt(value);
|
||||
(alpineEl as any).__x.$data.value.label = 'Selected Account';
|
||||
}
|
||||
|
||||
// Also update any parent Alpine model (accountId)
|
||||
const rowEl = el.closest('tr[x-data]');
|
||||
if (rowEl && (rowEl as any).__x) {
|
||||
(rowEl as any).__x.$data.accountId = parseInt(value);
|
||||
}
|
||||
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
const newInput = document.createElement('input');
|
||||
newInput.type = 'hidden';
|
||||
newInput.name = el.name;
|
||||
newInput.value = value;
|
||||
el.parentNode.replaceChild(newInput, el);
|
||||
newInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}, accountId.toString());
|
||||
|
||||
// Wait for any HTMX updates
|
||||
|
||||
// Wait for any HTMX updates (e.g. location select reload)
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
@@ -341,12 +348,12 @@ test.describe('Transaction Edit Validation', () => {
|
||||
// The form should still be present
|
||||
const form = page.locator('#wizard-form');
|
||||
await expect(form).toBeVisible();
|
||||
|
||||
// Verify the account row is still there with our $50 value
|
||||
const amountInput = page.locator('.account-amount-field').first();
|
||||
const value = await amountInput.inputValue();
|
||||
expect(parseFloat(value)).toBeCloseTo(50.0, 1);
|
||||
|
||||
|
||||
// Note: the validation-error response re-renders the manual section, and with
|
||||
// a single account that renders in "simple" mode (no advanced grid), so we
|
||||
// don't assert on the advanced-grid amount field here. The error message
|
||||
// below confirms the $50 value was received and validated.
|
||||
|
||||
// Verify the user-friendly error message is displayed
|
||||
const errorElement = page.locator('#form-errors .error-content');
|
||||
await expect(errorElement).toBeVisible();
|
||||
@@ -371,11 +378,10 @@ async function openEditModalForTransaction(page: any, description: string) {
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
|
||||
// Click Next to go to the links step (button says "Transaction Actions")
|
||||
await page.click('button:has-text("Transaction Actions")');
|
||||
|
||||
// Wait for the links step to load
|
||||
await page.waitForSelector('text=Transaction Actions', { state: 'visible' });
|
||||
// The modal is now single-page: the link tabs ("Link to payment", "Link to
|
||||
// unpaid invoices", ...) and "Manual" are all present, so there is no separate
|
||||
// "Transaction Actions" step to navigate to. Just wait for the tabs to render.
|
||||
await page.waitForSelector('button:has-text("Link to payment")');
|
||||
}
|
||||
|
||||
async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
||||
@@ -449,9 +455,12 @@ test.describe('Transaction Edit Vendor Pre-population', () => {
|
||||
const testInfo = await getTestInfo(page);
|
||||
expect(accountValue).toBe(testInfo.accounts['test-account'].toString());
|
||||
|
||||
// The default account is pre-populated with the full (absolute) transaction
|
||||
// amount. Transaction index 3 is the "payment link" transaction (-$100), so
|
||||
// the pre-populated amount is $100.
|
||||
const amountInput = page.locator('.account-amount-field').first();
|
||||
const amountValue = await amountInput.inputValue();
|
||||
expect(parseFloat(amountValue)).toBeCloseTo(400.0, 1);
|
||||
expect(parseFloat(amountValue)).toBeCloseTo(100.0, 1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Reset the shared test-server dataset before each test so tests are isolated
|
||||
// from one another (and from other spec files) regardless of run order.
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/test-reset');
|
||||
});
|
||||
|
||||
// The SSR manual transaction import accepts the exact Yodlee positional-column
|
||||
// TSV format from the master branch. Column order (14 columns), per
|
||||
// auto-ap.import.manual/columns:
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Reset the shared test-server dataset before each test so tests are isolated
|
||||
// from one another (and from other spec files) regardless of run order.
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/test-reset');
|
||||
});
|
||||
|
||||
async function navigateToTransactions(page: any, path: string = '/transaction2') {
|
||||
await page.setExtraHTTPHeaders({
|
||||
'x-clients': '"mine"'
|
||||
@@ -90,15 +96,24 @@ test.describe('Transaction Navigation - Amount Filter Persistence', () => {
|
||||
|
||||
test.describe('Transaction Navigation - Date Filter Persistence', () => {
|
||||
test('should persist date-range preset when navigating between pages', async ({ page }) => {
|
||||
// Step 1: Navigate with date-range=all (includes 2022 test data)
|
||||
// Step 1: Navigate with date-range=all (includes 2022 test data).
|
||||
// The server expands the "all" preset into a concrete start-date (~6 years
|
||||
// back) and drops the date-range key, so persistence happens via start-date.
|
||||
await navigateToTransactions(page, '/transaction2?date-range=all');
|
||||
|
||||
|
||||
// Step 2: Click Unapproved nav link
|
||||
await clickTransactionNavLink(page, 'Unapproved');
|
||||
|
||||
// Step 3: Verify date-range persisted
|
||||
const unapprovedUrl = page.url();
|
||||
expect(unapprovedUrl).toContain('date-range=all');
|
||||
|
||||
// Step 3: Verify the expanded date range persisted as a start-date.
|
||||
// "all" resolves to roughly 6 years before today (MM/DD/YYYY).
|
||||
const sixYearsAgo = new Date();
|
||||
sixYearsAgo.setFullYear(sixYearsAgo.getFullYear() - 6);
|
||||
const mm = String(sixYearsAgo.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(sixYearsAgo.getDate()).padStart(2, '0');
|
||||
const expectedStart = `${mm}/${dd}/${sixYearsAgo.getFullYear()}`;
|
||||
|
||||
const startDate = new URL(page.url()).searchParams.get('start-date');
|
||||
expect(startDate).toBe(expectedStart);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,10 +2,13 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
// These tests share a single stateful test server with one fixed dataset and
|
||||
// mutate the same transactions (coding, bulk coding, etc.), so they must run
|
||||
// serially. Running them in parallel causes cross-test races and flakes.
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
workers: 1,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3333',
|
||||
|
||||
1
resources/.~lock.sample-yodlee-manual.tsv#
Normal file
1
resources/.~lock.sample-yodlee-manual.tsv#
Normal file
@@ -0,0 +1 @@
|
||||
,noti,pop-os,01.06.2026 21:02,file:///home/noti/.config/libreoffice/4;
|
||||
@@ -268,6 +268,21 @@ const calendarYearPeriod = (date) => {
|
||||
return {end: formatDateMMDDYYYY(end), start: formatDateMMDDYYYY(start)};
|
||||
}
|
||||
|
||||
const previousCalendarYearPeriod = (date) => {
|
||||
if (!date) {
|
||||
date= new Date()
|
||||
} else {
|
||||
date = parseMMDDYYYY(date)
|
||||
}
|
||||
const priorYear = date.getFullYear() - 1;
|
||||
|
||||
// Jan 1 - Dec 31 of the previous calendar year
|
||||
const start = new Date(priorYear, 0, 1);
|
||||
const end = new Date(priorYear, 11, 31);
|
||||
|
||||
return {end: formatDateMMDDYYYY(end), start: formatDateMMDDYYYY(start)};
|
||||
}
|
||||
|
||||
const getLastMonthPeriods = (date) => {
|
||||
if (!date) {
|
||||
date = new Date();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -6,10 +6,10 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
4,PAPER & DISP,STRAW PLAS TRANS JMB WRPD 7.75,Paper Costs,55000,
|
||||
5,PAPER & DISP,FORK PLAS BLK PLA COMPOSTABLE,Paper Costs,55000,
|
||||
6,PAPER & DISP,BAG PLAS LOGO 3 CLR,Paper Costs,55000,
|
||||
7,FROZEN,BREAD PITA GYRO PRE-OILED 7,Food Costs,50000,
|
||||
7,FROZEN,BREAD PITA GYRO PRE-OILED 7,Bread and Bun Costs,51400,
|
||||
8,DAIRY PRODUCTS,YOGURT FRZN TART,Dairy Costs,51300,
|
||||
9,POULTRY,GYRO CHICKEN SHAWARMA CONE,Chicken/ Poultry Costs,51120,
|
||||
10,FROZEN,BAKLAVA CLASSIC 2X24,Food Costs,50000,
|
||||
10,FROZEN,BAKLAVA CLASSIC 2X24,Dry Goods Costs,51500,
|
||||
11,MEATS,PORK SLI GYRO CONE,Beef/Pork Costs,51110,
|
||||
12,DAIRY PRODUCTS,SAUCE TZATZIKI,Dairy Costs,51300,
|
||||
13,POULTRY,CHICKEN CVP THIGH BNLS SKLS,Chicken/ Poultry Costs,51120,
|
||||
@@ -19,7 +19,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
17,CANNED AND DRY,RICE BASMATI STEAMED XTRA LNG,Food Costs,50000,
|
||||
18,CANNED AND DRY,WATER SPARKLING GREEK,Beverages Costs,52000,
|
||||
19,DAIRY PRODUCTS,SAUCE SPICY YOGURT LOGO,Dairy Costs,51300,
|
||||
20,CANNED AND DRY,WATER PURIFIED .5,Food Costs,50000,
|
||||
20,CANNED AND DRY,WATER PURIFIED .5,Soft Beverage Cost,52000,
|
||||
21,FROZEN,DOUGH PASTRY HNY PUFF,Food Costs,50000,
|
||||
22,DAIRY PRODUCTS,YOGURT PLAIN GREEK NON-FAT,Dairy Costs,51300,
|
||||
23,PAPER & DISP,GLOVE NITRILE LARGE,Paper Costs,55000,
|
||||
@@ -43,39 +43,39 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
41,CANNED AND DRY,WATER BOTTLED DRINKING,Food Costs,50000,
|
||||
42,CANNED AND DRY,SODA CHERRY VISSINADA GRK PLAS,Food Costs,50000,
|
||||
43,CANNED AND DRY,SODA LEMON LEMONADA GREEK,Soft Beverage Costs,52000,
|
||||
44,CANNED AND DRY,WATER MINERAL CARNONATED GREEK,Food Costs,50000,
|
||||
44,CANNED AND DRY,WATER MINERAL CARNONATED GREEK,Soft Beverage Cost,52000,
|
||||
45,DISPENSER BEVRG,SYRUP COLA PEPSI BIB,Soft Beverage Costs,52000,
|
||||
46,DISPENSER BEVRG,SYRUP LEMONADE PNK BIB,Soft Beverage Costs,52000,
|
||||
47,CANNED AND DRY,RICE BASMATI PABROIL SELA CS,Food Costs,50000,
|
||||
48,CANNED AND DRY,KETCHUP FANCY,Food Costs,50000,
|
||||
47,CANNED AND DRY,RICE BASMATI PABROIL SELA CS,Dry Goods Costs,51500,
|
||||
48,CANNED AND DRY,KETCHUP FANCY,Dry Goods Costs,51500,
|
||||
49,CANNED AND DRY,TUB & HUMMUS,Food Costs,50000,
|
||||
50,CHEMICAL/JANTRL,SANITIZER MULTI QUAT LIQ,Food Costs,50000,
|
||||
50,CHEMICAL/JANTRL,SANITIZER MULTI QUAT LIQ,Cleaning Supplies,74100,
|
||||
51,DAIRY PRODUCTS,YOGURT PLAIN GRK 5%,Dairy Costs,51300,
|
||||
52,FROZEN,APTZR VEG FALAFEL BALL,Food Costs,50000,
|
||||
52,FROZEN,APTZR VEG FALAFEL BALL,Dry Goods Costs,51500,
|
||||
53,PAPER & DISP,BOWL PAPER FIBER RND 32OZ 8IN,Paper Costs,55000,
|
||||
54,PAPER & DISP,LID PLAS F/BOWL RND 8,Paper Costs,55000,
|
||||
55,MEATS,BEEF GRND CHUCK FINE 80/20FRSH,Beef/Pork Costs,51110,
|
||||
56,CANNED AND DRY,SODA ORANGE CRSH,Food Costs,50000,
|
||||
56,CANNED AND DRY,SODA ORANGE CRSH,Soft Beverage Cost,52000,
|
||||
57,PAPER & DISP,CONTAINER PLAS CLR BAR LK 5 IN,Paper Costs,55000,
|
||||
58,CANNED AND DRY,KETCHUP PACKET FCY,Food Costs,50000,
|
||||
58,CANNED AND DRY,KETCHUP PACKET FCY,Dry Goods Costs,51500,
|
||||
59,PAPER & DISP,BAG PLAS WAVE TOP LOGO 18X16,Paper Costs,55000,
|
||||
60,CANNED AND DRY,DRESSING VINAIGRETTE LOGO,Food Costs,50000,
|
||||
60,CANNED AND DRY,DRESSING VINAIGRETTE LOGO,Dressing & Sauce Cost,51450,
|
||||
61,PAPER & DISP,CONTAINER PAPER MLD FBR 9X6,Paper Costs,55000,
|
||||
62,PAPER & DISP,BOWL PAPER MLD FBR 32OZ NFA,Paper Costs,55000,
|
||||
63,PAPER & DISP,CONTAINER PLAS 120Z SUNDAE,Paper Costs,55000,
|
||||
64,CANNED AND DRY,DRESSING VINAIGRETTE GYRO,Food Costs,50000,
|
||||
65,CANNED AND DRY,VINEGAR WINE RED 5% 50 GRN,Alcohol Costs,54000,
|
||||
66,DAIRY PRODUCTS,EGG SHELL LG WHT AA CA CGFREE,Dairy Costs,51300,
|
||||
67,CANNED AND DRY,DRESSING MARINADE SOUVLAKI,Food Costs,50000,
|
||||
68,CANNED AND DRY,SAUCE MUSTARD,Food Costs,50000,
|
||||
67,CANNED AND DRY,DRESSING MARINADE SOUVLAKI,Dressing & Sauce Cost,51450,
|
||||
68,CANNED AND DRY,SAUCE MUSTARD,Dressing & Sauce Cost,51450,
|
||||
69,CANNED AND DRY,TEA ICED SWEET PURELEAF,Beverages Costs,52000,
|
||||
70,PAPER & DISP,LINER TRASH 40X46 1.1 ML GRY,Paper Costs,55000,
|
||||
71,CANNED AND DRY,SODA COLA,Soft Beverage Costs,52000,
|
||||
72,CANNED AND DRY,HONEY PURE CLOVER GR A TSC JUG,Food Costs,50000,
|
||||
72,CANNED AND DRY,HONEY PURE CLOVER GR A TSC JUG,Dry Goods Costs,51500,
|
||||
73,DAIRY PRODUCTS,CHEESE FETA RW,Dairy Costs,51300,
|
||||
74,CANNED AND DRY,WATER PURIFIED BTL PET LSE DW,Food Costs,50000,
|
||||
74,CANNED AND DRY,WATER PURIFIED BTL PET LSE DW,Soft Beverage Cost,52000,
|
||||
75,PRODUCE,JUICE LEMON FRESH PSTRZD,Produce Costs,51200,
|
||||
76,CANNED AND DRY,SPREAD HUMMUS TRADITIONAL,Food Costs,50000,
|
||||
76,CANNED AND DRY,SPREAD HUMMUS TRADITIONAL,Dressing & Sauce Cost,51450,
|
||||
77,PRODUCE,LETTUCE ROMAINE OF HEART FRSH,Produce Costs,51200,
|
||||
78,PAPER & DISP,CONTAINER PAPER #1/30OZ NTG,Paper Costs,55000,
|
||||
79,CANNED AND DRY,OIL SALAD CANOLA ZTF,Food Costs,50000,
|
||||
@@ -84,7 +84,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
82,POULTRY,CHICKEN CVP WHL WOG NAE 3.5-4#,Chicken/ Poultry Costs,51120,
|
||||
83,PAPER & DISP,GLOVE NITRILE FDSRV PF BLU LRG,Paper Costs,55000,
|
||||
84,CANNED AND DRY,RICE BASMATI CHEF SECRT LG GRN,Food Costs,50000,
|
||||
85,FROZEN,APTZR VEG FALAFEL PUCK HALAL,Food Costs,50000,
|
||||
85,FROZEN,APTZR VEG FALAFEL PUCK HALAL,Dry Goods Costs,51500,
|
||||
86,PAPER & DISP,LID PLAS PET FOR 32OZ BOWL,Paper Costs,55000,
|
||||
87,PAPER & DISP,FORK PLAS PP X-HVY BLK,Paper Costs,55000,
|
||||
88,PRODUCE,TOMATO ROMA JUMBO FRESH,Produce Costs,51200,
|
||||
@@ -95,7 +95,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
93,PRODUCE,SPINACH BABY FRSH,Produce Costs,51200,
|
||||
94,PRODUCE,DILL BABY FRESH HERB,Produce Costs,51200,
|
||||
95,PRODUCE,CUCUMBER ENGLISH MED SEEDLESS,Produce Costs,51200,
|
||||
96,CANNED AND DRY,SODA ORANGE PORTOKALADA GREEK,Food Costs,50000,
|
||||
96,CANNED AND DRY,SODA ORANGE PORTOKALADA GREEK,Soft Beverage Cost,52000,
|
||||
97,DAIRY PRODUCTS,BUTTER SOLID USDA AA UNSLTD,Dairy Costs,51300,
|
||||
98,DAIRY PRODUCTS,CHEESE MONT JACK SLI INT .75OZ,Dairy Costs,51300,
|
||||
99,DAIRY PRODUCTS,CREAMER HALF & HALF SHF STBL,Dairy Costs,51300,
|
||||
@@ -115,18 +115,18 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
113,CANNED AND DRY,FLOUR ALL PURP H&R BL EN MT,Food Costs,50000,
|
||||
114,CANNED AND DRY,JAM STRAWBERRY CUP,Food Costs,50000,
|
||||
115,CANNED AND DRY,MARMALADE ORANGE CUP,Food Costs,50000,
|
||||
116,CANNED AND DRY,SALT GRANULATED PLAIN,Food Costs,50000,
|
||||
116,CANNED AND DRY,SALT GRANULATED PLAIN,Dry Goods Costs,51500,
|
||||
117,CANNED AND DRY,SAUCE HOT PEPPER CALIFN STYLE,Food Costs,50000,
|
||||
118,CANNED AND DRY,SAUCE STEAK GLASS,Food Costs,50000,
|
||||
119,CANNED AND DRY,SHORTENING PAN & GRILL,Food Costs,50000,
|
||||
120,CANNED AND DRY,SUGAR GRANULATED XFINE CANE,Food Costs,50000,
|
||||
120,CANNED AND DRY,SUGAR GRANULATED XFINE CANE,Dry Goods Costs,51500,
|
||||
121,CANNED AND DRY,SYRUP BREAKFAST CUP,Food Costs,50000,
|
||||
122,CANNED AND DRY,SYRUP PANCAKE & WAFFLE,Food Costs,50000,
|
||||
123,PAPER & DISP,BAG PLAS TSHRT 11.5X6.5X21 TKU,Paper Costs,55000,
|
||||
124,PAPER & DISP,CONTAINER PLAS DELI TRANS W/LD,Paper Costs,55000,
|
||||
125,PAPER & DISP,CONTAINER PLAS HNG WHT 8.5 1C,Paper Costs,55000,
|
||||
126,PAPER & DISP,CUP PLAS PRTN TRANS 2OZ,Paper Costs,55000,
|
||||
127,PAPER & DISP,FOIL ALMN ROLL STD WGT 500FT,Paper Costs,55000,
|
||||
127,PAPER & DISP,FOIL ALMN ROLL STD WGT 500FT,Dry Goods Costs,51500,
|
||||
128,PAPER & DISP,LINER TRASH 40X46 1.6 ML BLK,Paper Costs,55000,
|
||||
129,PAPER & DISP,TOWEL MULTI 9.5X9.12 EARTH+,Paper Costs,55000,
|
||||
130,PRODUCE,ASPARAGUS FRESH LARGE FX,Produce Costs,51200,
|
||||
@@ -155,7 +155,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
153,DAIRY PRODUCTS,MILK WHL CORRUGATE,Dairy Costs,51300,
|
||||
154,DAIRY PRODUCTS,BUTTERMILK 1% HG,Dairy Costs,51300,
|
||||
155,DAIRY PRODUCTS,CHEESE CHDR MLD SLI INT .75 YL,Dairy Costs,51300,
|
||||
156,CHEMICAL/JANTRL,BLEACH LIQ GRMCDL ULTRA 6%,Food Costs,50000,
|
||||
156,CHEMICAL/JANTRL,BLEACH LIQ GRMCDL ULTRA 6%,Cleaning Supplies,74100,
|
||||
157,POULTRY,TURKEY BRST NAT BRN PAN SKON,Poultry Costs,51120,
|
||||
158,DAIRY PRODUCTS,CREAM HEAVY 40% FRESH HG,Dairy Costs,51300,
|
||||
159,MEATS,BACON SHINGLE 10/12 HY GF PR12,Beef/Pork Costs,51110,
|
||||
@@ -194,7 +194,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
192,PAPER & DISP,BOWL PAPER MOLDED FIBER 32OZ,Paper Costs,55000,
|
||||
193,PAPER & DISP,BOX CORR CATER #1 LOGO 2021,Paper Costs,55000,
|
||||
194,DISPENSER BEVRG,SYRUP LEMONADE BIB,Soft Beverage Costs,52000,
|
||||
195,PAPER & DISP,FOIL ALMN ROLL HVY WGT 500 FT,Paper Costs,55000,
|
||||
195,PAPER & DISP,FOIL ALMN ROLL HVY WGT 500 FT,Dry Goods Costs,51500,
|
||||
196,DAIRY PRODUCTS,CHEESE FETA CHUNKS IN BRNE,Dairy Costs,51300,
|
||||
197,POULTRY,CHICKEN CVP WOG WHL HAL,Chicken/ Poultry Costs,51120,
|
||||
198,PAPER & DISP,CONTAINER MFPP 1C HNG 9X6 WHT,Paper Costs,55000,
|
||||
@@ -203,11 +203,11 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
201,DISPENSER BEVRG,SYRUP LEMON LIME BIB,Soft Beverage Costs,52000,
|
||||
202,DAIRY PRODUCTS,CHEESE FETA PAIL,Dairy Costs,51300,
|
||||
203,CANNED AND DRY,CHGS FOR MINIMUM ORDER,Food Costs,50000,
|
||||
204,CANNED AND DRY,BREAD CRUMB PLAIN MED,Food Costs,50000,
|
||||
205,CANNED AND DRY,DRESSING SALAD PRASINI,Food Costs,50000,
|
||||
206,CANNED AND DRY,OIL CORN,Food Costs,50000,
|
||||
207,CANNED AND DRY,OLIVE KALAMATA PTD BRNE 22 LB,Food Costs,50000,
|
||||
208,CANNED AND DRY,SPICE TURMERIC GROUND,Food Costs,50000,
|
||||
204,CANNED AND DRY,BREAD CRUMB PLAIN MED,Dry Goods Costs,51500,
|
||||
205,CANNED AND DRY,DRESSING SALAD PRASINI,Dressing & Sauce Cost,51450,
|
||||
206,CANNED AND DRY,OIL CORN,Dressing & Sauce Cost,51450,
|
||||
207,CANNED AND DRY,OLIVE KALAMATA PTD BRNE 22 LB,Produce Costs,51200,
|
||||
208,CANNED AND DRY,SPICE TURMERIC GROUND,Dry Goods Costs,51500,
|
||||
209,PRODUCE,SQUASH ZUCCHINI MEDIUM FRESH,Produce Costs,51200,
|
||||
210,PRODUCE,ONION GREEN ICELS ROOTLESS,Produce Costs,51200,
|
||||
211,CANNED AND DRY,SODA COLA PEPSI ZERO,Soft Beverage Costs,52000,
|
||||
@@ -219,7 +219,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
217,FROZEN,BAKLAVA GREEK PASTRY,Food Costs,50000,
|
||||
218,DISPENSER BEVRG,SYRUP DR PPR DIET BIB,Soft Beverage Costs,52000,
|
||||
219,DISPENSER BEVRG,SYRUP MOUNTAIN DEW BIB,Soft Beverage Costs,52000,
|
||||
220,CANNED AND DRY,SAUCE CHILI HOT SRIRACHA,Food Costs,50000,
|
||||
220,CANNED AND DRY,SAUCE CHILI HOT SRIRACHA,Dry Goods Costs,51500,
|
||||
221,PAPER & DISP,SKEWER BAMBOO 10IN,Paper Costs,55000,
|
||||
222,CANNED AND DRY,RICE BASMATI,Food Costs,50000,
|
||||
223,PAPER & DISP,WRAP PAPER 14X14 LOGO,Paper Costs,55000,
|
||||
@@ -227,12 +227,12 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
225,MEATS,PORK BUTT BNLS VP PR12,Beef/Pork Costs,51110,
|
||||
226,DAIRY PRODUCTS,YOGURT PLAIN GREEK NONFAT,Dairy Costs,51300,
|
||||
227,PAPER & DISP,TONG PLAS 9 BLK SNAP N SERVE,Paper Costs,55000,
|
||||
228,CANNED AND DRY,SAUCE HOT SRIRACHA,Food Costs,50000,
|
||||
228,CANNED AND DRY,SAUCE HOT SRIRACHA,Dry Goods Costs,51500,
|
||||
229,DISPENSER BEVRG,SYRUP DR PEPPER BIB,Soft Beverage Costs,52000,
|
||||
230,HLTHCAR/HOSPITALITY,BILLING MISC REGULAR,Food Costs,50000,
|
||||
231,PAPER & DISP,FORK PLAS BLK MEDHVY MDLNGTH,Paper Costs,55000,
|
||||
232,DAIRY PRODUCTS,YOGURT PLAIN ORIGINAL FTFR,Dairy Costs,51300,
|
||||
233,SUPP & EQUIP,MOP HEAD BLND LPD ALL PURP LRG,Food Costs,50000,
|
||||
233,SUPP & EQUIP,MOP HEAD BLND LPD ALL PURP LRG,Cleaning Supplies,74100,
|
||||
234,PAPER & DISP,SPOON PLAS WHT MEDHVY MDLNGTH,Paper Costs,55000,
|
||||
235,MEATS,BEEF GROUND BULK NAT 80/20,Beef/Pork Costs,51110,
|
||||
236,PAPER & DISP,CONTAINER PAPER HNG 9X6 PFF,Paper Costs,55000,
|
||||
@@ -245,9 +245,9 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
243,PRODUCE,CUCUMBER ENGLISH FRSH,Produce Costs,51200,
|
||||
244,PAPER & DISP,FORK PLAS WHT MED HVY MDLNGTH,Paper Costs,55000,
|
||||
245,PAPER & DISP,CUP PLAS 12-14OZ CLR STRT WALL,Paper Costs,55000,
|
||||
246,CANNED AND DRY,SAUCE HOT BOTTLE,Food Costs,50000,
|
||||
247,CANNED AND DRY,OIL OLIVE BLEND 80/20,Food Costs,50000,
|
||||
248,CANNED AND DRY,SPICE OREGANO LEAF RUBBED,Food Costs,50000,
|
||||
246,CANNED AND DRY,SAUCE HOT BOTTLE,Dry Goods Costs,51500,
|
||||
247,CANNED AND DRY,OIL OLIVE BLEND 80/20,Dry Goods Costs,51500,
|
||||
248,CANNED AND DRY,SPICE OREGANO LEAF RUBBED,Dry Goods Costs,51500,
|
||||
249,DAIRY PRODUCTS,YOGURT VANILLA GREEK NFAT,Dairy Costs,51300,
|
||||
250,FROZEN,BUN BRIOCHE HOMESTYLE 4,Food Costs,50000,
|
||||
251,CANNED AND DRY,WATER SPRKLG IMPRTD MNERAL GLS,Food Costs,50000,
|
||||
@@ -260,18 +260,18 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
258,CANNED AND DRY,PEPPER GREEN CHILI WHL,Food Costs,50000,
|
||||
259,CANNED AND DRY,PEPPER JALAPENO SLI FIELD RUN,Food Costs,50000,
|
||||
260,CANNED AND DRY,SAUCE MIX HOLLANDAISE GF,Food Costs,50000,
|
||||
261,CANNED AND DRY,VINEGAR DISTILLED WHITE 5%,Food Costs,50000,
|
||||
261,CANNED AND DRY,VINEGAR DISTILLED WHITE 5%,Dry Goods Costs,51500,
|
||||
262,PAPER & DISP,COVER TOILET SEAT,Paper Costs,55000,
|
||||
263,PAPER & DISP,FILM PVC 2000FT ROLL,Paper Costs,55000,
|
||||
264,PAPER & DISP,DOILY PAPER NRMDY LACE 6,Paper Costs,55000,
|
||||
265,PAPER & DISP,KIT CUTLERY MED PP KFS S&P NAP,Paper Costs,55000,
|
||||
266,PAPER & DISP,NAPKIN DNR 2P 15X16.25 1/8F WH,Paper Costs,55000,
|
||||
267,CHEMICAL/JANTRL,DETERGENT POT/PAN LIQ PINK RTU,Food Costs,50000,
|
||||
267,CHEMICAL/JANTRL,DETERGENT POT/PAN LIQ PINK RTU,Cleaning Supplies,74100,
|
||||
268,CHEMICAL/JANTRL,SALT GRANULE SOLAR WATER SOFT,Food Costs,50000,
|
||||
269,PRODUCE,CARROT FRESH JUMBO,Produce Costs,51200,
|
||||
270,PRODUCE,LIME FRESH 200CT,Produce Costs,51200,
|
||||
271,PAPER & DISP,CUP PLAS RPET CLR 16 OZ,Paper Costs,55000,
|
||||
272,CANNED AND DRY,SAUCE HOT,Food Costs,50000,
|
||||
272,CANNED AND DRY,SAUCE HOT,Dry Goods Costs,51500,
|
||||
273,MEATS,BACON SHINGLE 10/12 AW GF PR12,Beef/Pork Costs,51110,
|
||||
274,PAPER & DISP,CRAYON RED BLUE YEL GREEN,Paper Costs,55000,
|
||||
275,DAIRY PRODUCTS,CREAMER HALF AND HALF PC ASEP,Dairy Costs,51300,
|
||||
@@ -282,7 +282,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
280,CANNED AND DRY,KETCHUP SQUEEZE UPSIDE DOWN,Food Costs,50000,
|
||||
281,PAPER & DISP,GLOVE NITRILE FDSRV PF BLK LRG,Paper Costs,55000,
|
||||
282,MEATS,BEEF GRND CHUCK 81/19 CHUB FRS,Beef/Pork Costs,51110,
|
||||
283,PAPER & DISP,LID FOIL F/FULL STM TBL PAN,Paper Costs,55000,
|
||||
283,PAPER & DISP,LID FOIL F/FULL STM TBL PAN,Dry Goods Costs,51500,
|
||||
284,PAPER & DISP,FORK WOODEN DISP,Paper Costs,55000,
|
||||
285,PAPER & DISP,SKEWER BAMBOO THIN 8 IN,Paper Costs,55000,
|
||||
286,PAPER & DISP,WRAP DELI WHT 12X12 GRS RESIST,Paper Costs,55000,
|
||||
@@ -292,9 +292,9 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
290,PAPER & DISP,TRAY FOOD PAPER #2 LOGO,Paper Costs,55000,
|
||||
291,PAPER & DISP,LID PLAS CLR F/1.5-2.5OZ PRTN,Paper Costs,55000,
|
||||
292,DISPENSER BEVRG,SYRUP TEA RASP 5X1 BRISK,Soft Beverage Costs,52000,
|
||||
293,PAPER & DISP,PAN FOIL STM TBL FULL DP 3-3/8,Paper Costs,55000,
|
||||
293,PAPER & DISP,PAN FOIL STM TBL FULL DP 3-3/8,Dry Goods Costs,51500,
|
||||
294,DISPENSER BEVRG,SYRUP ROOT BEER BIB,Soft Beverage Costs,52000,
|
||||
295,PAPER & DISP,FOIL ALMN ROLL HVY WGT 1000 FT,Paper Costs,55000,
|
||||
295,PAPER & DISP,FOIL ALMN ROLL HVY WGT 1000 FT,Dry Goods Costs,51500,
|
||||
296,DAIRY PRODUCTS,CHEESE GORGONZOLA WHEEL HALF,Dairy Costs,51300,
|
||||
297,DAIRY PRODUCTS,CHEESE MOZZ WM SHRED GOLD PREM,Dairy Costs,51300,
|
||||
298,DAIRY PRODUCTS,CREAM SOUR CULTRD GRADE A,Dairy Costs,51300,
|
||||
@@ -311,7 +311,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
309,SEAFOOD,SCALLOP SEA WTR ADD 10/20 USA,Seafood Costs,51130,
|
||||
310,MEATS,BACON SLAB SLI 13/17 CT PR12,Beef/Pork Costs,51110,
|
||||
311,CANNED AND DRY,DRESSING MIX RANCH,Food Costs,50000,
|
||||
312,FROZEN,BUN BRIOCHE HOMESTYLE 4.25,Food Costs,50000,
|
||||
312,FROZEN,BUN BRIOCHE HOMESTYLE 4.25,Bread and Bun Costs,51400,
|
||||
313,CANNED AND DRY,SODA LEMON LIME,Soft Beverage Costs,52000,
|
||||
314,FROZEN,PUREE ORANGE BLOOD CONCENTRATE,Food Costs,50000,
|
||||
315,CANNED AND DRY,FLOUR HI-GLUTEN BL EN MT AA,Dry Good Costs,51500,
|
||||
@@ -327,23 +327,23 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
325,DAIRY PRODUCTS,CHEESE FETA CHUNKS PAIL PREM,Dairy Costs,51300,
|
||||
326,PRODUCE,PEPPER GREEN BELL LARGE FRESH,Produce Costs,51200,
|
||||
327,PRODUCE,TOMATO ROMA FRSH,Produce Costs,51200,
|
||||
328,CHEMICAL/JANTRL,CLEANER DEGREASER OVEN RTU,Food Costs,50000,
|
||||
328,CHEMICAL/JANTRL,CLEANER DEGREASER OVEN RTU,Cleaning Supplies,74100,
|
||||
329,PAPER & DISP,BOX CORR CATER #4 LOGO 2021,Paper Costs,55000,
|
||||
330,PAPER & DISP,LID PLAS HI DOME DESSRT,Paper Costs,55000,
|
||||
331,MEATS,PORK BUTT BNLS 1/4 6-9#EA,Beef/Pork Costs,51110,
|
||||
332,PAPER & DISP,CUP PAPER HOT WHT TALL 12OZ,Paper Costs,55000,
|
||||
333,PAPER & DISP,SPOON PLAS SOUP BLACK XHEAVY,Paper Costs,55000,
|
||||
334,PAPER & DISP,FOIL ALMN ROLL STD WGT 1000 FT,Paper Costs,55000,
|
||||
335,SUPP & EQUIP,PAD SCRUB STNLS 50GR 1.75OZ,Food Costs,50000,
|
||||
334,PAPER & DISP,FOIL ALMN ROLL STD WGT 1000 FT,Dry Goods Costs,51500,
|
||||
335,SUPP & EQUIP,PAD SCRUB STNLS 50GR 1.75OZ,Cleaning Supplies,74100,
|
||||
336,DISPENSER BEVRG,SYRUP TEA UNSWTD 5X1,Soft Beverage Costs,52000,
|
||||
337,FROZEN,BUN BRIOCHE SPLIT TOP 4IN SLI,Food Costs,50000,
|
||||
337,FROZEN,BUN BRIOCHE SPLIT TOP 4IN SLI,Bread and Bun Costs,51400,
|
||||
338,CANNED AND DRY,WATER MINERAL LIMONATA CAN,Beverages Costs,52000,
|
||||
339,PRODUCE,GARLIC PEELED CHINESE,Produce Costs,51200,
|
||||
340,PAPER & DISP,LID PLAS FLAT F/12-24Z PET CUP,Paper Costs,55000,
|
||||
341,FROZEN,BILLING MISC FROZEN,Food Costs,50000,
|
||||
342,PAPER & DISP,KNIFE PLAS BLK PLA COMPSTABLE,Paper Costs,55000,
|
||||
343,PAPER & DISP,GLOVE NITRILE MED,Paper Costs,55000,
|
||||
344,SUPP & EQUIP,GRILL BRICK 3.5IN THICK,Food Costs,50000,
|
||||
344,SUPP & EQUIP,GRILL BRICK 3.5IN THICK,Cleaning Supplies,74100,
|
||||
345,PRODUCE,CABBAGE SAVOY FRSH,Produce Costs,51200,
|
||||
346,PRODUCE,FLOWER ORCHID MULTI COLORED,Produce Costs,51200,
|
||||
347,CANNED AND DRY,OIL AVOCADO,Food Costs,50000,
|
||||
@@ -393,7 +393,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
391,PAPER & DISP,TOWEL MULTIFOLD 9.4X9.2 WHT 1P,Paper Costs,55000,
|
||||
392,PAPER & DISP,TISSUE TOILET WRPD 4X3.8 2PLY,Paper Costs,55000,
|
||||
393,MEATS,BEEF GRND 80/20 BULK,Beef/Pork Costs,51110,
|
||||
394,CANNED AND DRY,MAYONNAISE HEAVY DUTY,Food Costs,50000,
|
||||
394,CANNED AND DRY,MAYONNAISE HEAVY DUTY,Dressing & Sauce Cost,51450,
|
||||
395,PAPER & DISP,CONTAINER PLAS HNG 9X6 WHT,Paper Costs,55000,
|
||||
396,PAPER & DISP,SPOON PLASTIC BLK BAGGED,Paper Costs,55000,
|
||||
397,PAPER & DISP,FORK PLAS BLK HVY FULL LNGTH,Paper Costs,55000,
|
||||
@@ -406,7 +406,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
404,CANNED AND DRY,BEAN BLACK,Food Costs,50000,
|
||||
405,CANNED AND DRY,SYRUP CHOCOLATE PLAS JUG,Food Costs,50000,
|
||||
406,CANNED AND DRY,TUNA LIGHT SKIPJACK CHUNK WTR,Food Costs,50000,
|
||||
407,CHEMICAL/JANTRL,DEGREASER HEAVY DUTY RTU,Food Costs,50000,
|
||||
407,CHEMICAL/JANTRL,DEGREASER HEAVY DUTY RTU,Cleaning Supplies,74100,
|
||||
408,CANNED AND DRY,JAM BLACKBERRY CUP,Food Costs,50000,
|
||||
409,CANNED AND DRY,PICKLE WHL DILL KO REF 75/85,Food Costs,50000,
|
||||
410,DAIRY PRODUCTS,CHEESE QUESO FRESCO CASERO,Dairy Costs,51300,
|
||||
@@ -423,7 +423,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
421,PAPER & DISP,CONTAINER PAPER HNG 9X6 FIB,Paper Costs,55000,
|
||||
422,PAPER & DISP,LINER REPRO 40X46 1.5 ML BLK,Paper Costs,55000,
|
||||
423,PAPER & DISP,TAPE PAPR REG THERMAL 3-1/8,Paper Costs,55000,
|
||||
424,SUPP & EQUIP,PAD SCOUR GRN 6X9IN ANTIMICRO,Food Costs,50000,
|
||||
424,SUPP & EQUIP,PAD SCOUR GRN 6X9IN ANTIMICRO,Cleaning Supplies,74100,
|
||||
425,PAPER & DISP,GLOVE NITRILE BLUE XL,Paper Costs,55000,
|
||||
426,PRODUCE,CUCUMBER ENGLISH LONG,Produce Costs,51200,
|
||||
427,PRODUCE,TOMATO ROMA MED,Produce Costs,51200,
|
||||
@@ -434,13 +434,13 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
432,POULTRY,CHICKEN CVP THIGH B/S HALAL JM,Chicken/ Poultry Costs,51120,
|
||||
433,CANNED AND DRY,DRESSING HONEY MUSTARD,Food Costs,50000,
|
||||
434,PRODUCE,JUICE LEMON PSTRZD ULTRA PREM,Produce Costs,51200,
|
||||
435,CANNED AND DRY,SPICE PAPRIKA GROUND,Food Costs,50000,
|
||||
435,CANNED AND DRY,SPICE PAPRIKA GROUND,Dry Goods Costs,51500,
|
||||
436,PAPER & DISP,FORK PLAS WHT HVY FULL LENGTH,Paper Costs,55000,
|
||||
437,PAPER & DISP,LID FOIL F/ HALF STMTBL PAN,Paper Costs,55000,
|
||||
437,PAPER & DISP,LID FOIL F/ HALF STMTBL PAN,Dry Goods Costs,51500,
|
||||
438,SUPP & EQUIP,PAN FOIL HALF DEEP 100CT,Food Costs,50000,
|
||||
439,PAPER & DISP,CONTAINER FOAM HNG LRG 1C,Paper Costs,55000,
|
||||
440,PAPER & DISP,LINER TRASH 40X48 13 MC NAT,Paper Costs,55000,
|
||||
441,CANNED AND DRY,KETCHUP FCY,Food Costs,50000,
|
||||
441,CANNED AND DRY,KETCHUP FCY,Dry Goods Costs,51500,
|
||||
442,PAPER & DISP,STRAW PLAS WRPD FLEX WHT 7.625,Paper Costs,55000,
|
||||
443,CANNED AND DRY,WATER SPRKLG CHRY/POMGRNT,Food Costs,50000,
|
||||
444,PAPER & DISP,APRON POLY EMBSD WHT 28X46 ECO,Paper Costs,55000,
|
||||
@@ -457,13 +457,13 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
455,SEAFOOD,CALAMARI TUBE & TNT 5-8 INCH,Seafood Costs,51130,
|
||||
456,SEAFOOD,SHRIMP CKD & PLD BAY 90\150 FZ,Seafood Costs,51130,
|
||||
457,SEAFOOD,SHRIMP WHT GH 16-20,Seafood Costs,51130,
|
||||
458,SUPP & EQUIP,BROOM ANGULAR FLAGGED,Food Costs,50000,
|
||||
458,SUPP & EQUIP,BROOM ANGULAR FLAGGED,Cleaning Supplies,74100,
|
||||
459,CANNED AND DRY,SAUCE CHILI SRIRACHA,Food Costs,50000,
|
||||
460,PAPER & DISP,CONTAINER PAPER #1 TK OUT KRFT,Paper Costs,55000,
|
||||
461,PAPER & DISP,SPOON PLAS BLK MEDHVY MDLNGTH,Paper Costs,55000,
|
||||
462,PAPER & DISP,LID PLAS F/ 12/16/21/24 CUPS,Paper Costs,55000,
|
||||
463,PAPER & DISP,GLOVE NITRILE FDSRV PF BLU XL,Paper Costs,55000,
|
||||
464,CANNED AND DRY,SODA ORANGE,Food Costs,50000,
|
||||
464,CANNED AND DRY,SODA ORANGE,Soft Beverage Cost,52000,
|
||||
465,PRODUCE,SQUASH ZUCCHINI MED FRSH,Produce Costs,51200,
|
||||
466,CANNED AND DRY,SPICE TURMERIC GRND ORGANIC,Food Costs,50000,
|
||||
467,CANNED AND DRY,WATER SPRING IMPORTED GLS,Food Costs,50000,
|
||||
@@ -474,9 +474,9 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
472,PAPER & DISP,GLOVE NITRILE BLK PEDRFREE LRG,Paper Costs,55000,
|
||||
473,PAPER & DISP,TRAY CARRYOUT 4CUP,Paper Costs,55000,
|
||||
474,PAPER & DISP,BAG PLAS T-SHRT THNKYOU12X7X22,Paper Costs,55000,
|
||||
475,PAPER & DISP,GRILL BRICK 3.5IN THICK,Paper Costs,55000,
|
||||
476,PAPER & DISP,PAD SCOUR GRN 6X9IN ANTIMICRO,Paper Costs,55000,
|
||||
477,PAPER & DISP,PAD SCRUB STNLS 50GR 1.75OZ,Paper Costs,55000,
|
||||
475,PAPER & DISP,GRILL BRICK 3.5IN THICK,Cleaning Supplies,74100,
|
||||
476,PAPER & DISP,PAD SCOUR GRN 6X9IN ANTIMICRO,Cleaning Supplies,74100,
|
||||
477,PAPER & DISP,PAD SCRUB STNLS 50GR 1.75OZ,Cleaning Supplies,74100,
|
||||
478,PAPER & DISP,LID TOGO PLAS F/12-16-32 OZ,Paper Costs,55000,
|
||||
479,PRODUCE,PEPPER GREEN BELL FRSH LG,Produce Costs,51200,
|
||||
480,DISPENSER BEVRG,SYRUP COLA WILD CHERRY,Soft Beverage Costs,52000,
|
||||
@@ -486,25 +486,25 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
484,PRODUCE,TOMATO ROMA FRESH,Produce Costs,51200,
|
||||
485,DAIRY PRODUCTS,YOGURT PLAIN GREEK WM 4% FAT,Dairy Costs,51300,
|
||||
486,CHEMICAL/JANTRL,CLEANER OVEN GREASESTRIP+ NP,Food Costs,50000,
|
||||
487,FROZEN,BUN BRIOCHE SLI 4.5,Food Costs,50000,
|
||||
487,FROZEN,BUN BRIOCHE SLI 4.5,Bread and Bun Costs,51400,
|
||||
488,PRODUCE,LEMON FRESH,Produce Costs,51200,
|
||||
489,SUPP & EQUIP,DISH SUPREME GLASS,Food Costs,50000,
|
||||
490,PAPER & DISP,CONTAINER POLYETHELYN,Paper Costs,55000,
|
||||
491,PAPER & DISP,FILTER GREASE CONE 10 IN,Paper Costs,55000,
|
||||
491,PAPER & DISP,FILTER GREASE CONE 10 IN,Cleaning Supplies,74100,
|
||||
492,PRODUCE,SQUASH ZUCCHINI FCY FRESH,Produce Costs,51200,
|
||||
493,CHEMICAL/JANTRL,CLEANER ALL PURPOSE PINE RTU,Food Costs,50000,
|
||||
494,CANNED AND DRY,KETCHUP PACKET FCY FOIL,Food Costs,50000,
|
||||
493,CHEMICAL/JANTRL,CLEANER ALL PURPOSE PINE RTU,Cleaning Supplies,74100,
|
||||
494,CANNED AND DRY,KETCHUP PACKET FCY FOIL,Dry Goods Costs,51500,
|
||||
495,PRODUCE,MUSHROOM PORTABELLA CP LRG FSH,Produce Costs,51200,
|
||||
496,PAPER & DISP,CONTAINER MINERAL 9X6 HNG 1CPT,Paper Costs,55000,
|
||||
497,PAPER & DISP,WRAP DRY WAX DELI HVY 10X10.75,Paper Costs,55000,
|
||||
498,PAPER & DISP,LID PLAS 12/16/22 OZ CUP,Paper Costs,55000,
|
||||
499,CHEMICAL/JANTRL,POLISH S-S SATIN SHINE ARSL,Food Costs,50000,
|
||||
500,CANNED AND DRY,SPICE MARJORAM LVS,Food Costs,50000,
|
||||
501,SUPP & EQUIP,BOTTLE PLASTIC SQUEEZE WIDEMTH,Food Costs,50000,
|
||||
502,PAPER & DISP,PAN FOIL STM TBL DEEPXH 2-9/16,Paper Costs,55000,
|
||||
499,CHEMICAL/JANTRL,POLISH S-S SATIN SHINE ARSL,Cleaning Supplies,74100,
|
||||
500,CANNED AND DRY,SPICE MARJORAM LVS,Dry Goods Costs,51500,
|
||||
501,SUPP & EQUIP,BOTTLE PLASTIC SQUEEZE WIDEMTH,Paperware Cost,55000,
|
||||
502,PAPER & DISP,PAN FOIL STM TBL DEEPXH 2-9/16,Dry Goods Costs,51500,
|
||||
503,PAPER & DISP,TONG PLAS BLK 6.25IN SM SRVING,Paper Costs,55000,
|
||||
504,PAPER & DISP,FORK PLAS WHT P/P,Paper Costs,55000,
|
||||
505,CHEMICAL/JANTRL,CLEANER DEGRSR HGH TMP GRL RTU,Food Costs,50000,
|
||||
505,CHEMICAL/JANTRL,CLEANER DEGRSR HGH TMP GRL RTU,Cleaning Supplies,74100,
|
||||
506,DISPENSER BEVRG,SYRUP BASE ORG CRSH BIB,Soft Beverage Costs,52000,
|
||||
507,PAPER & DISP,CONTAINER PAPER #4 TAKEOUT WHT,Paper Costs,55000,
|
||||
508,CANNED AND DRY,CAPER NONPAREIL IMPORTED,Food Costs,50000,
|
||||
@@ -515,7 +515,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
513,PAPER & DISP,FORK PLASTIC WRPD PP XHVY BLK,Paper Costs,55000,
|
||||
514,MEATS,LAMB RIBLET FZN,Meats Costs,51110,
|
||||
515,CANNED AND DRY,WATER BOTTLED,Food Costs,50000,
|
||||
516,CHEMICAL/JANTRL,SOAP HAND LIQ PINK RTU,Food Costs,50000,
|
||||
516,CHEMICAL/JANTRL,SOAP HAND LIQ PINK RTU,Cleaning Supplies,74100,
|
||||
517,PAPER & DISP,LINER TRASH 40X46 1.5 ML BLU,Paper Costs,55000,
|
||||
518,CANNED AND DRY,SYSCO CUSTOMER AGREEMENT,Food Costs,50000,
|
||||
519,CANNED AND DRY,HONEY WILDFLOWER BLOSSOM,Food Costs,50000,
|
||||
@@ -532,12 +532,12 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
530,CANNED AND DRY,JUICE APPLE GLASS FCY,Food Costs,50000,
|
||||
531,DAIRY PRODUCTS,MILK ALMOND BARISTA BLEND,Dairy Costs,51300,
|
||||
532,CANNED AND DRY,SPICE OREGANO LEAF,Food Costs,50000,
|
||||
533,CANNED AND DRY,SAUCE CHILI SRIRACHA CHA,Food Costs,50000,
|
||||
533,CANNED AND DRY,SAUCE CHILI SRIRACHA CHA,Dry Goods Costs,51500,
|
||||
534,CANNED AND DRY,OLIVE KALAMATA PTD PLAS KEG,Food Costs,50000,
|
||||
535,CANNED AND DRY,MUSTARD YELLOW PRPD,Food Costs,50000,
|
||||
536,CHEMICAL/JANTRL,CLEANER DEGRSR GREASELIFT RTU,Food Costs,50000,
|
||||
537,CANNED AND DRY,SALT PKT .6 GM,Food Costs,50000,
|
||||
538,CANNED AND DRY,SPICE PEPPER PACKET .1 GM,Food Costs,50000,
|
||||
538,CANNED AND DRY,SPICE PEPPER PACKET .1 GM,Dry Goods Costs,51500,
|
||||
539,PAPER & DISP,LINER ROLL COMPOST47X60 1ML,Paper Costs,55000,
|
||||
540,CANNED AND DRY,WATER SPRKLG ORG ARANCAT CAN,Food Costs,50000,
|
||||
541,DISPENSER BEVRG,SYRUP COKE DIET 5X1 BIB,Soft Beverage Costs,52000,
|
||||
@@ -564,7 +564,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
562,CHEMICAL/JANTRL,BLEACH LIQUID DISINFECT CLENER,Food Costs,50000,
|
||||
563,PAPER & DISP,CONTAINER PAPER FBR 9X6 1CPFF,Paper Costs,55000,
|
||||
564,CANNED AND DRY,SODA COKE MEXICO GLASS NON RET,Soft Beverage Costs,52000,
|
||||
565,CANNED AND DRY,SPICE CINNAMON STICK,Food Costs,50000,
|
||||
565,CANNED AND DRY,SPICE CINNAMON STICK,Dry Goods Costs,51500,
|
||||
566,CANNED AND DRY,WALNUT HALVES AND PCS,Food Costs,50000,
|
||||
567,DISPENSER BEVRG,TEA ICED CONC RASP 5.5+1,Soft Beverage Costs,52000,
|
||||
568,MEATS,BEEF GRND BULK 81/19 CHUB FRS,Beef/Pork Costs,51110,
|
||||
@@ -658,7 +658,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
656,PAPER & DISP,CUP PLAS TRANS 16OZ SOFT,Paper Costs,55000,
|
||||
657,PRODUCE,PARSLEY BUNCH FDSVC,Produce Costs,51200,
|
||||
658,PAPER & DISP,LINER TRASH 24X32 .5 ML BLK,Paper Costs,55000,
|
||||
659,CANNED AND DRY,SPICE CINNAMON GRND,Food Costs,50000,
|
||||
659,CANNED AND DRY,SPICE CINNAMON GRND,Dry Goods Costs,51500,
|
||||
660,PAPER & DISP,FORK PLAS HVY STY BLK,Paper Costs,55000,
|
||||
661,PAPER & DISP,SPOON PLAS PP HVY BLK FULL LEN,Paper Costs,55000,
|
||||
662,DAIRY PRODUCTS,EGG SHELL LG PAST CF,Dairy Costs,51300,
|
||||
@@ -797,7 +797,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
795,PAPER & DISP,SUPPLY ACCESSORIES SOTF COM,Paper Costs,55000,
|
||||
796,PAPER & DISP,LID PLAS CLR FLT W/SLT 12-24OZ,Paper Costs,55000,
|
||||
797,PAPER & DISP,TRAY PAPER PULP CARRYOUT 4 CUP,Paper Costs,55000,
|
||||
798,CHEMICAL/JANTRL,SANITIZER OASIS 146 MULTI QUAT,Food Costs,50000,
|
||||
798,CHEMICAL/JANTRL,SANITIZER OASIS 146 MULTI QUAT,Cleaning Supplies,74100,
|
||||
799,PAPER & DISP,CUP PLAS CLR TALL 8OZ RIGID,Paper Costs,55000,
|
||||
800,PAPER & DISP,BILLING MISC DISP,Paper Costs,55000,
|
||||
801,PAPER & DISP,LID FOIL F/ HALF STM TBL PAN,Paper Costs,55000,
|
||||
@@ -816,7 +816,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
814,CANNED AND DRY,FLOUR SEMOLINA UNBLCH,Food Costs,50000,
|
||||
815,CANNED AND DRY,SAUCE HOT SRIRACHA BLUE AGAVE,Food Costs,50000,
|
||||
816,PAPER & DISP,LINER TRASH 43X48 16 MC NAT,Paper Costs,55000,
|
||||
817,CANNED AND DRY,WALNUT HALF & PCS,Food Costs,50000,
|
||||
817,CANNED AND DRY,WALNUT HALF & PCS,Produce Costs,51200,
|
||||
818,MEATS,BEEF SHORT RIB ASIAN CUT 1/4,Beef/Pork Costs,51110,
|
||||
819,PAPER & DISP,LINER PLAS INSERT/WARMER 18X14,Paper Costs,55000,
|
||||
820,PAPER & DISP,BOX PIZZA 14 W/K B-FLT 1-7/8,Paper Costs,55000,
|
||||
@@ -877,7 +877,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
875,DAIRY PRODUCTS,CHEESE CREAM LIGHT CUP,Dairy Costs,51300,
|
||||
876,DAIRY PRODUCTS,YOGURT BLUEBERRY GREEK NON FAT,Dairy Costs,51300,
|
||||
877,HLTHCAR/HOSPITALITY,PERKS MEMBERSHIP FEE,Food Costs,50000,
|
||||
878,CANNED AND DRY,DRESSING RED WINE VINGRT METRO,Wine Costs,54400,
|
||||
878,CANNED AND DRY,DRESSING RED WINE VINGRT METRO,Dressing & Sauce Cost,51450,
|
||||
879,CANNED AND DRY,SPICE GARLIC PWDR,Food Costs,50000,
|
||||
880,CANNED AND DRY,SPICE ONION POWDER,Food Costs,50000,
|
||||
881,CANNED AND DRY,SALT KOSHER FLAKE COARSE,Food Costs,50000,
|
||||
@@ -925,7 +925,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
923,CANNED AND DRY,SAUCE WORCESTERSHIRE,Food Costs,50000,
|
||||
924,PAPER & DISP,CONTAINER PLAS 1C HNG 6X6 WHT,Paper Costs,55000,
|
||||
925,PRODUCE,PINEAPPLE FRESH,Produce Costs,51200,
|
||||
926,SUPP & EQUIP,MOP HEAD CTN CUT END VALUE #24,Food Costs,50000,
|
||||
926,SUPP & EQUIP,MOP HEAD CTN CUT END VALUE #24,Cleaning Supplies,74100,
|
||||
927,DAIRY PRODUCTS,CHEESE RICOTTA WMHM SEL,Dairy Costs,51300,
|
||||
928,MEATS,BACON LAYFLAT NT CC 13/17 PR12,Beef/Pork Costs,51110,
|
||||
929,CANNED AND DRY,COOKIE CRUMB OREO MED CRUNCH,Food Costs,50000,
|
||||
@@ -1005,7 +1005,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
1004,DAIRY PRODUCTS,CHEESE BLUE STUFFED OLIVES,Dairy Costs,51300,
|
||||
1005,SUPP & EQUIP,HANDLE MOP FIBRGLS QUICK CHNGE,Food Costs,50000,
|
||||
1006,SUPP & EQUIP,SUPPLY SOTF JANSAN,Food Costs,50000,
|
||||
1007,CANNED AND DRY,WALNUT HALVES & PCS,Food Costs,50000,
|
||||
1007,CANNED AND DRY,WALNUT HALVES & PCS,Produce Costs,51200,
|
||||
1008,CANNED AND DRY,WATER SPARKLN ORG PRCKLY PEAR,Beverages Costs,52000,
|
||||
1009,CANNED AND DRY,DRINK NATURAL CLMTN SPRKLG,Food Costs,50000,
|
||||
1010,CANNED AND DRY,DRESSING MIX RNCH BTRMK NO MSG,Food Costs,50000,
|
||||
@@ -1032,7 +1032,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
1031,CANNED AND DRY,DRESSING BALSAMIC VINGT GARLIC,Food Costs,50000,
|
||||
1032,CANNED AND DRY,SPREAD CHOC NUTELLA JAR FDSRV,Food Costs,50000,
|
||||
1033,CANNED AND DRY,SUGAR BROWN LIGHT,Food Costs,50000,
|
||||
1034,PAPER & DISP,PAD SCOUR 6X9 HVYDTY ANTIMICRO,Paper Costs,55000,
|
||||
1034,PAPER & DISP,PAD SCOUR 6X9 HVYDTY ANTIMICRO,Cleaning Supplies,74100,
|
||||
1035,CHEMICAL/JANTRL,DETERGENT POT/PAN LIQ GRN RTU,Food Costs,50000,
|
||||
1036,PAPER & DISP,KNIFE PLAS HVY STY BLK,Paper Costs,55000,
|
||||
1037,CHEMICAL/JANTRL,CLEANER DISINFECT PEROX RTU,Food Costs,50000,
|
||||
@@ -1111,7 +1111,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
1110,PAPER & DISP,BAG PAPER BRN W/HNDL REGAL 65#,Paper Costs,55000,
|
||||
1111,DAIRY PRODUCTS,EGG SHELL WHT CAGEFREE GR A LG,Dairy Costs,51300,
|
||||
1112,CANNED AND DRY,VINEGAR RICE SEASONED,Food Costs,50000,
|
||||
1113,CANNED AND DRY,VINEGAR WHITE DSTD 5%,Food Costs,50000,
|
||||
1113,CANNED AND DRY,VINEGAR WHITE DSTD 5%,Dry Goods Costs,51500,
|
||||
1114,SEAFOOD,SHRIMP WHT GH 13-15,Seafood Costs,51130,
|
||||
1115,PAPER & DISP,BAG PLAS PRTN 6.5X7 ORG SAT,Paper Costs,55000,
|
||||
1116,CANNED AND DRY,BREAD CRUMB JAP PANKO TOASTED,Food Costs,50000,
|
||||
@@ -1489,7 +1489,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
1488,POULTRY,CHICKEN CVP WHL WOG FZ,Chicken/ Poultry Costs,51120,
|
||||
1489,PRODUCE,LEEK BUNCH FRSH ICELS,Produce Costs,51200,
|
||||
1490,CANNED AND DRY,BILLING MISC CANNED/DRY,Food Costs,50000,
|
||||
1491,PAPER & DISP,PAN FOIL STEAM TBL HALF DEEP,Paper Costs,55000,
|
||||
1491,PAPER & DISP,PAN FOIL STEAM TBL HALF DEEP,Dry Goods Costs,51500,
|
||||
1492,PAPER & DISP,TRAY PAPER CARRIER 4 CUP,Paper Costs,55000,
|
||||
1493,PAPER & DISP,KIT CUTLERY FKS/SP/NP HW PP BK,Paper Costs,55000,
|
||||
1494,MEATS,BEEF PATTY 80/20 RND FRSH,Beef/Pork Costs,51110,
|
||||
@@ -1498,7 +1498,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
1497,DAIRY PRODUCTS,EGG HARDCOOKED CGFREE HARD PK,Dairy Costs,51300,
|
||||
1498,SEAFOOD,SHRIMP WHT P&D TLOF 26/30,Seafood Costs,51130,
|
||||
1499,PAPER & DISP,CONTAINER PAPER HNG 9X6 1C FBR,Paper Costs,55000,
|
||||
1500,CHEMICAL/JANTRL,DETERGENT POT & PAN LIQUID,Food Costs,50000,
|
||||
1500,CHEMICAL/JANTRL,DETERGENT POT & PAN LIQUID,Cleaning Supplies,74100,
|
||||
1501,FROZEN,ASPARAGUS SPEAR MED IQF P,Produce Costs,51200,
|
||||
1502,PRODUCE,ASPARAGUS FRESH STANDARD,Produce Costs,51200,
|
||||
1503,SUPP & EQUIP,SCREEN GRIDDLE 4X6IN,Food Costs,50000,
|
||||
@@ -1515,7 +1515,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
1514,PAPER & DISP,GLOVE SYNTHETIC FDSRV PF MED,Paper Costs,55000,
|
||||
1515,POULTRY,CHICKEN THIGH BNLS SKIN-ON RAW,Chicken/ Poultry Costs,51120,
|
||||
1516,CANNED AND DRY,SPICE SAGE GRND,Food Costs,50000,
|
||||
1517,CANNED AND DRY,KETCHUP SQUEEZE RED UPSIDE DWN,Food Costs,50000,
|
||||
1517,CANNED AND DRY,KETCHUP SQUEEZE RED UPSIDE DWN,Dry Goods Costs,51500,
|
||||
1518,CANNED AND DRY,SPICE NUTMEG WHL,Food Costs,50000,
|
||||
1519,DAIRY PRODUCTS,YOGURT PLAIN FULL FAT,Dairy Costs,51300,
|
||||
1520,CANNED AND DRY,VINEGAR WINE RED ITALY 6% GLS,Wine Costs,54400,
|
||||
@@ -1555,7 +1555,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
1554,FROZEN,BAKLAVA WALNT TRIANGLES,Food Costs,50000,
|
||||
1555,PAPER & DISP,DISPENSER NAP XPRSNP STND BLK,Paper Costs,55000,
|
||||
1556,PAPER & DISP,DISPENSER TOWEL MANUL COMP360,Paper Costs,55000,
|
||||
1557,PAPER & DISP,PAN FOIL STM TBL MED 2-3/16,Paper Costs,55000,
|
||||
1557,PAPER & DISP,PAN FOIL STM TBL MED 2-3/16,Dry Goods Costs,51500,
|
||||
1558,PRODUCE,ONION RED JUMBO CTN,Produce Costs,51200,
|
||||
1559,MEATS,BEEF CHUCK SHORTRIB KOREAN1/2,Beef/Pork Costs,51110,
|
||||
1560,FROZEN,RICE MEXICAN STY,Food Costs,50000,
|
||||
@@ -1653,7 +1653,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
1652,CANNED AND DRY,SODA COKE CHERRY ZERO CONTOUR,Soft Beverage Costs,52000,
|
||||
1653,FROZEN,ENTREE VEG FALAFEL BALLS VEGAN,Food Costs,50000,
|
||||
1654,SUPP & EQUIP,BRUSH GRILL W/SCRPR 27 IN HNDL,Food Costs,50000,
|
||||
1655,CANNED AND DRY,SAUCE HOT SRIRACHA HUY FONG,Food Costs,50000,
|
||||
1655,CANNED AND DRY,SAUCE HOT SRIRACHA HUY FONG,Dry Goods Costs,51500,
|
||||
1656,PAPER & DISP,TOWEL MULTIFOLD PRM LEAF,Paper Costs,55000,
|
||||
1657,CANNED AND DRY,SODA LEMON LIME 12OZ,Soft Beverage Costs,52000,
|
||||
1658,PAPER & DISP,KNIFE PLAS WRP BLK,Paper Costs,55000,
|
||||
@@ -1709,7 +1709,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
1708,PAPER & DISP,CONTAINER PLAS CLR HNG 8IN,Paper Costs,55000,
|
||||
1709,PAPER & DISP,TISSUE TOILET 2PL ADVC WHT WR,Paper Costs,55000,
|
||||
1710,SUPP & EQUIP,SPATULA RUBBER SILICONE 10.25,Food Costs,50000,
|
||||
1711,CANNED AND DRY,BEAN GARBANZO FCY NO SULFITE,Food Costs,50000,
|
||||
1711,CANNED AND DRY,BEAN GARBANZO FCY NO SULFITE,Dry Goods Costs,51500,
|
||||
1712,MEATS,BACON SLI APLWD 13/17CT PR12,Beef/Pork Costs,51110,
|
||||
1713,POULTRY,SAUSAGE CHICKEN APPLE RAW 1 OZ,Poultry Costs,51120,
|
||||
1714,CANNED AND DRY,TOMATO SUNDRIED JULENNE,Food Costs,50000,
|
||||
@@ -1759,38 +1759,73 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
1758,MEATS,PORK BELLY SKIN ON P12 COV,Beef/Pork Costs,51110,
|
||||
1759,MEATS,PORK SHANK BONE KUROBUTA PR12,Beef/Pork Costs,51110,
|
||||
1760,CANNED AND DRY,SEASONING ITALIAN WHL,Food Costs,50000,
|
||||
1761,PRODUCE,MUSHROOM PORTABELLA CAP 4-5,Produce Costs,51200,
|
||||
1761,PRODUCE,MUSHROOM PORTABELLA CAP 4-5,Produce Costs,51200,
|
||||
1762,PAPER & DISP,BAG PAPER 250 CT,Paper Costs,55000,
|
||||
1763,MEATS,BEEF SHLDR TERES MAJOR SEL,Beef/Pork Costs,51110,
|
||||
1764,PAPER & DISP,BOWL PLASTIC COATING 42 OZ,Paper Costs,55000,
|
||||
1765,PAPER & DISP,BOX CATERING 21X13X4.25 LOGO,Paper Costs,55000,
|
||||
1766,CANNED AND DRY,CANDY MILK CHOC SHELLS,Food Costs,50000,
|
||||
1767,CANNED AND DRY,CHOCOLATE DUBAI PISTCHO KUNFEH,Food Costs,50000,
|
||||
1766,CANNED AND DRY,CANDY MILK CHOC SHELLS,Dry Goods Costs,51500,
|
||||
1767,CANNED AND DRY,CHOCOLATE DUBAI PISTCHO KUNFEH,Dry Goods Costs,51500,
|
||||
1768,PAPER & DISP,CONTAINER PAPER 1/30 OZ NTG,Paper Costs,55000,
|
||||
1769,PAPER & DISP,CONTAINER PAPER 4/110OZ NTG,Paper Costs,55000,
|
||||
1770,PAPER & DISP,CUP PAPER COLD 22 OZ LOGO NTG,Paper Costs,55000,
|
||||
1771,PAPER & DISP,CUP PORTION PLAS CLR 1.50 OZ,Paper Costs,55000,
|
||||
1772,CANNED AND DRY,DESSERT CUP,Food Costs,50000,
|
||||
1773,FROZEN,DESSERT MINI PLAIN BEIGNET,Food Costs,50000,
|
||||
1774,CANNED AND DRY,DIP GARLIC TOUM,Food Costs,50000,
|
||||
1772,PAPER & DISP,DESSERT CUP,Paper Costs,55000,
|
||||
1773,FROZEN,DESSERT MINI PLAIN BEIGNET,Bread and Bun Costs,51400,
|
||||
1774,CANNED AND DRY,DIP GARLIC TOUM,Dressing & Sauce Cost,51450,
|
||||
1775,CANNED AND DRY,DRINK ENERGY ORANGE SPRKLNG,Soft Beverage Costs,52000,
|
||||
1776,CANNED AND DRY,DRINK ENERGY PEACH VIBE SPRKLG,Soft Beverage Costs,52000,
|
||||
1777,CANNED AND DRY,DRINK ENERGY TROPICAL VIBE,Soft Beverage Costs,52000,
|
||||
1778,PAPER & DISP,FILM PVC 18X2000 ROLL,Paper Costs,55000,
|
||||
1779,CANNED AND DRY,JUICE CONC MANDARIN CARDAMOM,Food Costs,50000,
|
||||
1780,CANNED AND DRY,JUICE CONC STRAWB DRAGON,Food Costs,50000,
|
||||
1779,CANNED AND DRY,JUICE CONC MANDARIN CARDAMOM,Soft Beverage Cost,52000,
|
||||
1780,CANNED AND DRY,JUICE CONC STRAWB DRAGON,Soft Beverage Cost,52000,
|
||||
1781,PAPER & DISP,LID CLEAR PET 42 OZ,Paper Costs,55000,
|
||||
1782,PAPER & DISP,LID DOME DESSERT CUP,Paper Costs,55000,
|
||||
1783,PAPER & DISP,NAPKIN 2PLY INTR FOLD 6.3X8.26,Paper Costs,55000,
|
||||
1784,CANNED AND DRY,PASTE HERB HARISSA MOROCCAN,Food Costs,50000,
|
||||
1785,CANNED AND DRY,PASTE TAHINI DRESSING,Food Costs,50000,
|
||||
1786,FROZEN,PASTRY BEIGNET MN FLD CHOCCRML,Food Costs,50000,
|
||||
1787,CANNED AND DRY,PEPPER BANANA MILD RING,Food Costs,50000,
|
||||
1788,CANNED AND DRY,RICE MIX NICKS,Food Costs,50000,
|
||||
1784,CANNED AND DRY,PASTE HERB HARISSA MOROCCAN,Dressing & Sauce Cost,51450,
|
||||
1785,CANNED AND DRY,PASTE TAHINI DRESSING,Dressing & Sauce Cost,51450,
|
||||
1786,FROZEN,PASTRY BEIGNET MN FLD CHOCCRML,Bread and Bun Costs,51400,
|
||||
1787,CANNED AND DRY,PEPPER BANANA MILD RING,Produce Costs,51200,
|
||||
1788,CANNED AND DRY,RICE MIX NICKS,Dry Goods Costs,51500,
|
||||
1789,CANNED AND DRY,SODA CHERRY VISSINADA GREEK,Soft Beverage Costs,52000,
|
||||
1790,CANNED AND DRY,SODA COLA PEPSI ZERO SUGAR,Soft Beverage Costs,52000,
|
||||
1791,CANNED AND DRY,SODA PEPSI COLA,Soft Beverage Costs,52000,
|
||||
1792,FROZEN,SPANAKOPITA SPINACH COOKED,Food Costs,50000,
|
||||
1792,FROZEN,SPANAKOPITA SPINACH COOKED,Bread and Bun Costs,51400,
|
||||
1793,PAPER & DISP,SPOON PLAS TEA PP X-HVY BLK,Paper Costs,55000,
|
||||
1794,PAPER & DISP,WRAP PAPER 14X14 LOGO VER2,Paper Costs,55000,
|
||||
1795,DAIRY PRODUCTS,YOGURT FRZN NF NICK THE GREEK,Dairy Costs,51300,
|
||||
1796,FROZEN,BALL FALAFEL FRTTR 1 OZ IQF,Dry Goods Costs,51500,
|
||||
1797,SUPP & EQUIP,BASKET PLAS 10.5X7X1.5 BLK,Paperware Cost,55000,
|
||||
1798,CANNED AND DRY,BEAN GARBANZO LOW SODIUM,Dry Goods Costs,51500,
|
||||
1799,FROZEN,BREAD POTATO ROLL 4 INCH,Bread and Bun Costs,51400,
|
||||
1800,FROZEN,BUN HAMBURGER 4IN 1.75 OZ,Bread and Bun Costs,51400,
|
||||
1801,DAIRY PRODUCTS,CHEESE FETA CRUMBLES,Dairy Costs,51300,
|
||||
1802,FROZEN,CHEESE STICK HALLOUMI STYL,Dairy Costs,51300,
|
||||
1803,POULTRY,CHICKEN CVP THGH B/S HALAL,Chicken/ Poultry Costs,51120,
|
||||
1804,CHEMICAL/JANTRL,CLEANER DEGREASER CONCENTR RTU,Cleaning Supplies,74100,
|
||||
1805,CHEMICAL/JANTRL,CLEANER DEGREASER GRSELFT RTU,Cleaning Supplies,74100,
|
||||
1806,,CONTAINER PAPER CUSTOM LOGO8X5,Paperware Cost,55000,
|
||||
1807,,CONTAINER PAPER FBR 9X6 1C WHT,Paperware Cost,55000,
|
||||
1808,,DRESSING RANCH SPICY,Dairy Costs,51300,
|
||||
1809,,FILM PVC ROLL CRYS 2000 FT,Paperware Cost,55000,
|
||||
1810,,FORK PLASTIC SERVING BLK 10IN,Paperware Cost,55000,
|
||||
1811,DAIRY PRODUCTS,ICE CREAM COOKIE&CREAM,Dairy Costs,51300,
|
||||
1812,DAIRY PRODUCTS,ICE CREAM STRAWBERRY,Dairy Costs,51300,
|
||||
1813,DAIRY PRODUCTS,ICE CREAM VAN QUICK BLEND,Dairy Costs,51300,
|
||||
1814,DISPENSER BEVRG,JUICE CONC BERRY PATCH ORG,Soft Beverage Cost,52000,
|
||||
1815,CANNED AND DRY,JUICE LEMON PLAS RTU,Produce Costs,51200,
|
||||
1816,PRODUCE,KALE CHOPPED,Produce Costs,51200,
|
||||
1817,PRODUCE,KALE FRESH,Produce Costs,51200,
|
||||
1818,CANNED AND DRY,KETCHUP FCY POUCH EQUALS 6/10#,Dry Goods Costs,51500,
|
||||
1819,PRODUCE,LEMON FRESH BAGGED,Produce Costs,51200,
|
||||
1820,CANNED AND DRY,OIL OLIVE SOYBEAN BLEND 75/25,Dry Goods Costs,51500,
|
||||
1821,PRODUCE,ONION WHITE JUMBO BAG,Produce Costs,51200,
|
||||
1822,CANNED AND DRY,PEPPER BANANA MILD RING 7-9HUN,Produce Costs,51200,
|
||||
1823,CANNED AND DRY,PEPPER BANANA RING,Produce Costs,51200,
|
||||
1824,FROZEN,POTATO FRY 1/4 SS XLF PHANTM,Produce Costs,51200,
|
||||
1825,,SANITIZER NO RINSE QUORUM,Cleaning Supplies,74100,
|
||||
1826,CANNED AND DRY,SODA ROOT BEER CUBE,Soft Beverage Cost,52000,
|
||||
1827,DISPENSER BEVRG,SYRUP FRUIT PUNCH BIB FLASHIN,Soft Beverage Cost,52000,
|
||||
1828,DISPENSER BEVRG,SYRUP ORANGE 5X1 BIB,Soft Beverage Cost,52000,
|
||||
1829,PRODUCE,TOMATO BULK 5X5 FRSH,Produce Costs,51200,
|
||||
1830,PRODUCE,TOMATO GRAPE FRSH,Produce Costs,51200,
|
||||
|
||||
|
43
resources/sysco_recode/bad.csv
Normal file
43
resources/sysco_recode/bad.csv
Normal file
@@ -0,0 +1,43 @@
|
||||
,,,,,,,,,,,,,,,,,,,,,,,,,d,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,20.56,CA,20.56,Y,0,0,0,1.2,07,48,02,01,CANNED AND DRY,24,20OZ,AQUAFIN,WATER PURIFIED BTL PET LSE DW,30,33,0.75,24,,000000,,0000,,,,29115,47,,8492330,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,14.84,CA,14.84,Y,0,0,0,0.6,07,16,05,01,CANNED AND DRY,12,11.2OZ,LOUX,SODA CHERRY VISSINADA GRK PLAS,9.5,10.5,0.26,12,,000000,,0000,,,3000P,808959,01,,7189422,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,14.93,CA,14.93,Y,0,0,0,0.6,07,16,05,01,CANNED AND DRY,12,8 OZ,LOUX,SODA LEMON LEMONADA GREEK,9,11.5,0.26,12,,000000,,0000,,,3200,808959,01,,9910355,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,79.83,CA,79.83,Y,0,0,0,0,07,35,03,99,CANNED AND DRY,4,5 LB,OTHRYS,SPICE OREGANO LEAF RUBBED,20,22,2.51,4,,000000,,0000,,,62760,808959,01,,9911236,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,31.07,CA,31.07,Y,0,0,0,0,07,35,99,99,CANNED AND DRY,22,4.68OZ,HI WEST,RICE MIX NICKS,6.43,7,0.16,22,,000000,,0000,,,30-5729,345717,03,,7301949,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,2,2,0,54.84,CA,109.68,Y,0,0,0,0,07,33,01,99,CANNED AND DRY,2,20 LB,ROYAL,RICE BASMATI PABROIL SELA CS,40,40.6,1.21,2,,000000,,0000,,,91000244,26992,43,,7053293,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,75.49,CA,75.49,Y,0,0,0,0,07,34,04,99,CANNED AND DRY,4,1 GAL,NICKGRK,DRESSING VINAIGRETTE LOGO,33,35,0.87,4,,000000,,0000,,,1654,853,01,,7108399,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,29.4,CA,29.4,Y,0,0,0,0,07,36,99,99,CANNED AND DRY,8,15 OZ,HAIG'S,DIP GARLIC TOUM,7.25,8.25,0.34,8,,000000,,0000,,,8PGD16,691816,01,,7360056,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,38.85,CA,38.85,Y,0,0,0,0,07,37,02,02,CANNED AND DRY,1,35 LB,BEOCO,OIL CORN,35,36.55,0.85,1,,000000,,0000,,,,9846,02,,4823761,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,2,2,0,56.72,CA,113.44,Y,0,0,0,0,07,36,99,99,CANNED AND DRY,4,4 LB,GRECDEL,SPREAD HUMMUS TRADITIONAL,16,17,0.62,4,,000000,,0000,,,HU000083,1533,19,,7278619,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,-7.22,CA,-7.32,Y,0,0,0,0,07,86,01,99,CANNED AND DRY,1,EA,NONPROD,ALLOWANCE FOR DROP SIZE,0.01,0.01,0.01,1,,000000,,0000,,,,,01,,9477498,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,4.17,CA,4.17,Y,0,0,0,0,07,86,01,99,CANNED AND DRY,1,EA,NONPROD,CHGS FOR FUEL SURCHARGE,1,1,0,1,,000000,,0000,,,,,01,,6592893,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,2,2,0,95.98,CA,191.96,Y,0,0,0,0,02,04,99,99,DAIRY PRODUCTS,1,5 GAL,NICKGRK,SAUCE TZATZIKI,42,43.5,1.2,1,,000000,,0000,,,SA000084,1533,19,,7213639,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,2,2,0,54.82,CA,109.64,Y,0,0,0,0,02,10,01,99,DAIRY PRODUCTS,4,1 GAL,NICKGRK,YOGURT FRZN NF NICK THE GREEK,39.9,39.9,0.97,4,,000000,,0000,,,13101,379887,05,,7302646,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,172.37,CA,172.37,Y,0,0,0,0,12,08,02,03,DISPENSER BEVRG,12,32 OZ,TRACTOR,JUICE CONC STRAWB DRAGON,24,25.5,0.58,12,,000000,,0000,,,6555,693956,01,,7206974,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,62.48,CA,62.48,Y,0,0,0,0,12,08,02,03,DISPENSER BEVRG,1,2.5GAL,DR PEPR,SYRUP DR PPR DIET BIB,20.93,21.82,0.47,1,,000000,,0000,,,12115,376510,09,,7459969,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,117.3,CA,117.3,Y,0,0,0,0,12,08,02,03,DISPENSER BEVRG,1,5GAL,DR PEPR,SYRUP DR PEPPER BIB,40,54.4,0.83,1,,000000,,0000,,,12109,9562,14,,4273553,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,8,8,0,26.36,CA,210.88,Y,0,0,0,0,06,02,45,99,FROZEN,12,10 CT,KONTOS,BREAD PITA GYRO PRE-OILED 7,21,24,1.65,12,,000000,,0000,,,10005,25370,01,,5223334,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,2,2,0,69.37,CA,138.74,Y,0,0,0,0,06,01,70,99,FROZEN,36,6 OZ,HELLAS,SPANAKOPITA SPINACH COOKED,12.4,13.4,0.62,36,,000000,,0000,,,216312,32248,01,,7455027,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,2,2,0,49.53,CA,99.06,Y,0,0,0,0,06,01,60,99,FROZEN,2,24 CT,HELLAS,BAKLAVA CLASSIC 2X24,9.6,10.6,0.46,2,,000000,,0000,,,100224,32248,01,,7187055,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,57.66,CA,57.66,Y,0,0,0,0,06,01,65,99,FROZEN,140,0.7 OZ,CHICPAT,DESSERT MINI PLAIN BEIGNET,6.17,7.5,0.88,140,,000000,,0000,,,540061,1188,53,,7212299,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,97.94,CA,97.94,Y,0,0,0,0,06,02,01,99,FROZEN,4,10 LB,NICKGRK,APTZR VEG FALAFEL PUCK HALAL,40,42,2.03,4,,000000,,0000,,,FA000090,1533,05,,7274591,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,1,53.5,1,53.5,7.556,LB,404.25,Y,0,0,0,0,03,02,01,13,MEATS,5,10.5#,TWORVRS,BEEF SHLDR TERES MAJOR SEL,53,55,1.99,5,,000000,,0000,,,B83003,527004,03,,0932867,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,87.76,CA,87.76,Y,0,0,0,0,03,04,99,99,MEATS,1,20 LB,GRECDEL,PORK SLI GYRO CONE,20,21,0.77,1,,000000,,0000,,,ME000215,1533,05,,7211838,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,7,7,0,92.53,CA,647.71,Y,0,0,0,0,03,02,04,99,MEATS,1,30 LB,NICKGRK,MEAT GYRO BEEF CONE NTG,30,31,0.97,1,,000000,,0000,,,ME000071,1533,05,,9906087,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,25.41,CA,25.41,Y,0,0,0,0,08,42,64,43,PAPER & DISP,20,50 CT,KARAT,LID PLAS FLAT F/12-22 OZ,5.75,7,1.94,20,,000000,,0000,,,C-KCL90,461672,05,,7661388,00000000000000,,,260402,04671945,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,22.32,CA,22.32,Y,0,0,0,0,08,60,99,99,PAPER & DISP,24,250 CT,ELEMEN,NAPKIN 2PLY INTR FOLD 6.3X8.26,16.1,16.8,1.55,24,,000000,,0000,,,11904,613310,01,,7452585,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,26.15,CA,26.15,Y,0,0,0,0,08,21,64,62,PAPER & DISP,50,50CT,KARAT,CUP PORTION PLAS CLR 1.50 OZ,10,10,1.36,50,,000000,,0000,,,FP-P150-PP,461672,05,,4613026,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,43.99,CA,43.99,Y,0,0,0,0,08,75,03,04,PAPER & DISP,6,50 EA,NATZWAY,BOWL PLASTIC COATING 42 OZ,14.55,17.19,3.56,6,,000000,,0000,,,10205,773772,01,,7408008,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,41.25,CA,41.25,Y,0,0,0,0,08,42,99,99,PAPER & DISP,6,50CT,NATZWAY,LID CLEAR PET 42 OZ,8.59,10.47,2.01,6,,000000,,0000,,,10206,773772,01,,7408215,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,47.6,CA,47.6,Y,0,0,0,0,08,21,56,99,PAPER & DISP,1000,22 OZ,NICKGRK,CUP PAPER COLD 22 OZ LOGO NTG,31.96,34.62,3.78,1000,,000000,,0000,,,810161542703,461672,05,,7354127,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,30.6,CA,30.6,Y,0,0,0,0,08,18,56,99,PAPER & DISP,1,450 CT,NICKGRK,CONTAINER PAPER 1/30 OZ NTG,23,25,3.25,1,,000000,,0000,,,810161542673,461672,05,,7354120,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,27.6,CA,27.6,Y,0,0,0,0,08,18,56,99,PAPER & DISP,1,160 CT,NICKGRK,CONTAINER PAPER 4/110OZ NTG,19.6,21.4,3.59,1,,000000,,0000,,,810161542680,461672,05,,7354119,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,3,3,0,37.6,CA,112.8,Y,0,0,0,0,08,18,02,12,PAPER & DISP,2,100CT,NATZWAY,CONTAINER PAPER MLD FBR 9X6,18,18,1.28,2,,000000,,0000,,,10042,773772,01,,7250678,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,2,2,0,46.07,CA,92.14,Y,0,0,0,0,08,09,56,99,PAPER & DISP,1,250BAG,NICKGRK,BAG PAPER 250 CT,14.5,15,2.18,1,,000000,,0000,,,,773772,01,,7417242,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,1,,1,1,0,24.46,EA,24.46,Y,0,0,0,0,08,36,56,79,PAPER & DISP,5,1000,BAGCRFT,WRAP DELI WHT 12X12 GRS RESIST,37,37,1.07,5,,000000,,0000,,,P057012,276,01,,5723808,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,39.41,CA,39.41,Y,3.85,0,0,0,08,06,25,10,PAPER & DISP,10,100CT,DHGPROF,GLOVE NITRILE BLK PEDRFREE LRG,12.21,12.21,0.66,10,,000000,,0000,,,DNGB-L,613310,01,,7296407,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,3,3,0,89.32,CA,267.96,Y,0,0,0,0,05,01,01,07,POULTRY,4,10 LB,SYS CLS,CHICKEN CVP THIGH BNLS SKLS,40,42,1.04,4,,000000,,0000,,,14301,3254,21,,7792187,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,9,9,0,86.97,CA,782.73,Y,0,0,0,0,05,02,01,99,POULTRY,1,20LB,GRECDEL,GYRO CHICKEN SHAWARMA CONE,20,21,0.77,1,,000000,,0000,,,ME000102,1533,05,,7124188,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,5,5,0,23.65,CA,118.25,Y,0,0,0,0,11,02,23,01,PRODUCE,1,50 LB,PACKER,POTATO KENNEBEC FRESH,50,52,2,1,,000000,,0000,,,,696760,01,,2039220,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,HDR,,,CKC CONCORD INC,,260402,,,,Rolling 8,,NICK THE GREEK CONCORD,2075 DIAMOND BLVD,STE H-103,CONCORD,CA,94520-582,408593,000000000,,,,,BBNKG,0,050,SYSCO SAN FRANCISCO,5900 STEWART AVENU,,FREMONT,CA,94538,,,,,,,1372486,4024,004,0000000,00000000,20260529,6.25,CRO8
|
||||
EEK,,050,00175469,850081745,SUM,,,40,0,0,74,0,4625.36,6.25,00000463161,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
|
271
scratch-sessions/sysco_recode_scratch.clj
Normal file
271
scratch-sessions/sysco_recode_scratch.clj
Normal file
@@ -0,0 +1,271 @@
|
||||
;; =====================================================================
|
||||
;; ONE-OFF SCRATCH — re-code already-imported Sysco invoices after fixing
|
||||
;; resources/sysco_line_item_mapping.csv.
|
||||
;;
|
||||
;; Context: the Sysco importer codes each line item by EXACT description
|
||||
;; match against sysco_line_item_mapping.csv, defaulting to GL 50000 when a
|
||||
;; description is missing (auto-ap.jobs.sysco/get-line-account). Missing
|
||||
;; PAPER & DISP (and other) descriptions landed in 50000 (Food Costs)
|
||||
;; instead of their real accounts (e.g. 55000 Paper Costs). The mapping is
|
||||
;; now fixed; this re-derives the correct split from each invoice's source
|
||||
;; CSV and rewrites :invoice/expense-accounts.
|
||||
;;
|
||||
;; Design:
|
||||
;; - Recode EVERY invoice found in the CSV resource (a Sysco file may batch
|
||||
;; several invoices; they're grouped by InvoiceNumber).
|
||||
;; - Build ONE transaction covering every invoice, emitting only the datoms
|
||||
;; that actually change:
|
||||
;; * reuse an existing invoice-expense-account when its account (and
|
||||
;; location) already match, updating just :amount when it differs;
|
||||
;; * add a child for an account that has no row yet;
|
||||
;; * retract a child whose account is no longer in the corrected split;
|
||||
;; * emit nothing for rows already correct.
|
||||
;; - Validate with (dc/with db changes): apply the tx to an in-memory db
|
||||
;; value and assert every affected invoice's expense-account amounts sum
|
||||
;; to its :invoice/total BEFORE committing for real.
|
||||
;; - After committing, touch the ledger for every affected invoice. This is
|
||||
;; a SEPARATE transaction on purpose: :upsert-invoice rebuilds the journal
|
||||
;; entry from the invoice's expense-accounts as seen in db-before, so it
|
||||
;; must run after the recode is committed.
|
||||
;;
|
||||
;; DO NOT load/evaluate this whole file. Step through the (comment ...) forms
|
||||
;; one at a time in a connected REPL; the commit + ledger steps are gated #_.
|
||||
;;
|
||||
;; PRECONDITIONS
|
||||
;; - The deployed artifact ships the fixed sysco_line_item_mapping.csv AND
|
||||
;; the invoice CSV at resources/sysco_recode/<file>.csv (io/resource).
|
||||
;; - You are connected to the DB you intend to mutate (prod conn!).
|
||||
;; =====================================================================
|
||||
|
||||
(comment
|
||||
|
||||
(require '[auto-ap.jobs.sysco :as sysco]
|
||||
'[auto-ap.datomic :refer [conn audit-transact random-tempid]]
|
||||
'[auto-ap.utils :refer [dollars=]]
|
||||
'[auto-ap.time :as t]
|
||||
'[clj-time.coerce :as coerce]
|
||||
'[clojure.data.csv :as csv]
|
||||
'[clojure.java.io :as io]
|
||||
'[datomic.api :as dc])
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 0 — reload the mapping cache so the corrected CSV is in effect.
|
||||
;; ------------------------------------------------------------------
|
||||
(reset! sysco/sysco-name->line nil)
|
||||
(count (sysco/get-sysco->line))
|
||||
|
||||
;; sanity: a previously-missing paper description now resolves to 55000.
|
||||
(dc/pull (dc/db conn) [:account/numeric-code :account/name]
|
||||
(sysco/get-line-account "BAG PAPER 250 CT")) ; => 55000
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; Helpers
|
||||
;; ------------------------------------------------------------------
|
||||
(defn read-csv-rows
|
||||
"Reads the invoice CSV from the classpath (so it ships with the deploy).
|
||||
`resource-path` is relative to a resources/ root, e.g. \"sysco_recode/bad.csv\"."
|
||||
[resource-path]
|
||||
(with-open [r (io/reader (or (io/resource resource-path)
|
||||
(throw (ex-info "CSV not found on classpath"
|
||||
{:resource-path resource-path}))))]
|
||||
(doall (csv/read-csv r))))
|
||||
|
||||
(defn parse-date
|
||||
"Sysco yyMMdd string -> java.util.Date, the same way the importer stores
|
||||
:invoice/date (auto-ap.jobs.sysco/extract-invoice-details)."
|
||||
[yymmdd]
|
||||
(coerce/to-date (t/parse yymmdd "yyMMdd")))
|
||||
|
||||
(defn group-invoices
|
||||
"Split a (possibly multi-invoice) Sysco CSV into one entry per invoice.
|
||||
Groups DET/HDR/SUM rows by InvoiceNumber (index 4); date comes from the
|
||||
group's HDR row InvoiceDate (index 10)."
|
||||
[rows]
|
||||
(->> rows
|
||||
(filter #(contains? #{"DET" "HDR" "SUM"} (nth % 5 nil)))
|
||||
(group-by #(nth % 4))
|
||||
(mapv (fn [[number grp]]
|
||||
(let [hdr (first (filter #(= "HDR" (nth % 5)) grp))]
|
||||
{:invoice-number number
|
||||
:date-str (some-> hdr (nth 10))
|
||||
:rows grp})))))
|
||||
|
||||
(defn desired-split
|
||||
"Rows of one Sysco invoice -> {account-eid -> amount-double}, using the
|
||||
CURRENT (fixed) mapping. DET rows only (record-type at index 5); tax
|
||||
(SUM row, TotalTaxAmount index 14) added to the same account the
|
||||
importer uses for \"TAX\". Mirrors auto-ap.jobs.sysco/code-individual-items."
|
||||
[rows]
|
||||
(let [det (filter #(= "DET" (nth % 5)) rows)
|
||||
sum-row (first (filter #(= "SUM" (nth % 5)) rows))
|
||||
tax (some-> sum-row (nth 14) Double/parseDouble)
|
||||
by-acct (reduce
|
||||
(fn [acc row]
|
||||
(update acc
|
||||
(sysco/get-line-account (nth row sysco/item-name-index))
|
||||
(fnil + 0.0)
|
||||
(Double/parseDouble (nth row sysco/item-price-index))))
|
||||
{}
|
||||
det)]
|
||||
(cond-> by-acct
|
||||
(and tax (not (zero? tax)))
|
||||
(update (sysco/get-line-account "TAX") (fnil + 0.0) tax))))
|
||||
|
||||
(defn resolve-eid
|
||||
"Match on invoice-number AND date (belt-and-suspenders). Asserts a unique
|
||||
hit so we never recode the wrong invoice."
|
||||
[invoice-number date]
|
||||
(let [ids (mapv first (dc/q '[:find ?i :in $ ?n ?d
|
||||
:where
|
||||
[?i :invoice/invoice-number ?n]
|
||||
[?i :invoice/date ?d]]
|
||||
(dc/db conn) invoice-number date))]
|
||||
(assert (>= 1 (count ids))
|
||||
(str "multiple invoices match " invoice-number " / " date ": " ids))
|
||||
(first ids)))
|
||||
|
||||
(defn invoice-change-datoms
|
||||
"Minimal tx-data to make invoice `eid`'s expense-account split equal
|
||||
`desired` ({account-eid -> amount}). Returns [] when already correct."
|
||||
[db eid desired]
|
||||
(let [existing (:invoice/expense-accounts
|
||||
(dc/pull db [{:invoice/expense-accounts
|
||||
[:db/id :invoice-expense-account/amount
|
||||
:invoice-expense-account/location
|
||||
{:invoice-expense-account/account [:db/id]}]}]
|
||||
eid))
|
||||
loc (or (some :invoice-expense-account/location existing) "HQ")
|
||||
;; one child per account expected; index by account, retract any dupes
|
||||
by-acct (group-by #(get-in % [:invoice-expense-account/account :db/id]) existing)
|
||||
one (into {} (map (fn [[a cs]] [a (first cs)])) by-acct)
|
||||
dupes (mapcat (fn [[_ cs]] (map :db/id (rest cs))) by-acct)
|
||||
wanted (set (keys desired))
|
||||
upserts (keep (fn [[acct amt]]
|
||||
(let [child (get one acct)]
|
||||
(cond
|
||||
;; new account -> accrete a child under the invoice
|
||||
(nil? child)
|
||||
{:db/id eid
|
||||
:invoice/expense-accounts
|
||||
[#:invoice-expense-account{:db/id (random-tempid)
|
||||
:account acct
|
||||
:location loc
|
||||
:amount amt}]}
|
||||
;; right account, wrong value -> reuse, set amount
|
||||
;; (and fix location if it drifted)
|
||||
(or (not (dollars= (:invoice-expense-account/amount child) amt))
|
||||
(not= (:invoice-expense-account/location child) loc))
|
||||
(cond-> {:db/id (:db/id child)
|
||||
:invoice-expense-account/amount amt}
|
||||
(not= (:invoice-expense-account/location child) loc)
|
||||
(assoc :invoice-expense-account/location loc))
|
||||
;; already correct -> nothing
|
||||
:else nil)))
|
||||
desired)
|
||||
retracts (for [[acct child] one :when (not (wanted acct))]
|
||||
[:db/retractEntity (:db/id child)])]
|
||||
(vec (concat upserts
|
||||
retracts
|
||||
(map (fn [id] [:db/retractEntity id]) dupes)))))
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 1 — point at the CSV. EVERY invoice in this file gets recoded.
|
||||
;; Place the file under resources/ (e.g. resources/sysco_recode/bad.csv)
|
||||
;; and commit it so it's on the classpath of the deployed artifact.
|
||||
;; ------------------------------------------------------------------
|
||||
(def csv-path "sysco_recode/bad.csv")
|
||||
(def rows (read-csv-rows csv-path))
|
||||
|
||||
(def invoices (group-invoices rows))
|
||||
(mapv (juxt :invoice-number :date-str) invoices) ;; what we found in the file
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 2 — resolve each invoice (number + date) and compute its split.
|
||||
;; ------------------------------------------------------------------
|
||||
(def plan
|
||||
(mapv (fn [{:keys [invoice-number date-str rows]}]
|
||||
(let [date (parse-date date-str)]
|
||||
{:invoice-number invoice-number
|
||||
:date date
|
||||
:eid (resolve-eid invoice-number date)
|
||||
:desired (desired-split rows)}))
|
||||
invoices))
|
||||
|
||||
;; bail if any invoice number didn't resolve
|
||||
(assert (every? :eid plan)
|
||||
(str "unresolved invoices: "
|
||||
(mapv (juxt :invoice-number :date) (remove :eid plan))))
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 3 — build the SINGLE changes-only transaction across all invoices.
|
||||
;; ------------------------------------------------------------------
|
||||
(def changes
|
||||
(let [db (dc/db conn)]
|
||||
(vec (mapcat (fn [{:keys [eid desired]}] (invoice-change-datoms db eid desired))
|
||||
plan))))
|
||||
|
||||
(count changes) ;; how many datoms we're actually changing
|
||||
changes ;; inspect the full minimal tx
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 4 — validate with dc/with: apply the tx to an in-memory db value
|
||||
;; and confirm every affected invoice still balances (sum of expense
|
||||
;; account amounts == :invoice/total).
|
||||
;; ------------------------------------------------------------------
|
||||
(def preview (dc/with (dc/db conn) changes))
|
||||
|
||||
(def balance-report
|
||||
(let [db-after (:db-after preview)]
|
||||
(mapv (fn [{:keys [eid invoice-number]}]
|
||||
(let [inv (dc/pull db-after
|
||||
[:invoice/total
|
||||
{:invoice/expense-accounts [:invoice-expense-account/amount]}]
|
||||
eid)
|
||||
s (reduce + 0.0 (map :invoice-expense-account/amount
|
||||
(:invoice/expense-accounts inv)))]
|
||||
{:invoice-number invoice-number
|
||||
:total (:invoice/total inv)
|
||||
:ea-sum s
|
||||
:ok? (dollars= s (:invoice/total inv))}))
|
||||
plan)))
|
||||
balance-report
|
||||
|
||||
;; HARD GATE — do not continue unless every invoice balances post-change.
|
||||
(assert (every? :ok? balance-report)
|
||||
(str "unbalanced after change: "
|
||||
(filterv (complement :ok?) balance-report)))
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 5 — COMMIT the recode (gated). One transaction, changes only.
|
||||
;; ------------------------------------------------------------------
|
||||
#_(audit-transact changes
|
||||
{:user/name "sysco recode (missing GL mappings fix)"
|
||||
:user/role "admin"})
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 6 — touch the ledger for every affected invoice (separate tx;
|
||||
;; :upsert-invoice rebuilds the journal entry from the now-committed
|
||||
;; expense-accounts). Gated.
|
||||
;; ------------------------------------------------------------------
|
||||
#_(audit-transact (mapv (fn [{:keys [eid]}] [:upsert-invoice {:db/id eid}]) plan)
|
||||
{:user/name "sysco recode ledger touch"
|
||||
:user/role "admin"})
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 7 — verify committed result.
|
||||
;; ------------------------------------------------------------------
|
||||
#_(let [db (dc/db conn)]
|
||||
(mapv (fn [{:keys [eid invoice-number]}]
|
||||
{:invoice-number invoice-number
|
||||
:accounts
|
||||
(->> (dc/pull db
|
||||
[{:invoice/expense-accounts
|
||||
[:invoice-expense-account/amount
|
||||
{:invoice-expense-account/account [:account/numeric-code]}]}]
|
||||
eid)
|
||||
:invoice/expense-accounts
|
||||
(map (juxt #(get-in % [:invoice-expense-account/account :account/numeric-code])
|
||||
:invoice-expense-account/amount))
|
||||
(sort-by first)
|
||||
vec)})
|
||||
plan)))
|
||||
375
src/clj/auto_ap/jobs/backfill_olo_processors.clj
Normal file
375
src/clj/auto_ap/jobs/backfill_olo_processors.clj
Normal file
@@ -0,0 +1,375 @@
|
||||
(ns auto-ap.jobs.backfill-olo-processors
|
||||
"One-off backfill for the Olo third-party-source fix (commit fa25620b, \"olo fixes\").
|
||||
|
||||
That commit replaced the exact-match `condp` on `(:name (:source order))` in
|
||||
`square3/tender->charge` with substring matching, so Olo-brokered orders that
|
||||
arrive from Square with source names like \"Olo - DoorDash\" or
|
||||
\"olo-ubereats\" now classify as the real delivery processor instead of
|
||||
falling through to `:ccp-processor/na`.
|
||||
|
||||
Orders imported *before* the fix shipped still carry the old
|
||||
`:charge/processor`, which means `sales-summaries` bucketed them as
|
||||
\"Unknown\" instead of \"Food App Payments\". This job recomputes
|
||||
`:charge/processor` for already-imported Square charges over a date range
|
||||
(default: 2026-07-07 -> today) and marks the affected sales summaries dirty
|
||||
so `auto-ap.jobs.sales-summaries` recalculates them.
|
||||
|
||||
IMPORTANT: this backfill never re-implements the classifier. It feeds the
|
||||
stored shape of each charge back through the real production function
|
||||
(`square3/tender->charge`), so it cannot drift from the importer.
|
||||
|
||||
No Square API calls are made -- every input the classifier needs
|
||||
(`:sales-order/source`, `:charge/note`, `:charge/type-name`) is already in
|
||||
Datomic. See the `(comment ...)` block at the bottom for the API-based
|
||||
fallback if you need to repair charges that have no parent sales order.
|
||||
|
||||
Dry run by default. Pass `:apply? true` to write."
|
||||
(:require
|
||||
[auto-ap.datomic :refer [conn]]
|
||||
[auto-ap.jobs.core :refer [execute]]
|
||||
[auto-ap.jobs.sales-summaries :as summaries]
|
||||
[auto-ap.logging :as alog]
|
||||
[auto-ap.square.core3 :as square3]
|
||||
[auto-ap.time :as atime]
|
||||
[clj-time.coerce :as coerce]
|
||||
[clj-time.core :as time]
|
||||
[clj-time.format :as f]
|
||||
[clj-time.periodic :as per]
|
||||
[clojure.string :as str]
|
||||
[config.core :refer [env]]
|
||||
[datomic.api :as dc]
|
||||
;; the datalog below calls iol-ion.query/scan-charges by fully-qualified
|
||||
;; symbol, so the namespace has to be loaded.
|
||||
[iol-ion.query]))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Date handling
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(def pacific (time/time-zone-for-id "America/Los_Angeles"))
|
||||
|
||||
(def default-start-date
|
||||
"The Olo fix landed 2026-07-22; the user-visible bad data starts 7/7."
|
||||
"2026-07-07")
|
||||
|
||||
(defn parse-day
|
||||
"\"2026-07-07\" -> local (Pacific) midnight DateTime. Passes DateTimes through."
|
||||
[d]
|
||||
(cond
|
||||
(nil? d) nil
|
||||
(string? d) (f/parse (f/with-zone (f/formatter "yyyy-MM-dd") pacific) d)
|
||||
:else (coerce/to-date-time d)))
|
||||
|
||||
(defn local-midnight [dt]
|
||||
(.toDateMidnight (atime/localize dt)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Classification -- delegates to the production importer
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn recompute-processor
|
||||
"Runs the *live* classifier over a charge's stored inputs.
|
||||
|
||||
`tender->charge` only reads `(:name (:source order))` from the order and
|
||||
`:type`/`:note` from the tender when deciding `:charge/processor`, so a
|
||||
synthetic order/tender carrying the stored values yields exactly what a
|
||||
fresh import would produce today. `:created_at` and `:id` are placeholders
|
||||
purely to keep the unrelated `:charge/date` and `:charge/reference-link`
|
||||
branches from blowing up; we only read `:charge/processor` back out.
|
||||
|
||||
Note on nil sources: the importer stores `(or (:name (:source order))
|
||||
\"Square\")`, so a source that was originally nil comes back as \"Square\".
|
||||
Both nil and \"Square\" classify to `:ccp-processor/na`, so the round trip
|
||||
is faithful."
|
||||
[{:keys [source note type-name]}]
|
||||
(:charge/processor
|
||||
(square3/tender->charge {:source {:name source}
|
||||
:created_at "1970-01-01T00:00:00Z"}
|
||||
{} ; client -> only :charge/client
|
||||
{} ; location -> only :charge/location
|
||||
{:id "backfill"
|
||||
:type type-name
|
||||
:note note})))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Scanning
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(def charge-pull
|
||||
[:db/id
|
||||
:charge/external-id
|
||||
:charge/type-name
|
||||
:charge/note
|
||||
:charge/date
|
||||
:charge/total
|
||||
{:charge/processor [:db/ident]}
|
||||
{:sales-order/_charges [:db/id
|
||||
:sales-order/external-id
|
||||
:sales-order/source
|
||||
{:sales-order/vendor [:db/ident]}]}])
|
||||
|
||||
(defn client-charges
|
||||
"All charges for `client-eid` between `start-inst` and `end-inst` (both
|
||||
inclusive by day), via the :charge/client+date index."
|
||||
[db client-eid start-inst end-inst]
|
||||
(->> (dc/q '[:find (pull ?c pull-pattern)
|
||||
:in $ pull-pattern [?clients ?start ?end]
|
||||
:where
|
||||
[(iol-ion.query/scan-charges $ ?clients ?start ?end) [[?c _ _] ...]]]
|
||||
db
|
||||
charge-pull
|
||||
[[client-eid] start-inst end-inst])
|
||||
(map first)))
|
||||
|
||||
(defn parent-order
|
||||
"Reverse refs on component attributes come back as a single map from pull,
|
||||
but tolerate a collection in case that ever changes."
|
||||
[charge]
|
||||
(let [o (:sales-order/_charges charge)]
|
||||
(if (sequential? o) (first o) o)))
|
||||
|
||||
(defn square-charge? [charge]
|
||||
(= :vendor/ccp-square (get-in (parent-order charge) [:sales-order/vendor :db/ident])))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Planning
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn charge->change
|
||||
"nil when the charge is already correct (or isn't ours to touch)."
|
||||
[charge]
|
||||
(when (square-charge? charge)
|
||||
(let [order (parent-order charge)
|
||||
current (get-in charge [:charge/processor :db/ident])
|
||||
next (recompute-processor {:source (:sales-order/source order)
|
||||
:note (:charge/note charge)
|
||||
:type-name (:charge/type-name charge)})]
|
||||
(when (not= current next)
|
||||
{:charge (:db/id charge)
|
||||
:external-id (:charge/external-id charge)
|
||||
:order (:sales-order/external-id order)
|
||||
:date (:charge/date charge)
|
||||
:source (:sales-order/source order)
|
||||
:note (:charge/note charge)
|
||||
:type-name (:charge/type-name charge)
|
||||
:total (:charge/total charge)
|
||||
:from current
|
||||
:to next}))))
|
||||
|
||||
(defn downgrade?
|
||||
"A change that *loses* processor information. The Olo fix can only ever widen
|
||||
matching, so these should not exist -- if they do, something else changed
|
||||
and we'd rather report than clobber."
|
||||
[{:keys [from to]}]
|
||||
(and (= :ccp-processor/na to)
|
||||
(not= :ccp-processor/na from)
|
||||
(some? from)))
|
||||
|
||||
(defn plan-for-client
|
||||
[db {:keys [db/id client/code]} start-inst end-inst allow-downgrade?]
|
||||
(let [charges (client-charges db id start-inst end-inst)
|
||||
square (filter square-charge? charges)
|
||||
orphans (->> charges
|
||||
(remove (comp some? parent-order))
|
||||
(filter #(some-> (:charge/external-id %)
|
||||
(str/starts-with? "square/charge/"))))
|
||||
all (keep charge->change square)
|
||||
[skipped changes] (if allow-downgrade?
|
||||
[[] all]
|
||||
[(filter downgrade? all) (remove downgrade? all)])]
|
||||
{:client id
|
||||
:code code
|
||||
:scanned (count square)
|
||||
:orphan-charges (count orphans)
|
||||
:changes (vec changes)
|
||||
:skipped (vec skipped)}))
|
||||
|
||||
(defn build-plan
|
||||
[db clients start-inst end-inst allow-downgrade?]
|
||||
(->> clients
|
||||
(map #(plan-for-client db % start-inst end-inst allow-downgrade?))
|
||||
(remove #(and (zero? (count (:changes %)))
|
||||
(zero? (count (:skipped %)))
|
||||
(zero? (:orphan-charges %))))
|
||||
vec))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Reporting
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- transitions [changes]
|
||||
(->> changes
|
||||
(group-by (juxt :from :to))
|
||||
(map (fn [[[from to] cs]]
|
||||
[from to (count cs) (reduce + 0.0 (keep :total cs))]))
|
||||
(sort-by #(- (nth % 2)))))
|
||||
|
||||
(defn print-report [plan]
|
||||
(println)
|
||||
(println "=== Olo processor backfill ===")
|
||||
(doseq [{:keys [code scanned changes skipped orphan-charges]} plan]
|
||||
(println)
|
||||
(printf "%-8s scanned %d square charges, %d to update%n"
|
||||
(str code) scanned (count changes))
|
||||
(doseq [[from to n total] (transitions changes)]
|
||||
(printf " %-24s -> %-24s %5d $%.2f%n"
|
||||
(str from) (str to) n total))
|
||||
(when (seq skipped)
|
||||
(printf " !! %d downgrade(s) to :ccp-processor/na SKIPPED (pass :allow-downgrade? true to force)%n"
|
||||
(count skipped))
|
||||
(doseq [{:keys [external-id order source note from]} (take 10 skipped)]
|
||||
(printf " %s (order %s, source %s, note %s) was %s%n"
|
||||
external-id order (pr-str source) (pr-str note) from)))
|
||||
(when (pos? orphan-charges)
|
||||
(printf " note: %d square charge(s) have no parent sales order (custom-amount tenders).%n"
|
||||
orphan-charges)
|
||||
(println " Their order source is not stored, so they cannot be reclassified")
|
||||
(println " locally -- use the re-import fallback in the comment block.")))
|
||||
(let [total (reduce + 0 (map (comp count :changes) plan))]
|
||||
(println)
|
||||
(printf "TOTAL: %d charge(s) across %d client(s)%n" total (count plan))
|
||||
(println)
|
||||
total))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Applying
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn apply-plan!
|
||||
"Transacts the processor corrections in batches. Returns the number written."
|
||||
[plan]
|
||||
(let [tx (for [{:keys [changes]} plan
|
||||
{:keys [charge to]} changes]
|
||||
{:db/id charge :charge/processor to})]
|
||||
(doseq [batch (partition-all 100 tx)]
|
||||
(alog/info ::updating-charges :count (count batch))
|
||||
@(dc/transact-async conn batch))
|
||||
(count tx)))
|
||||
|
||||
(defn mark-summaries-dirty!
|
||||
"Processor drives the Card / Food App / Unknown split in
|
||||
`sales-summaries/get-payment-items`, so every day we touched has to be
|
||||
recalculated. `periodic-seq` is end-exclusive, hence the +1 day."
|
||||
[plan start end]
|
||||
(doseq [{:keys [client code changes]} plan
|
||||
:when (seq changes)]
|
||||
(alog/info ::marking-summaries-dirty :client code)
|
||||
(summaries/mark-dirty client
|
||||
(local-midnight start)
|
||||
(time/plus (local-midnight end) (time/days 1)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Entry point
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn backfill!
|
||||
"Recompute :charge/processor for imported Square charges.
|
||||
|
||||
Options:
|
||||
:start \"2026-07-07\" (default) or a DateTime -- inclusive
|
||||
:end \"2026-07-29\" or a DateTime -- inclusive, default today
|
||||
:codes seq of client codes; default every Square client
|
||||
:apply? false (default) = dry run, print only
|
||||
:allow-downgrade? false (default) = refuse changes that drop a known
|
||||
processor back to :ccp-processor/na
|
||||
:mark-dirty? true (default when applying) = flag sales summaries for
|
||||
recalculation
|
||||
|
||||
Returns the plan so you can inspect individual charges in the REPL."
|
||||
[& {:keys [start end codes apply? allow-downgrade? mark-dirty?]
|
||||
:or {start default-start-date
|
||||
apply? false
|
||||
allow-downgrade? false}}]
|
||||
(let [start-dt (parse-day start)
|
||||
end-dt (or (parse-day end) (atime/localize (time/now)))
|
||||
start-inst (coerce/to-date (local-midnight start-dt))
|
||||
end-inst (coerce/to-date (local-midnight end-dt))
|
||||
db (dc/db conn)
|
||||
clients (if (seq codes)
|
||||
(apply square3/get-square-clients codes)
|
||||
(square3/get-square-clients))
|
||||
_ (alog/info ::scanning
|
||||
:start start-inst
|
||||
:end end-inst
|
||||
:clients (count clients)
|
||||
:dry-run? (not apply?))
|
||||
plan (build-plan db clients start-inst end-inst allow-downgrade?)
|
||||
total (print-report plan)]
|
||||
(if-not apply?
|
||||
(do (println "DRY RUN -- nothing written. Re-run with :apply? true to commit.")
|
||||
(alog/info ::dry-run-complete :change-count total))
|
||||
(do
|
||||
(alog/info ::applying :change-count total)
|
||||
(apply-plan! plan)
|
||||
(when (not= false mark-dirty?)
|
||||
(mark-summaries-dirty! plan start-dt end-dt)
|
||||
(println "Sales summaries marked dirty. Run auto-ap.jobs.sales-summaries")
|
||||
(println "(or `(auto-ap.jobs.sales-summaries/sales-summaries-v2)`) to rebuild them."))
|
||||
(alog/info ::done :change-count total)))
|
||||
plan))
|
||||
|
||||
(defn -main [& _]
|
||||
(execute "backfill-olo-processors"
|
||||
(fn []
|
||||
(let [{:keys [start end codes apply allow-downgrade mark-dirty]} (:args env)]
|
||||
(backfill! :start (or start default-start-date)
|
||||
:end end
|
||||
:codes (cond-> codes (string? codes)
|
||||
(str/split #","))
|
||||
:apply? (boolean apply)
|
||||
:allow-downgrade? (boolean allow-downgrade)
|
||||
:mark-dirty? (if (nil? mark-dirty) true (boolean mark-dirty)))))))
|
||||
|
||||
(comment
|
||||
;; ---------------------------------------------------------------------
|
||||
;; 1. Dry run everything from 7/7 -- read only, prints what would change.
|
||||
;; ---------------------------------------------------------------------
|
||||
(def plan (backfill!))
|
||||
|
||||
;; One client at a time while you sanity check.
|
||||
(backfill! :codes ["NGCL"])
|
||||
|
||||
;; Eyeball the actual charges behind a transition.
|
||||
(->> plan
|
||||
(mapcat :changes)
|
||||
(filter #(= :ccp-processor/doordash (:to %)))
|
||||
(map (juxt :date :source :note :total :from :to))
|
||||
(take 20))
|
||||
|
||||
;; Every distinct source string we saw reclassified -- confirms the Olo
|
||||
;; variants ("Olo - DoorDash", "olo-ubereats", ...) are what moved.
|
||||
(->> plan (mapcat :changes) (map :source) frequencies)
|
||||
|
||||
;; ---------------------------------------------------------------------
|
||||
;; 2. Commit, then rebuild the summaries.
|
||||
;; ---------------------------------------------------------------------
|
||||
(backfill! :apply? true)
|
||||
(auto-ap.jobs.sales-summaries/sales-summaries-v2)
|
||||
|
||||
;; ---------------------------------------------------------------------
|
||||
;; 3. Verify: no square charge in the window disagrees with the classifier.
|
||||
;; ---------------------------------------------------------------------
|
||||
(->> (backfill!) (mapcat :changes) count) ; => 0
|
||||
|
||||
;; ---------------------------------------------------------------------
|
||||
;; Fallback: charges with no parent sales order (custom-amount tenders,
|
||||
;; see `is-order-only-for-charge?` in square3/order->sales-order) do not
|
||||
;; store the order source, so they cannot be fixed locally. Re-import them
|
||||
;; through the real pipeline instead -- `upsert` is idempotent on
|
||||
;; :charge/external-id / :sales-order/external-id, so re-running a day is
|
||||
;; safe and rewrites the processor from live Square data.
|
||||
;;
|
||||
;; This hits the Square API for every day x location; the existing
|
||||
;; auto-ap.jobs.load-historical-sales job does the same thing by day count.
|
||||
;; ---------------------------------------------------------------------
|
||||
(doseq [client (square3/get-square-clients)
|
||||
location (:client/square-locations client)
|
||||
:when (:square-location/client-location location)
|
||||
d (per/periodic-seq (parse-day "2026-07-07")
|
||||
(time/plus (atime/localize (time/now)) (time/days 1))
|
||||
(time/days 1))]
|
||||
(println (:client/code client) (:square-location/client-location location) (str d))
|
||||
@(square3/upsert client location d (time/plus d (time/days 1))))
|
||||
|
||||
;; ...then mark dirty + rebuild summaries as in step 2.
|
||||
)
|
||||
@@ -333,7 +333,8 @@
|
||||
|
||||
(iol-ion.tx.upsert-sales-summary-ledger/upsert-sales-summary (dc/db conn) {:db/id 17592314241429})
|
||||
|
||||
(mark-all-dirty 5)
|
||||
(mark-all-dirty 14)
|
||||
|
||||
(delete-all)
|
||||
|
||||
(sales-summaries-v2)
|
||||
|
||||
@@ -35,19 +35,35 @@
|
||||
(into {}))))))
|
||||
@sysco-name->line)
|
||||
|
||||
(defn get-line-account [item-name]
|
||||
(get (get-sysco->line)
|
||||
item-name
|
||||
(ffirst (dc/q '[:find ?a
|
||||
:in $ ?an
|
||||
:where [?a :account/numeric-code ?an]]
|
||||
(dc/db conn)
|
||||
50000))))
|
||||
(defn get-account-by-code [numeric-code]
|
||||
(ffirst (dc/q '[:find ?a
|
||||
:in $ ?an
|
||||
:where [?a :account/numeric-code ?an]]
|
||||
(dc/db conn)
|
||||
numeric-code)))
|
||||
|
||||
;; Sysco categories whose *default* is unambiguous, so a line item whose
|
||||
;; description is missing from sysco_line_item_mapping.csv still codes
|
||||
;; correctly instead of silently defaulting to Food Costs. Only PAPER & DISP
|
||||
;; qualifies: 440 of its 455 mapped descriptions point at 55000, and the 15
|
||||
;; exceptions (foil pans -> 51500, scour pads -> 74100) are all mapped
|
||||
;; explicitly, so the description mapping wins ahead of this fallback.
|
||||
(def category->numeric-code {"PAPER & DISP" 55000})
|
||||
|
||||
(def default-numeric-code 50000)
|
||||
|
||||
(defn get-line-account
|
||||
([item-name] (get-line-account item-name nil))
|
||||
([item-name sysco-category]
|
||||
(or (get (get-sysco->line) item-name)
|
||||
(some-> (category->numeric-code sysco-category) get-account-by-code)
|
||||
(get-account-by-code default-numeric-code))))
|
||||
|
||||
(def ^:dynamic bucket-name (:data-bucket env))
|
||||
|
||||
(def header-keys ["TransCode" "GroupID" "Company" "CustomerNumber" "InvoiceNumber" "RecordType" "Item" "InvoiceDocument" "AccountName" "AccountDunsNo" "InvoiceDate" "AccountDate" "CustomerPONo" "PaymentTerms" "TermsDescription" "StoreNumber" "CustomerName" "AddressLine1" "AddressLine2" "City1" "State1" "Zip1" "Phone1" "Duns1" "Hin1" "Dea1" "TIDCustomer" "ChainNumber" "BidNumber" "ContractNumber" "CompanyNumber" "BriefName" "Address" "Address2" "City2" "State2" "Zip2" "Phone2" "Duns2" "Hin2" "Dea2" "Tid_OPCO" "ObligationIndicator" "Manifest" "Route" "Stop" "TermsDiscountPercent" "TermsDiscountDueDate" "TermsNetDueDate" "TermsDiscountAmount" "TermsDiscountCode" "OrderDate" "DepartmentCode"])
|
||||
(def item-price-index 15)
|
||||
(def item-category-index 25)
|
||||
(def item-name-index 29)
|
||||
|
||||
(def summary-keys ["TranCode" "GroupID" "Company" "CustomerNumber" "InvoiceNumber" "RecordType" "Item" "InvoiceDocument" "TotalLines" "TotalQtyInvoice" "TotalQty" "TotalQtySplit" "TotalQtyPounds" "TotalExtendedPrice" "TotalTaxAmount" "TotalInvoiceAmount" "AccountDate"])
|
||||
@@ -80,7 +96,8 @@
|
||||
butlast
|
||||
(reduce
|
||||
(fn [acc row]
|
||||
(update acc (get-line-account (nth row item-name-index))
|
||||
(update acc (get-line-account (nth row item-name-index)
|
||||
(nth row item-category-index))
|
||||
(fnil + 0.0)
|
||||
(Double/parseDouble (nth row item-price-index))))
|
||||
|
||||
|
||||
@@ -315,21 +315,55 @@
|
||||
[[(:db/id a) (:db/id (:account-client-override/client o))]
|
||||
(:account-client-override/name o)])
|
||||
(:account/client-overrides a))))
|
||||
(into {}))]
|
||||
(into {}))
|
||||
|
||||
;; A client's bank account and the financial account it posts to share a
|
||||
;; numeric code. Reports key their rows off that code, so the two would
|
||||
;; otherwise render as two rows for the same account. Resolve every
|
||||
;; account at a shared code down to the bank account's name. Where a
|
||||
;; client has two bank accounts on one code (a data-entry error, but it
|
||||
;; happens) the lowest :bank-account/sort-order wins, then the lowest
|
||||
;; :db/id, so the name is stable across runs.
|
||||
bank-name-by-code (->> (dc/q {:find ['(pull ?b [:db/id :bank-account/name
|
||||
:bank-account/numeric-code
|
||||
:bank-account/sort-order])]
|
||||
:in ['$ '?c]
|
||||
:where ['[?c :client/bank-accounts ?b]]}
|
||||
(dc/db conn)
|
||||
client-id)
|
||||
(map first)
|
||||
(filter (every-pred :bank-account/numeric-code :bank-account/name))
|
||||
(sort-by (juxt #(or (:bank-account/sort-order %) Long/MAX_VALUE)
|
||||
:db/id))
|
||||
(reduce (fn [m b]
|
||||
(let [code (:bank-account/numeric-code b)]
|
||||
(cond-> m
|
||||
(not (contains? m code))
|
||||
(assoc code (:bank-account/name b)))))
|
||||
{}))]
|
||||
(fn [a]
|
||||
{:name (or (:bank-account/name (bank-accounts a))
|
||||
(overrides-by-client [a client-id])
|
||||
(:account/name (accounts a)))
|
||||
:account_type (or (:db/ident (:account/type (accounts a)))
|
||||
({:bank-account-type/check :account-type/asset
|
||||
:bank-account-type/cash :account-type/asset
|
||||
:bank-account-type/credit :account-type/liability}
|
||||
(:db/ident (:bank-account/type (bank-accounts a))))
|
||||
:account-type/asset ;; DEFAULT TO ASSET, for things like unknown
|
||||
)
|
||||
:numeric_code (or (:account/numeric-code (accounts a))
|
||||
(:bank-account/numeric-code (bank-accounts a)))
|
||||
:client_id client-id})))
|
||||
(let [account (accounts a)
|
||||
bank-account (bank-accounts a)
|
||||
numeric-code (or (:account/numeric-code account)
|
||||
(:bank-account/numeric-code bank-account))
|
||||
bank-name (or (bank-name-by-code numeric-code)
|
||||
(:bank-account/name bank-account))]
|
||||
{:name (or bank-name
|
||||
(overrides-by-client [a client-id])
|
||||
(:account/name account))
|
||||
;; Whether :name above came from a bank account. Reports that pool
|
||||
;; several clients into one row per code use this to pick the label,
|
||||
;; since only some of those clients may have a bank account at the code.
|
||||
:bank_account_name? (some? bank-name)
|
||||
:account_type (or (:db/ident (:account/type account))
|
||||
({:bank-account-type/check :account-type/asset
|
||||
:bank-account-type/cash :account-type/asset
|
||||
:bank-account-type/credit :account-type/liability}
|
||||
(:db/ident (:bank-account/type bank-account)))
|
||||
:account-type/asset ;; DEFAULT TO ASSET, for things like unknown
|
||||
)
|
||||
:numeric_code numeric-code
|
||||
:client_id client-id}))))
|
||||
|
||||
(defn find-mismatch-index []
|
||||
(reduce + 0
|
||||
|
||||
@@ -742,6 +742,19 @@
|
||||
:total [:trim-commas-and-negate nil]}
|
||||
:multi #"\n"
|
||||
:multi-match? #"^\d+"}
|
||||
|
||||
;; REEL PRODUCE STATEMENT (QuickBooks layout -- no "Reel Produce" text on the page)
|
||||
{:vendor "Reel Produce"
|
||||
:keywords [#"reelproduce\.com" #"Statement"]
|
||||
:extract {:date #"^\s*([0-9]+/[0-9]+/[0-9]+)"
|
||||
:customer-identifier #"To:(?:.*?)\n\s*(.*?)\s{2,}"
|
||||
:invoice-number #"INV #(\d+)"
|
||||
:total #"Orig\. Amount \$([\d\-,]+\.\d{2,2})"}
|
||||
:parser {:date [:clj-time "MM/dd/yyyy"]
|
||||
:total [:trim-commas-and-negate nil]}
|
||||
:multi #"\n"
|
||||
:multi-match? #"^\s*[0-9]+/[0-9]+/[0-9]+\s+INV #"}
|
||||
|
||||
{:vendor "Paulino's Bakery"
|
||||
:keywords [#"paulinosbakery"]
|
||||
:extract {:date #"\s*([0-9]+/[0-9]+/[0-9]+)"
|
||||
@@ -758,8 +771,22 @@
|
||||
:keywords [#"530-544-4136"]
|
||||
:extract {:invoice-number #"NO\s+(\d{8,})\s+\d{2}/\d{2}/\d{2}"
|
||||
:date #"NO\s+\d{8,}\s+(\d{2}/\d{2}/\d{2})"
|
||||
:customer-identifier #"(?s)I\s+([A-Z][A-Z\s]+?)\s{2,}.*?L\s+([0-9][A-Z0-9\s]+?)(?=\s{2,}|\n)"
|
||||
:account-number #"(?s)L\s+([0-9][A-Z0-9\s]+?)(?=\s{2,}|\n)"
|
||||
;; The bill-to block spells BILL TO down the left margin, one letter
|
||||
;; per line, with the customer's details in the column to its right:
|
||||
;; B NICKGK
|
||||
;; I NICK THE GREEK <<McCARRAN>>
|
||||
;; L NICK THE GREEK <<McCARRAN>>
|
||||
;; L 10310 N McCARRAN BLVD STE.400
|
||||
;; RENO, NV 89503
|
||||
;; Anchor on those margin letters at the start of a line -- the ship-to
|
||||
;; block to the right reuses the same letters mid-line -- and take the
|
||||
;; whole column up to the next column gap. That column carries mixed
|
||||
;; case and punctuation, so it must not be restricted to [A-Z0-9\s];
|
||||
;; doing so dropped store names like McCARRAN entirely.
|
||||
:customer-identifier #"(?m)^\s+I\s{2,}(\S.*?)(?:\s{2,}|$)"
|
||||
;; Both the name and the street sit on an L line; the street is the
|
||||
;; one that starts with a house number.
|
||||
:account-number #"(?m)^\s+L\s{2,}(\d\S*.*?)(?:\s{2,}|$)"
|
||||
:total #"SHIPPED\s+[\d\.]+\s+TOTAL\s+([\d\.]+)"}
|
||||
:parser {:date [:clj-time "MM/dd/yy"]
|
||||
:total [:trim-commas nil]}}
|
||||
|
||||
@@ -22,17 +22,22 @@
|
||||
(if (not-empty q)
|
||||
(->>
|
||||
(str/split q #",")
|
||||
(map (fn [k]
|
||||
(let [[key asc?] (str/split k #":")
|
||||
matching-header (first (filter #(= (str key) (:sort-key %)) (:headers grid-spec)))]
|
||||
{:sort-key (str key)
|
||||
:asc (boolean (= "asc" asc?))
|
||||
:matching-header matching-header
|
||||
:name (:name matching-header)
|
||||
:sort-icon (if (= (boolean (= "asc" asc?)) true)
|
||||
svg/sort-down
|
||||
svg/sort-up)})))
|
||||
(filter :matching-header)
|
||||
;; NOTE: matching-header is deliberately not carried in the result. It is the
|
||||
;; grid's header map, which holds a :render fn. This sort lands in :query-params,
|
||||
;; which the bulk wizards copy into their form snapshot, and that snapshot is
|
||||
;; round-tripped through pr-str / clojure.edn/read-string. A fn pr-strs as
|
||||
;; #object[...], which edn has no reader for, 500ing the submit.
|
||||
;; apply-toggle-sort below already builds entries without it.
|
||||
(keep (fn [k]
|
||||
(let [[key asc?] (str/split k #":")
|
||||
matching-header (first (filter #(= (str key) (:sort-key %)) (:headers grid-spec)))]
|
||||
(when matching-header
|
||||
{:sort-key (str key)
|
||||
:asc (boolean (= "asc" asc?))
|
||||
:name (:name matching-header)
|
||||
:sort-icon (if (= (boolean (= "asc" asc?)) true)
|
||||
svg/sort-down
|
||||
svg/sort-up)}))))
|
||||
(into []))
|
||||
[]))
|
||||
|
||||
|
||||
@@ -62,7 +62,15 @@
|
||||
(.setHandler server stats-handler))
|
||||
(.setStopAtShutdown server true))
|
||||
|
||||
(def ^:dynamic *http-port-override* nil)
|
||||
(def ^:dynamic *http-port-override*
|
||||
;; In dev, `lein mcp-repl` records the chosen HTTP port in `.http-port` so it
|
||||
;; stays stable across reloads. `refresh` re-evaluates this def, so reading the
|
||||
;; file here (rather than relying solely on an alter-var-root override that gets
|
||||
;; reset) keeps the port from falling back to (env :port). Absent in prod.
|
||||
(let [f (java.io.File. ".http-port")]
|
||||
(when (.exists f)
|
||||
(let [p (.trim ^String (slurp f))]
|
||||
(when (seq p) p)))))
|
||||
|
||||
(mount/defstate port :start (Integer/parseInt (str (or *http-port-override* (env :port) "3000"))))
|
||||
|
||||
|
||||
@@ -281,15 +281,15 @@
|
||||
"grubhub" :ccp-processor/grubhub
|
||||
"grub" :ccp-processor/grubhub
|
||||
"gh" :ccp-processor/grubhub
|
||||
(condp = (:name (:source order))
|
||||
"GRUBHUB" :ccp-processor/grubhub
|
||||
"UBEREATS" :ccp-processor/uber-eats
|
||||
"Uber Eats" :ccp-processor/uber-eats
|
||||
"DOORDASH" :ccp-processor/doordash
|
||||
"DoorDash" :ccp-processor/doordash
|
||||
"Koala" :ccp-processor/koala
|
||||
"koala-production" :ccp-processor/koala
|
||||
:ccp-processor/na))
|
||||
(let [src (some-> (:name (:source order)) str/lower-case)]
|
||||
(cond
|
||||
(nil? src) :ccp-processor/na
|
||||
(str/includes? src "doordash") :ccp-processor/doordash
|
||||
(str/includes? src "uber") :ccp-processor/uber-eats
|
||||
(str/includes? src "grubhub") :ccp-processor/grubhub
|
||||
(str/includes? src "postmates") :ccp-processor/uber-eats
|
||||
(str/includes? src "koala") :ccp-processor/koala
|
||||
:else :ccp-processor/na)))
|
||||
(= (:type t) "CARD")
|
||||
:ccp-processor/square
|
||||
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
[bidi.bidi :as bidi]
|
||||
[clj-time.coerce :as coerce]
|
||||
[clj-time.core :as time]
|
||||
[datomic.api :as dc]
|
||||
[hiccup2.core :as hiccup]))
|
||||
[datomic.api :as dc]))
|
||||
|
||||
(defn hourly-changes []
|
||||
(let [tx-instant-attr (:db/id (dc/pull (dc/db conn) '[:db/id] :db/txInstant))
|
||||
@@ -56,34 +55,68 @@
|
||||
[:div
|
||||
[:h1.text-2xl.mb-3.font-bold "Growth in clients"]
|
||||
[:div
|
||||
[:div {:class "w-full h-64"
|
||||
:id "client-chart"
|
||||
:data-chart (hx/json {:labels ["2 years ago" "1 year ago" "today"],
|
||||
:series [(for [n [2 1 0]
|
||||
:let [start (time/plus (time/now) (time/years (- n)))]]
|
||||
(->> (dc/q '[:find (count ?c)
|
||||
:in $
|
||||
:where [?c :client/code]]
|
||||
(dc/as-of (dc/db conn) (coerce/to-date start)))
|
||||
first
|
||||
first))]})}]
|
||||
[:script {:lang "javascript"}
|
||||
(hiccup/raw
|
||||
"new Chartist.Bar('#client-chart', JSON.parse(document.getElementById('client-chart').getAttribute('data-chart')))")]]]])
|
||||
[:div.w-full.h-64
|
||||
[:canvas.w-full.h-full {:x-data (hx/json {:chart nil
|
||||
:labels ["2 years ago" "1 year ago" "today"]
|
||||
:data (for [n [2 1 0]
|
||||
:let [start (time/plus (time/now) (time/years (- n)))]]
|
||||
(->> (dc/q '[:find (count ?c)
|
||||
:in $
|
||||
:where [?c :client/code]]
|
||||
(dc/as-of (dc/db conn) (coerce/to-date start)))
|
||||
first
|
||||
first))})
|
||||
:x-init "new Chart($el, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Clients',
|
||||
data: data,
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});"}]]]]])
|
||||
|
||||
(com/content-card {:class "w-1/2"}
|
||||
[:div {:class "flex flex-col px-4 py-3 space-y-3"}
|
||||
[:div
|
||||
[:h1.text-2xl.mb-3.font-bold "Changes by hour"]
|
||||
[:div
|
||||
[:div {:class "w-full h-64"
|
||||
:id "changes"
|
||||
:data-chart (hx/json {:labels (for [n (range -24 0)]
|
||||
(format "%d" n)),
|
||||
:series [(hourly-changes)]})}]
|
||||
[:script {:lang "javascript"}
|
||||
(hiccup/raw
|
||||
"new Chartist.Line('#changes', JSON.parse(document.getElementById('changes').getAttribute('data-chart')))")]]]])])
|
||||
[:div.w-full.h-64
|
||||
[:canvas.w-full.h-full {:x-data (hx/json {:chart nil
|
||||
:labels (for [n (range -24 0)]
|
||||
(format "%d" n))
|
||||
:data (hourly-changes)})
|
||||
:x-init "new Chart($el, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Changes',
|
||||
data: data,
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});"}]]]]])])
|
||||
"Admin"))
|
||||
|
||||
(def key->handler
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
default-grid-fields-schema)]))
|
||||
|
||||
(defn filters [request]
|
||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/table)
|
||||
"hx-target" "#entity-table"
|
||||
|
||||
@@ -656,13 +656,7 @@
|
||||
linear-wizard this
|
||||
:head "Transaction rule"
|
||||
:body (mm/default-step-body {}
|
||||
[:form#my-form {:hx-ext "response-targets"
|
||||
:hx-target-400 "#form-errors .error-content"
|
||||
:hx-indicator "#submit"
|
||||
:x-trap "true"
|
||||
(if (:db/id (fc/field-value))
|
||||
:hx-put
|
||||
:hx-post) (str (bidi/path-for ssr-routes/only-routes ::route/save))}
|
||||
[:div#my-form {:x-trap "true"}
|
||||
[:fieldset {:class "hx-disable"
|
||||
:x-data (hx/json {:clientId (or (:db/id (:transaction-rule/client (fc/field-value)))
|
||||
(:transaction-rule/client (fc/field-value)))})}
|
||||
@@ -728,25 +722,26 @@
|
||||
:class "w-24"
|
||||
:placeholder "NTG"
|
||||
:value (fc/field-value)})]))
|
||||
(fc/with-field :transaction-rule/bank-account
|
||||
(com/validated-field
|
||||
(-> {:label "Bank Account"
|
||||
:errors (fc/field-errors)
|
||||
:x-show "bankAccountFilter"}
|
||||
hx/alpine-appear)
|
||||
[:div.w-96
|
||||
[:div#bank-account-changer {:hx-get (bidi/path-for ssr-routes/only-routes :bank-account-typeahead)
|
||||
:hx-trigger "changed"
|
||||
:hx-target "next *"
|
||||
:hx-include "#bank-account-changer"
|
||||
:hx-swap "innerHTML"
|
||||
(let [rule-client (fc/field-value (:transaction-rule/client fc/*current*))]
|
||||
(fc/with-field :transaction-rule/bank-account
|
||||
(com/validated-field
|
||||
(-> {:label "Bank Account"
|
||||
:errors (fc/field-errors)
|
||||
:x-show "bankAccountFilter"}
|
||||
hx/alpine-appear)
|
||||
[:div.w-96
|
||||
[:div#bank-account-changer {:hx-get (bidi/path-for ssr-routes/only-routes :bank-account-typeahead)
|
||||
:hx-trigger "changed"
|
||||
:hx-target "next *"
|
||||
:hx-include "#bank-account-changer"
|
||||
:hx-swap "outerHTML"
|
||||
|
||||
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId}" (fc/field-name))
|
||||
:x-init "$watch('clientId', cid => $dispatch('changed', $data))"}]
|
||||
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId}" (fc/field-name))
|
||||
:x-init "$watch('clientId', cid => $dispatch('changed', $data))"}]
|
||||
|
||||
(bank-account-typeahead* {:client-id (:transaction-rule/client (fc/field-value))
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})]))
|
||||
(bank-account-typeahead* {:client-id (or (:db/id rule-client) rule-client)
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})])))
|
||||
|
||||
(com/field (-> {:label "Amount"
|
||||
:x-show "amountFilter"}
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
[:link {:rel "stylesheet" :href "/output.css"}]
|
||||
[:script {:defer true :src "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"}]
|
||||
[:style
|
||||
"body{background:linear-gradient(160deg,#79b52e 0%,#009cea 100%);min-height:100vh}"]]
|
||||
"body{background:linear-gradient(160deg,#79b52e 0%,#009cea 100%);min-height:100vh}@keyframes slideUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}"]]
|
||||
[:body contents]]))})
|
||||
|
||||
(defn- page-contents [request]
|
||||
@@ -72,7 +72,7 @@
|
||||
[:div.flex-shrink-0.w-5.h-5.text-red-500 svg/alert]
|
||||
[:div.flex-1.min-w-0
|
||||
[:p.text-sm.font-medium.text-gray-900 "Something went wrong"]
|
||||
[:p.text-xs.text-gray-500.mt-0.5
|
||||
[:div.text-xs.text-gray-500.mt-0.5
|
||||
"Our team has been notified. Please try again."
|
||||
[:span {:x-data (hx/json {"e" false})}
|
||||
" "
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
(com/data-grid-header {} "Synced count")
|
||||
(com/data-grid-header {} "Approved transactions")
|
||||
(com/data-grid-header {} "Unapproved transactions")
|
||||
(com/data-grid-header {} "Requires feedback transactions")
|
||||
(com/data-grid-header {} "Client Review transactions")
|
||||
(com/data-grid-header {} "Missing transactions")])
|
||||
#_#_:thead-params {:class "sticky top-0 z-50"}}
|
||||
(for [row report]
|
||||
@@ -84,18 +84,18 @@
|
||||
(com/validated-field {:label "Start"
|
||||
:errors (fc/field-errors)}
|
||||
[:div {:class "w-64"}
|
||||
(com/date-input {:name (fc/field-name)
|
||||
(com/date-input {:name (fc/field-name)
|
||||
:class "w-64"
|
||||
:value (some-> (fc/field-value)
|
||||
(atime/unparse-local atime/normal-date))})]))
|
||||
:value (some-> (fc/field-value)
|
||||
(atime/unparse-local atime/normal-date))})]))
|
||||
(fc/with-field :end-date
|
||||
(com/validated-field {:label "End"
|
||||
:errors (fc/field-errors)}
|
||||
[:div {:class "w-64"}
|
||||
(com/date-input {:name (fc/field-name)
|
||||
(com/date-input {:name (fc/field-name)
|
||||
:class "w-64"
|
||||
:value (some-> (fc/field-value)
|
||||
(atime/unparse-local atime/normal-date))})]))
|
||||
:value (some-> (fc/field-value)
|
||||
(atime/unparse-local atime/normal-date))})]))
|
||||
(com/button {:color :primary :class "self-center w-24"} "Run")])]
|
||||
(if report
|
||||
(report* {:request request :report report})
|
||||
@@ -104,15 +104,15 @@
|
||||
(defn page [request]
|
||||
(base-page
|
||||
request
|
||||
(com/page {:nav com/company-aside-nav
|
||||
(com/page {:nav com/company-aside-nav
|
||||
:client-selection (:client-selection request)
|
||||
:client (:client request)
|
||||
:clients (:clients request)
|
||||
:identity (:identity request)
|
||||
:app-params {:hx-get (bidi/path-for ssr-routes/only-routes :company-reconciliation-report)
|
||||
:identity (:identity request)
|
||||
:app-params {:hx-get (bidi/path-for ssr-routes/only-routes :company-reconciliation-report)
|
||||
:hx-trigger "clientSelected from:body"
|
||||
:hx-select "#app-contents"
|
||||
:hx-swap "outerHTML swap:300ms"}}
|
||||
:hx-swap "outerHTML swap:300ms"}}
|
||||
(com/breadcrumbs {}
|
||||
[:a {:href (bidi/path-for ssr-routes/only-routes :company)}
|
||||
"My Company"]
|
||||
@@ -133,7 +133,7 @@
|
||||
|
||||
(defn get-report-data [start-date end-date client-ids]
|
||||
(let [client-codes (map first (dc/q '[:find ?cc :in $ [?c ...] :where [?c :client/code ?cc]] (dc/db conn) client-ids))]
|
||||
(for [[ib ba c] (seq (apply get-intuit-bank-accounts (dc/db conn) client-codes))
|
||||
(for [[ib ba c] (seq (apply get-intuit-bank-accounts (dc/db conn) client-codes))
|
||||
:let [raw-transactions (get-transactions (atime/unparse-local start-date atime/iso-date)
|
||||
(atime/unparse-local end-date atime/iso-date)
|
||||
ib)
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
(dropdown-search-results* {:options (get-clients identity (get (:query-params request) "search-text"))})))
|
||||
|
||||
(defn dropdown [{:keys [client-selection client identity clients]}]
|
||||
[:div#company-dropdown {:x-data (hx/json {})}
|
||||
[:div#company-dropdown {:x-data (hx/json {}) :class "shrink-0"}
|
||||
[:script
|
||||
(hiccup/raw
|
||||
"localStorage.setItem(\"last-client-id\", \"" (:db/id client) "\")" "\n"
|
||||
@@ -93,22 +93,23 @@
|
||||
:else
|
||||
client-selection) ")")]
|
||||
[:div
|
||||
[:button#company-dropdown-button {:class "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2.5 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
[:button#company-dropdown-button {:class "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2.5 text-center inline-flex items-center whitespace-nowrap dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
"x-tooltip.on.click" "{content: ()=>$refs.tooltip.innerHTML, theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}"
|
||||
:type "button"}
|
||||
(cond
|
||||
(= :mine client-selection)
|
||||
"My Companies"
|
||||
(= :all client-selection)
|
||||
"All Companies"
|
||||
[:span {:class "truncate max-w-[10rem] sm:max-w-[14rem]"}
|
||||
(cond
|
||||
(= :mine client-selection)
|
||||
"My Companies"
|
||||
(= :all client-selection)
|
||||
"All Companies"
|
||||
|
||||
(and client
|
||||
(= 1 (count clients)))
|
||||
(:client/name client)
|
||||
(and client
|
||||
(= 1 (count clients)))
|
||||
(:client/name client)
|
||||
|
||||
:else
|
||||
(str (count clients) " Companies"))
|
||||
[:div.w-4.h-4.ml-2
|
||||
:else
|
||||
(str (count clients) " Companies"))]
|
||||
[:div.w-4.h-4.ml-2.shrink-0
|
||||
svg/drop-down]]
|
||||
[:template#company-dropdown-list {:x-ref "tooltip"}
|
||||
[:div {:class "w-[300px]"
|
||||
|
||||
@@ -112,9 +112,11 @@
|
||||
true (str " focus:ring-4 font-bold rounded-lg text-xs p-3 text-center mr-2 inline-flex items-center hover:scale-105 transition duration-100 justify-center")
|
||||
(= :secondary (:color params)) (str " text-white bg-blue-500 hover:bg-blue-600 focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700")
|
||||
(= :primary (:color params)) (str " text-white bg-green-500 hover:bg-green-600 focus:ring-green-300 dark:bg-green-600 dark:hover:bg-green-700 ")
|
||||
(= :secondary-light (:color params)) (str " text-blue-800 bg-white-200 border-gray-100 border hover:bg-blue-100 focus:ring-blue-100 dark:bg-blue-400 dark:hover:bg-blue-800 ")
|
||||
(= :secondary-light (:color params)) (str " text-blue-800 bg-blue-100 border-blue-300 border hover:bg-blue-200 focus:ring-blue-100 dark:text-white dark:bg-blue-700 dark:border-blue-500 dark:hover:bg-blue-600 ")
|
||||
|
||||
(not (nil? (:color params)))
|
||||
;; the light variants paint their own text/background above -
|
||||
;; falling through here would stack white text on them
|
||||
(not (contains? #{nil :secondary-light} (:color params)))
|
||||
(str " text-white " (bg-colors (:color params) (:disabled params)))
|
||||
|
||||
(nil? (:color params))
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
[:section (merge params {:class (hh/add-class " py-3 sm:py-5" (:class params))})
|
||||
[:div {:class (:max-w params "max-w-screen-2xl")}
|
||||
(into
|
||||
[:div {:class "relative overflow-scroll shadow-md dark:bg-gray-800 sm:rounded-lg border-2 border-gray-200 dark:border-gray-900 bg-white"}]
|
||||
[:div {:class "relative overflow-auto shadow-md dark:bg-gray-800 sm:rounded-lg border-2 border-gray-200 dark:border-gray-900 bg-white"}]
|
||||
children)]])
|
||||
|
||||
@@ -12,9 +12,14 @@
|
||||
[hiccup2.core :as hiccup]))
|
||||
|
||||
(defn header- [params & rest]
|
||||
(into [:th.px-4.py-3 {:scope "col" :class (:class params)
|
||||
"@click" (format "$dispatch('sorted', {key: '%s'})" (:sort-key params))
|
||||
:style (:style params)}]
|
||||
;; NOTE: only attach the sort dispatcher when there is a :sort-key. Otherwise
|
||||
;; (format "%s" nil) renders the literal string "null", which sails through the
|
||||
;; 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)
|
||||
[(into [:a {:href "#"}] rest)]
|
||||
rest)))
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[clj-time.core :as t]
|
||||
[clj-time.periodic :as per]))
|
||||
|
||||
(defn date-range-field [{:keys [value id apply-button?]}]
|
||||
(defn date-range-field [{:keys [value id]}]
|
||||
[:div {:id id}
|
||||
(com/field {:label "Date Range"}
|
||||
[:div.space-y-4
|
||||
@@ -17,7 +17,7 @@
|
||||
(com/button-group-button {:size :small :value "week" :hx-trigger "click"} "Week")
|
||||
(com/button-group-button {:size :small :value "month" :hx-trigger "click"} "Month")
|
||||
(com/button-group-button {:size :small :value "year" :hx-trigger "click"} "Year"))]
|
||||
[:div.flex.space-x-1.items-baseline.w-full.justify-start
|
||||
[:div.flex.space-x-1.items-baseline.w-full.justify-start {"@change.stop" ""}
|
||||
(com/date-input {:name "start-date"
|
||||
:value (some-> (:start value)
|
||||
(atime/unparse-local atime/normal-date))
|
||||
@@ -31,9 +31,8 @@
|
||||
:placeholder "Date"
|
||||
:size :small
|
||||
:class "shrink date-filter-input"})
|
||||
(when apply-button?
|
||||
(but/button- {:color :secondary
|
||||
:size :small
|
||||
:type "button"
|
||||
"x-on:click" "$dispatch('datesApplied')"}
|
||||
"Apply"))]])])
|
||||
(but/button- {:color :secondary
|
||||
:size :small
|
||||
:type "button"
|
||||
"x-on:click" "$dispatch('datesApplied')"}
|
||||
"Apply")]])])
|
||||
|
||||
@@ -86,6 +86,13 @@
|
||||
:x-init (hiccup/raw (str "$watch('value', v => { $el.value = (v && v.value != null) ? v.value : ''; $nextTick(() => $dispatch('change')); }); "))))]
|
||||
[:div.flex.w-full.justify-items-stretch
|
||||
[:span.flex-grow.text-left {"x-text" "value.label"}]
|
||||
[:div {:class "w-4 h-4 m-1 inline ml-1 justify-self-end self-center cursor-pointer text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
:tabindex "-1"
|
||||
:aria-hidden "true"
|
||||
:title "Clear selection"
|
||||
"@click.prevent.stop" "value = {value: '', label: ''}; if (tippy) { tippy.hide(); }"
|
||||
:x-show "!!(value && value.value)"}
|
||||
svg/x]
|
||||
[:div {:class "w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center"}
|
||||
svg/drop-down]
|
||||
[:div {:x-show "value.warning"}
|
||||
|
||||
@@ -138,6 +138,33 @@
|
||||
[:div.space-y-1 {}
|
||||
children])
|
||||
|
||||
(defn flatten-form-errors
|
||||
"Walks a malli-humanized error structure and returns a flat sequence of
|
||||
human-readable strings, prefixing each leaf message with the nearest
|
||||
field name for context. Lets the footer's error bar surface every
|
||||
validation error for the whole form, even ones whose field lives on a
|
||||
hidden step/tab and so would otherwise be invisible."
|
||||
([errors] (flatten-form-errors nil errors))
|
||||
([field errors]
|
||||
(let [label (cond (keyword? field) (name field)
|
||||
(string? field) field
|
||||
:else nil)
|
||||
decorate (fn [msg] (if label (str label ": " msg) msg))]
|
||||
(cond
|
||||
(map? errors)
|
||||
(mapcat (fn [[k v]] (flatten-form-errors k v)) errors)
|
||||
|
||||
(and (sequential? errors) (every? string? errors))
|
||||
(map decorate errors)
|
||||
|
||||
(sequential? errors)
|
||||
(mapcat #(flatten-form-errors field %) errors)
|
||||
|
||||
(string? errors)
|
||||
[(decorate errors)]
|
||||
|
||||
:else nil))))
|
||||
|
||||
(defn default-step-footer [linear-wizard step & {:keys [validation-route
|
||||
discard-button
|
||||
next-button
|
||||
@@ -146,7 +173,8 @@
|
||||
[:div.flex.items-baseline.gap-x-4
|
||||
(let [step-errors (:step-params fc/*form-errors*)]
|
||||
(com/form-errors {:errors (or (:errors step-errors)
|
||||
(when (sequential? step-errors) step-errors))}))
|
||||
(when (sequential? step-errors) step-errors)
|
||||
(seq (distinct (flatten-form-errors step-errors))))}))
|
||||
(when (not= (first (steps linear-wizard))
|
||||
(step-key step))
|
||||
(when validation-route
|
||||
|
||||
@@ -7,32 +7,42 @@
|
||||
[auto-ap.ssr.components.buttons :refer [icon-button-]]
|
||||
[auto-ap.ssr.components.user-dropdown :as user-dropdown]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[bidi.bidi :as bidi]))
|
||||
[bidi.bidi :as bidi]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defn navbar- [{:keys [client-selection client identity clients dd-env]}]
|
||||
[:nav {:class "fixed z-30 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700"}
|
||||
[:div {:class "px-3 py-3 lg:px-5 lg:pl-3"}
|
||||
[:div {:class "flex items-center justify-between"}
|
||||
[:div {:class "flex items-center justify-start"}
|
||||
[:button {:aria-controls "left-nav", :id "left-nav-toggle" :type "button", :class "inline-flex items-center p-2 mt-2 ml-2 mr-2 text-sm text-gray-500 rounded-lg hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
|
||||
[:div {:class "px-3 lg:px-5 lg:pl-3 h-16 flex items-center"}
|
||||
[:div {:class "flex items-center w-full"}
|
||||
;; Left cluster: sidebar toggle, logo, environment badge. Holds its size.
|
||||
[:div {:class "flex items-center shrink-0"}
|
||||
[:button {:aria-controls "left-nav", :id "left-nav-toggle" :type "button", :class "inline-flex items-center p-2 mr-2 text-sm text-gray-500 rounded-lg hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
|
||||
"@click" "leftNavShow = !leftNavShow"}
|
||||
[:span {:class "sr-only"} "Open sidebar"]
|
||||
[:svg {:class "w-6 h-6", :aria-hidden "true", :fill "currentColor", :viewbox "0 0 20 20", :xmlns "http://www.w3.org/2000/svg"}
|
||||
[:path {:clip-rule "evenodd", :fill-rule "evenodd", :d "M2 4.75A.75.75 0 012.75 4h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 4.75zm0 10.5a.75.75 0 01.75-.75h7.5a.75.75 0 010 1.5h-7.5a.75.75 0 01-.75-.75zM2 10a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 10z"}]]]
|
||||
[:a {:href (bidi/path-for ssr-routes/only-routes ::dashboard/page) :class "flex ml-2 hidden md:mr-24 sm:inline"}
|
||||
[:img {:src "/img/logo-big2.png", :class "h-10", :alt "Integreat logo"}]]
|
||||
(when-not (= "prod" dd-env) [:div.rounded-full.bg-yellow-200.text-lg.text-yellow-800.px-4.hidden.md:block.mr-8 "environment: " dd-env])]
|
||||
[:a {:href (bidi/path-for ssr-routes/only-routes ::dashboard/page) :class "hidden sm:flex items-center shrink-0"}
|
||||
[:img {:src "/img/logo-big2.png", :class "h-10 max-w-none", :alt "Integreat logo"}]]
|
||||
(when (and dd-env (not= "prod" dd-env))
|
||||
(let [env-label (str "environment: " dd-env)]
|
||||
[:div {:class "shrink-0"}
|
||||
;; Full pill when there is room (md-lg and xl+); compact letter badge in the tight lg range.
|
||||
[:span {:class "hidden md:inline-flex lg:hidden xl:inline-flex items-center ml-4 h-8 px-3 rounded-full bg-yellow-200 text-yellow-800 text-sm font-medium whitespace-nowrap"}
|
||||
env-label]
|
||||
[:span {:class "hidden lg:flex xl:hidden items-center justify-center ml-3 w-8 h-8 rounded-full bg-yellow-200 text-yellow-800 text-sm font-bold"
|
||||
:title env-label}
|
||||
(str/upper-case (subs dd-env 0 1))]]))]
|
||||
|
||||
[:div {:class "flex items-center gap-4"}
|
||||
;; Search: fills the middle, grows to a comfortable max and shrinks first when space is tight.
|
||||
(when (is-admin? identity)
|
||||
[:button.relative.hidden.lg:block.flex-1.min-w-0.max-w-md.mx-4 {:class "bg-gray-50 hover:bg-gray-200 dark:hover:bg-gray-700 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 pl-10 h-10 pr-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes :search)}
|
||||
[:div {:class "absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-gray-500"}
|
||||
[:div.w-4.h-4 svg/search]
|
||||
[:span.ml-2 "Search"]]])
|
||||
|
||||
(when (is-admin? identity)
|
||||
[:button.mt-1.lg:w-96.relative.hidden.lg:block {:class "bg-gray-50 hover:bg-gray-200 dark:hover:bg-gray-700 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 w-full pl-10 py-4 pr-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500 gap-4 "
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes :search)}
|
||||
[:div {:class "absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-gray-500"}
|
||||
[:div.w-4.h-4 svg/search]
|
||||
[:span.ml-2 "Search"]]])
|
||||
[:div {:class "hidden mr-3 -mb-1 sm:block"}
|
||||
[:span]]
|
||||
;; Right cluster: mobile search, company selector, user menu. Stays pinned right and keeps its size.
|
||||
[:div {:class "flex items-center gap-2 sm:gap-4 ml-auto shrink-0"}
|
||||
(icon-button-
|
||||
{:id "toggleSidebarMobileSearch", :type "button", :class "p-2 text-gray-500 rounded-lg lg:hidden hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
|
||||
@@ -55,9 +55,9 @@
|
||||
[:div.flex.flex-col.gap-2
|
||||
(buttons/a-button- {"@click" "periods=getFourWeekPeriodsPeriods(source_date)"} [:span "13 periods, ending "
|
||||
[:span {:x-text "source_date"}]])
|
||||
(buttons/a-button- {"@click" "periods=[calendarYearPeriod(source_date)]"} [:span "Calendar year ("
|
||||
[:span {:x-text "parseMMDDYYYY(source_date).getFullYear()"}]
|
||||
")"])
|
||||
(buttons/a-button- {"@click" "periods=[previousCalendarYearPeriod(source_date)]"} [:span "Previous Calendar Year ("
|
||||
[:span {:x-text "parseMMDDYYYY(source_date).getFullYear() - 1"}]
|
||||
")"])
|
||||
(buttons/a-button- {"@click" "periods=getTwelveCalendarMonthsPeriods(source_date)"} [:span "12 months, ending "
|
||||
[:span {:x-text "parseMMDDYYYY(source_date).toLocaleString('default', { month: 'long' })"}]])
|
||||
[:hr {:class "h-px my-1 bg-gray-200 border-0 dark:bg-gray-700"}]
|
||||
|
||||
@@ -149,8 +149,11 @@
|
||||
main-transformer))
|
||||
"sort" sort->query)
|
||||
"selected" "all-selected"))
|
||||
:color :secondary-light}
|
||||
[:div.w-4.h-4 svg/download])))
|
||||
:color :secondary-light
|
||||
:title "Export CSV"
|
||||
:aria-label "Export CSV"}
|
||||
[:div.w-4.h-4 svg/download]
|
||||
"CSV")))
|
||||
:rows
|
||||
(let [break-table-fn (some-> grid-spec :break-table (create-break-table-fn grid-spec))]
|
||||
(for [entity entities
|
||||
@@ -282,6 +285,7 @@
|
||||
[:div {:x-data (hx/json {:selected [] :all_selected false :type (:entity-name grid-spec)})
|
||||
"x-on:copy" "if (selected.length > 0) {$clipboard(JSON.stringify({'type': type, 'selected': selected}))}"
|
||||
"x-on:client-selected.document" "selected=[]; all_selected=false"
|
||||
"x-on:reset-selection.document" "selected=[]; all_selected=false"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
:x-init "$watch('selected', s=> $dispatch('selectedChanged', {selected: s, all_selected: all_selected}) );
|
||||
$watch('all_selected', a=>$dispatch('selectedChanged', {selected: selected, all_selected: a}))"}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
[:div {:id "exact-match-id-tag"}]))
|
||||
|
||||
(defn filters [request]
|
||||
[:form#invoice-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form#invoice-filters {"hx-trigger" "datesApplied, 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"
|
||||
|
||||
@@ -656,7 +656,7 @@
|
||||
{:exact-match-id (:db/id p)})
|
||||
:content (str (format "$%,.2f" (:payment/amount p))
|
||||
(some-> (:payment/date p) coerce/to-date-time (atime/unparse-local atime/normal-date) (#(str " payment on " %))))}]
|
||||
(:payment/transaction p) (conj {:link (hu/url (bidi/path-for ssr-routes/only-routes ::transaction-routes/all-page)
|
||||
(:payment/transaction p) (conj {:link (hu/url (bidi/path-for ssr-routes/only-routes ::transaction-routes/page)
|
||||
{:exact-match-id (:db/id (first (:payment/transaction p)))})
|
||||
:color :secondary
|
||||
:content "Transaction"})))))
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
[auto-ap.ssr.ui :refer [base-page]]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [apply-middleware-to-all-handlers clj-date-schema
|
||||
html-response main-transformer money strip
|
||||
html-response modal-response main-transformer money strip
|
||||
wrap-form-4xx-2 wrap-implied-route-param
|
||||
wrap-merge-prior-hx wrap-schema-decode
|
||||
wrap-schema-enforce]]
|
||||
@@ -69,6 +69,40 @@
|
||||
selected)]
|
||||
ids))
|
||||
|
||||
(defn all-ids-not-locked
|
||||
"Filters journal-entry ids to only those whose date is on/after the client's
|
||||
locked-until date (i.e. not in a reconciled/locked period)."
|
||||
[all-ids]
|
||||
(->> all-ids
|
||||
(dc/q '[:find ?t
|
||||
:in $ [?t ...]
|
||||
:where
|
||||
[?t :journal-entry/client ?c]
|
||||
[(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu]
|
||||
[?t :journal-entry/date ?d]
|
||||
[(>= ?d ?lu)]]
|
||||
(dc/db conn))
|
||||
(map first)))
|
||||
|
||||
(defn bulk-delete [request]
|
||||
(assert-admin (:identity request))
|
||||
(let [params (:form-params request)
|
||||
ids (selected->ids (assoc-in request [:route-params :external?] true) params)
|
||||
all-ids (all-ids-not-locked ids)]
|
||||
(if (> (count all-ids) 1000)
|
||||
(modal-response
|
||||
(com/success-modal {:title "Too many ledger entries"}
|
||||
[:p "You can only delete 1000 ledger entries at a time."]))
|
||||
(do
|
||||
(alog/info ::bulk-delete-ledger :count (count all-ids) :sample (take 3 all-ids))
|
||||
(audit-transact-batch
|
||||
(map (fn [i] [:db/retractEntity i]) all-ids)
|
||||
(:identity request))
|
||||
(modal-response
|
||||
(com/success-modal {:title "Ledger Entries Deleted"}
|
||||
[:p (str "Successfully deleted " (count all-ids) " ledger entries.")])
|
||||
:headers {"hx-trigger" "invalidated, reset-selection"})))))
|
||||
|
||||
(defn delete [{invoice :entity :as request identity :identity}]
|
||||
(exception->notification
|
||||
#(when-not (= :invoice-status/unpaid (:invoice/status invoice))
|
||||
@@ -114,17 +148,18 @@
|
||||
(= ::route/external-page matched-current-page-route) (assoc-in [:route-params :external?] true))]
|
||||
(handler request))))
|
||||
|
||||
(defn line->id [{:keys [source external-id client-code]}]
|
||||
(str client-code "-" source "-" external-id))
|
||||
|
||||
(defn external-import-table-form* [request]
|
||||
[:div#table-form
|
||||
(clojure.pprint/pprint (:form-errors request))
|
||||
(fc/start-form
|
||||
(:form-params request)
|
||||
(:form-errors request)
|
||||
(fc/with-field :table
|
||||
(clojure.pprint/pprint (fc/field-errors))
|
||||
(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)
|
||||
:autocomplete "off"}
|
||||
(when (:just-parsed? request)
|
||||
@@ -145,8 +180,10 @@
|
||||
[:div.flex.gap-4.items-center
|
||||
(com/checkbox {"@click" "showTable=!showTable"}
|
||||
"Show table")
|
||||
(com/checkbox {:x-model "errorsOnly"}
|
||||
"Only show errors")
|
||||
(com/button {:color :primary} "Import")]
|
||||
[:div {:x-show "showTable"}
|
||||
[:div {:x-show "showTable || errorsOnly"}
|
||||
(com/data-grid-card {:id "ledger-import-data"
|
||||
:route nil
|
||||
:title "Data to import"
|
||||
@@ -164,81 +201,100 @@
|
||||
:rows
|
||||
(fc/cursor-map
|
||||
(fn [r]
|
||||
(com/data-grid-row {} (com/data-grid-cell {}
|
||||
(fc/with-field :external-id
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input {:value (fc/field-value)
|
||||
:name (fc/field-name)}))))
|
||||
(com/data-grid-cell {}
|
||||
(fc/with-field :client-code
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input {:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:class "w-24"}))))
|
||||
(com/data-grid-cell {}
|
||||
(fc/with-field :source
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input
|
||||
{:value (fc/field-value)
|
||||
:name (fc/field-name)}))))
|
||||
(com/data-grid-cell {} (fc/with-field :vendor-name
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input {:value (fc/field-value)
|
||||
:name (fc/field-name)}))))
|
||||
(com/data-grid-cell {} (fc/with-field :date
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input {:value (some-> (fc/field-value) (atime/unparse-local
|
||||
atime/normal-date))
|
||||
:name (fc/field-name)
|
||||
:class "w-24"}))))
|
||||
(com/data-grid-cell {}
|
||||
(fc/with-field :account-code
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/text-input {:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:class "w-16"}))))
|
||||
(com/data-grid-cell {} (fc/with-field :location
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input {:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:size 2}))))
|
||||
(com/data-grid-cell {} (fc/with-field :debit
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/money-input {:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:class "w-24"}))))
|
||||
(com/data-grid-cell {} (fc/with-field :credit
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/money-input {:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:class "w-24"}))))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
[:div.p-2
|
||||
(let [errors (seq (fc/field-errors))]
|
||||
(cond errors
|
||||
[:div
|
||||
{"x-tooltip" "{content: ()=>$refs.tt.innerHTML , allowHTML: true}"}
|
||||
[:div.w-8.h-8.rounded-full.p-2.flex.items-start {:class
|
||||
(if (seq (filter
|
||||
(fn [[_ status]]
|
||||
(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
|
||||
;; entry id and drops itself when any of its siblings' remove
|
||||
;; buttons announces that id. Removing the row removes its
|
||||
;; inputs, so the entry is gone from the next import post.
|
||||
;; 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})
|
||||
"@remove-import-entry.window" "if ($event.detail.entryId === entryId) $el.remove()"}
|
||||
(not row-errors) (assoc :x-show "!errorsOnly"))
|
||||
(com/data-grid-cell {}
|
||||
(fc/with-field :external-id
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input {:value (fc/field-value)
|
||||
:name (fc/field-name)}))))
|
||||
(com/data-grid-cell {}
|
||||
(fc/with-field :client-code
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input {:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:class "w-24"}))))
|
||||
(com/data-grid-cell {}
|
||||
(fc/with-field :source
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input
|
||||
{:value (fc/field-value)
|
||||
:name (fc/field-name)}))))
|
||||
(com/data-grid-cell {} (fc/with-field :vendor-name
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input {:value (fc/field-value)
|
||||
:name (fc/field-name)}))))
|
||||
(com/data-grid-cell {} (fc/with-field :date
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input {:value (some-> (fc/field-value) (atime/unparse-local
|
||||
atime/normal-date))
|
||||
:name (fc/field-name)
|
||||
:class "w-24"}))))
|
||||
(com/data-grid-cell {}
|
||||
(fc/with-field :account-code
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/text-input {:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:class "w-16"}))))
|
||||
(com/data-grid-cell {} (fc/with-field :location
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input {:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:size 2}))))
|
||||
(com/data-grid-cell {} (fc/with-field :debit
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/money-input {:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:class "w-24"}))))
|
||||
(com/data-grid-cell {} (fc/with-field :credit
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/money-input {:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:class "w-24"}))))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
[:div.p-2.flex.items-start.gap-2
|
||||
(let [errors (seq (fc/field-errors))]
|
||||
(cond errors
|
||||
[:div
|
||||
{"x-tooltip" "{content: ()=>$refs.tt.innerHTML , allowHTML: true}"}
|
||||
[:div.w-8.h-8.rounded-full.p-2.flex.items-start {:class
|
||||
(if (seq (filter
|
||||
(fn [[_ status]]
|
||||
|
||||
(= :error status))
|
||||
errors))
|
||||
"bg-red-50 text-red-300"
|
||||
"bg-yellow-100 text-yellow-600")}
|
||||
svg/alert]
|
||||
[:template {:x-ref "tt"}
|
||||
[:ul
|
||||
(for [[m] errors]
|
||||
[:li m])]]]
|
||||
:else
|
||||
nil))]))))}
|
||||
(= :error status))
|
||||
errors))
|
||||
"bg-red-50 text-red-300"
|
||||
"bg-yellow-100 text-yellow-600")}
|
||||
svg/alert]
|
||||
[:template {:x-ref "tt"}
|
||||
[:ul
|
||||
(for [[m] errors]
|
||||
[:li m])]]]
|
||||
:else
|
||||
nil))
|
||||
(com/icon-button {:type "button"
|
||||
:color :danger-light
|
||||
:title (str "Remove ledger entry " entry-id)
|
||||
:aria-label (str "Remove ledger entry " entry-id)
|
||||
:data-remove-entry-id entry-id
|
||||
"@click.prevent.stop" "$dispatch('remove-import-entry', {entryId: entryId})"}
|
||||
svg/trash)])))))}
|
||||
|
||||
[:div.flex.m-4.flex-row-reverse
|
||||
(com/button {:color :primary} "Import")])]]])))])
|
||||
@@ -374,9 +430,6 @@
|
||||
(html-response
|
||||
(external-import-form* (assoc request :just-parsed? true))))
|
||||
|
||||
(defn line->id [{:keys [source external-id client-code]}]
|
||||
(str client-code "-" source "-" external-id))
|
||||
|
||||
(defn add-errors [entry all-vendors all-accounts client-locked-lookup all-client-bank-accounts all-client-locations]
|
||||
(let [vendor (all-vendors (:vendor-name entry))
|
||||
locked-until (client-locked-lookup (:client-code entry))
|
||||
@@ -473,12 +526,23 @@
|
||||
ea)))
|
||||
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]
|
||||
(let [lines-with-indexes (for [[i l] (map vector (range) table)]
|
||||
(assoc l :index i))]
|
||||
(into []
|
||||
(for [[_ lines] (group-by line->id lines-with-indexes)
|
||||
:let [{:keys [source client-code date vendor-name note cleared-against] :as line} (first lines)]]
|
||||
(for [[_ grouped-lines] (group-by line->id lines-with-indexes)
|
||||
: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
|
||||
:indices (map :index lines)
|
||||
:external-id (line->id line)
|
||||
@@ -696,6 +760,8 @@
|
||||
::route/csv (helper/csv-route grid-page)
|
||||
::route/external-import-page external-import-page
|
||||
::route/bank-account-filter bank-account-filter
|
||||
::route/bulk-delete (-> bulk-delete
|
||||
(wrap-schema-enforce :form-schema query-schema))
|
||||
::route/external-import-parse (-> external-import-parse
|
||||
(wrap-schema-enforce :form-schema parse-form-schema)
|
||||
(wrap-form-4xx-2 external-import-parse)
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.form-cursor :as fc]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.ledger.export-modal :refer [export-modal]]
|
||||
[auto-ap.ssr.ledger.report-table :as rtable]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.ui :refer [base-page]]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [apply-middleware-to-all-handlers clj-date-schema
|
||||
html-response modal-response wrap-form-4xx-2
|
||||
html-response wrap-form-4xx-2
|
||||
wrap-schema-enforce]]
|
||||
[auto-ap.time :as atime]
|
||||
[bidi.bidi :as bidi]
|
||||
@@ -89,6 +89,7 @@
|
||||
:account-type (:account_type account)
|
||||
:numeric-code (:numeric_code account)
|
||||
:name (:name account)
|
||||
:bank-account-name? (:bank_account_name? account)
|
||||
:period (coerce/to-date d)}))
|
||||
args (assoc (:query-params request)
|
||||
:periods (map coerce/to-date (filter identity date)))
|
||||
@@ -235,30 +236,12 @@
|
||||
:report/creator (:user (:identity request))
|
||||
:report/created (java.util.Date.)}])
|
||||
{:report/name name
|
||||
:report/url url}))
|
||||
:report/url url
|
||||
:report/clients client}))
|
||||
|
||||
;; TODO PRINT WARNING
|
||||
(defn export [request]
|
||||
(modal-response
|
||||
(com/modal {}
|
||||
(com/modal-card
|
||||
{}
|
||||
"Ready!"
|
||||
(com/modal-body {}
|
||||
(let [bs (print-balance-sheet request)]
|
||||
[:div.flex.flex-col.mt-4.space-y-4.items-center
|
||||
[:a {:href (:report/url bs)}
|
||||
[:div.w-24.h-24.bg-green-50.rounded-full.p-4.text-green-300 {:class " hover:scale-110 transition duration-100"}
|
||||
|
||||
svg/download]]
|
||||
[:span.text-gray-800
|
||||
"Click "
|
||||
(com/link {:href (:report/url bs)} "here")
|
||||
" to download"]]))
|
||||
nil))
|
||||
:headers (-> {}
|
||||
(assoc "hx-retarget" ".modal-stack")
|
||||
(assoc "hx-reswap" "beforeend"))))
|
||||
(export-modal request (print-balance-sheet request)))
|
||||
|
||||
(def key->handler
|
||||
(apply-middleware-to-all-handlers
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.form-cursor :as fc]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.ledger.export-modal :refer [export-modal]]
|
||||
[auto-ap.ssr.ledger.report-table :refer [cell-count concat-tables table]]
|
||||
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.ui :refer [base-page]]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [apply-middleware-to-all-handlers clj-date-schema html-response
|
||||
modal-response wrap-form-4xx-2 wrap-schema-enforce]]
|
||||
wrap-form-4xx-2 wrap-schema-enforce]]
|
||||
[auto-ap.time :as atime]
|
||||
[bidi.bidi :as bidi]
|
||||
[clj-pdf.core :as pdf]
|
||||
@@ -88,6 +88,7 @@
|
||||
:account-type (:account_type account)
|
||||
:numeric-code (:numeric_code account)
|
||||
:name (:name account)
|
||||
:bank-account-name? (:bank_account_name? account)
|
||||
:period {:start (coerce/to-date (:start p)) :end (coerce/to-date (:end p))}}))
|
||||
args (assoc (:form-params request)
|
||||
:periods (map (fn [d] {:start (coerce/to-date (:start d)) :end (coerce/to-date (:end d))}) periods))
|
||||
@@ -226,31 +227,12 @@
|
||||
:report/creator (:user (:identity request))
|
||||
:report/created (java.util.Date.)}])
|
||||
{:report/name name
|
||||
:report/url url}))
|
||||
:report/url url
|
||||
:report/clients client}))
|
||||
|
||||
;; TODO PRINT WARNING
|
||||
(defn export [request]
|
||||
(modal-response
|
||||
(com/modal {}
|
||||
(com/modal-card
|
||||
{}
|
||||
"Ready!"
|
||||
(com/modal-body {}
|
||||
(let [bs (print-cash-flows request)]
|
||||
[:div.flex.flex-col.mt-4.space-y-4.items-center
|
||||
[:a {:href (:report/url bs)}
|
||||
[:div.w-24.h-24.bg-green-50.rounded-full.p-4.text-green-300 {:class " hover:scale-110 transition duration-100"}
|
||||
|
||||
svg/download]]
|
||||
[:span.text-gray-800
|
||||
"Click "
|
||||
(com/link {:href (:report/url bs)} "here")
|
||||
" to download"]]))
|
||||
|
||||
nil))
|
||||
:headers (-> {}
|
||||
(assoc "hx-retarget" ".modal-stack")
|
||||
(assoc "hx-reswap" "beforeend"))))
|
||||
(export-modal request (print-cash-flows request)))
|
||||
|
||||
(def key->handler
|
||||
(apply-middleware-to-all-handlers
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
[auto-ap.routes.transactions :as transaction-routes]
|
||||
[auto-ap.ssr-routes :as ssr-routes]
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.components.date-range :as dr]
|
||||
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
|
||||
[auto-ap.ssr.grid-page-helper :as helper]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.pos.common :refer [date-range-field*]]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [clj-date-schema entity-id html-response ref->enum-schema
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
(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"}
|
||||
[:div {:x-data (hx/json {:exact_match (:exact-match-id (:query-params request))}) :id "exact-match-id-tag" :class "filter-trigger"}
|
||||
(com/hidden {:name "exact-match-id"
|
||||
"x-model" "exact_match"})
|
||||
(com/pill {:color :primary}
|
||||
@@ -46,13 +46,14 @@
|
||||
[:div {:hx-trigger "clientSelected from:body"
|
||||
:hx-get (bidi.bidi/path-for ssr-routes/only-routes ::route/bank-account-filter)
|
||||
:hx-target "this"
|
||||
:hx-swap "outerHTML"}
|
||||
:hx-swap "outerHTML"
|
||||
:class "filter-trigger"}
|
||||
(when (:client request)
|
||||
(let [bank-account-belongs-to-client? (get (set (map :db/id (:client/bank-accounts (:client request))))
|
||||
(:db/id (:bank-account (:query-params request))))]
|
||||
(com/field {:label "Bank Account"}
|
||||
(com/radio-card {:size :small
|
||||
:name "bank-account"
|
||||
(com/radio-card {:size :small
|
||||
:name "bank-account"
|
||||
:value (or (when bank-account-belongs-to-client?
|
||||
(:db/id (:bank-account (:query-params request))))
|
||||
"")
|
||||
@@ -60,90 +61,123 @@
|
||||
(into [{:value ""
|
||||
:content "All"}]
|
||||
(for [ba (:client/bank-accounts (:client request))]
|
||||
{:value (:db/id ba)
|
||||
{:value (:db/id ba)
|
||||
:content (:bank-account/name ba)}))}))))])
|
||||
|
||||
(defn bank-account-filter [request]
|
||||
(html-response (bank-account-filter* request)))
|
||||
|
||||
(defn filters [request]
|
||||
[:form#ledger-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/table)
|
||||
"hx-target" "#entity-table"
|
||||
[:form#ledger-filters {"hx-trigger" "datesApplied, change delay:500ms from:.filter-trigger, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/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"
|
||||
(com/typeahead {:name "vendor"
|
||||
:id "vendor"
|
||||
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
|
||||
:value (:vendor (:query-params request))
|
||||
:value (:vendor (:query-params request))
|
||||
:value-fn :db/id
|
||||
:content-fn :vendor/name}))
|
||||
:content-fn :vendor/name
|
||||
:class "filter-trigger"}))
|
||||
(com/field {:label "Account"}
|
||||
(com/typeahead {:name "account"
|
||||
:id "account"
|
||||
(com/typeahead {:name "account"
|
||||
:id "account"
|
||||
:url (bidi/path-for ssr-routes/only-routes :account-search)
|
||||
:value (:account (:query-params request))
|
||||
:value (:account (:query-params request))
|
||||
:value-fn :db/id
|
||||
:content-fn #(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read (:db/id %))
|
||||
(:db/id (:client request))))}))
|
||||
(:db/id (:client request))))
|
||||
:class "filter-trigger"}))
|
||||
|
||||
(bank-account-filter* request)
|
||||
|
||||
(date-range-field* request)
|
||||
(dr/date-range-field {:value {:start (:start-date (:query-params request))
|
||||
:end (:end-date (:query-params request))}
|
||||
:id "date-range"
|
||||
:apply-button? true})
|
||||
(com/field {:label "Invoice #"}
|
||||
(com/text-input {:name "invoice-number"
|
||||
:id "invoice-number"
|
||||
:class "hot-filter"
|
||||
:value (:invoice-number (:query-params request))
|
||||
(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}))
|
||||
:size :small}))
|
||||
|
||||
(when (:external? (:route-params request))
|
||||
(com/field {:label "Source"}
|
||||
(com/text-input {:name "source"
|
||||
:id "source"
|
||||
:class "hot-filter"
|
||||
:value (:source (:query-params request))
|
||||
:placeholder "e.g., invoice"
|
||||
:size :small})))
|
||||
|
||||
(when (:external? (:route-params request))
|
||||
(com/field {:label "External Id"}
|
||||
(com/text-input {:name "external-id-like"
|
||||
:id "external-id-like"
|
||||
:class "hot-filter"
|
||||
:value (:external-id-like (:query-params request))
|
||||
:placeholder "e.g., ABC-123"
|
||||
:size :small})))
|
||||
|
||||
(when (:external? (:route-params request))
|
||||
(com/field {:label "Location"}
|
||||
(com/text-input {:name "location"
|
||||
:id "location"
|
||||
:class "hot-filter"
|
||||
:value (:location (:query-params request))
|
||||
:placeholder "SC"
|
||||
:size :small})))
|
||||
|
||||
(com/field {:label "Account Code"}
|
||||
[:div.flex.space-x-4.items-baseline
|
||||
(com/int-input {:name "numeric-code-gte"
|
||||
:id "numeric-code-gte"
|
||||
(com/int-input {:name "numeric-code-gte"
|
||||
:id "numeric-code-gte"
|
||||
:hx-preserve "true"
|
||||
:class "hot-filter w-20"
|
||||
:value (:numeric-code-gte (:query-params request))
|
||||
:class "hot-filter w-20"
|
||||
:value (:numeric-code-gte (:query-params request))
|
||||
:placeholder "40000"
|
||||
:size :small})
|
||||
:size :small})
|
||||
[:div.align-baseline
|
||||
"to"]
|
||||
(com/int-input {:name "numeric-code-lte"
|
||||
(com/int-input {:name "numeric-code-lte"
|
||||
:hx-preserve "true"
|
||||
:id "numeric-code-lte"
|
||||
:class "hot-filter w-20"
|
||||
:value (:numeric-code-lte (:query-params request))
|
||||
:id "numeric-code-lte"
|
||||
:class "hot-filter w-20"
|
||||
:value (:numeric-code-lte (:query-params request))
|
||||
:placeholder "50000"
|
||||
:size :small})])
|
||||
:size :small})])
|
||||
|
||||
(com/field {:label "Amount"}
|
||||
[:div.flex.space-x-4.items-baseline
|
||||
(com/money-input {:name "amount-gte"
|
||||
:id "amount-gte"
|
||||
(com/money-input {:name "amount-gte"
|
||||
:id "amount-gte"
|
||||
:hx-preserve "true"
|
||||
:class "hot-filter w-20"
|
||||
:value (:amount-gte (:query-params request))
|
||||
:class "hot-filter w-20"
|
||||
:value (:amount-gte (:query-params request))
|
||||
:placeholder "0.01"
|
||||
:size :small})
|
||||
:size :small})
|
||||
[:div.align-baseline
|
||||
"to"]
|
||||
(com/money-input {:name "amount-lte"
|
||||
(com/money-input {:name "amount-lte"
|
||||
:hx-preserve "true"
|
||||
:id "amount-lte"
|
||||
:class "hot-filter w-20"
|
||||
:value (:amount-lte (:query-params request))
|
||||
:id "amount-lte"
|
||||
:class "hot-filter w-20"
|
||||
:value (:amount-lte (:query-params request))
|
||||
:placeholder "9999.34"
|
||||
:size :small})])
|
||||
[:div.mt-4 {:x-data (hx/json {:onlyUnbalanced (:only-unbalanced (:query-params request))})}
|
||||
:size :small})])
|
||||
[:div.mt-4 {:x-data (hx/json {:onlyUnbalanced (:only-unbalanced (:query-params request))})}
|
||||
(com/hidden {:name "only-unbalanced"
|
||||
":value" "onlyUnbalanced ? 'on' : ''"})
|
||||
(com/checkbox {:value (:only-unbalanced (:query-params request))
|
||||
:class "filter-trigger"
|
||||
:x-model "onlyUnbalanced"}
|
||||
"Show unbalanced")]
|
||||
(exact-match-id* request)]])
|
||||
@@ -175,6 +209,37 @@
|
||||
;; 3. CSVs
|
||||
;; 4. better date range / advanced mode for dialog
|
||||
|
||||
(defn- accounts-sharing-code
|
||||
"The searched account, plus any of `clients`' bank accounts on the same numeric
|
||||
code. :journal-entry-line/account points at either entity, so a search for the
|
||||
financial account has to match lines posted to the bank account too."
|
||||
[db clients account-id]
|
||||
(into [account-id]
|
||||
(when-let [code (:account/numeric-code (dc/entity db account-id))]
|
||||
(dc/q '[:find [?ba ...]
|
||||
|
||||
:in $ [?client ...] ?code
|
||||
|
||||
:where
|
||||
[?client :client/bank-accounts ?ba]
|
||||
[?ba :bank-account/numeric-code ?code]]
|
||||
db clients code))))
|
||||
|
||||
(defn- account-filter-query
|
||||
"Ledger clause for the Account search. Stays a scalar binding when nothing
|
||||
shares the account's code, so the common case costs what it always did."
|
||||
[db clients account-id]
|
||||
(let [ids (accounts-sharing-code db clients account-id)]
|
||||
(if (second ids)
|
||||
{:query {:in ['[?a3 ...]]
|
||||
|
||||
:where ['[?li :journal-entry-line/account ?a3]]}
|
||||
:args [ids]}
|
||||
{:query {:in ['?a3]
|
||||
|
||||
:where ['[?li :journal-entry-line/account ?a3]]}
|
||||
:args [account-id]})))
|
||||
|
||||
(defn fetch-ids [db {:keys [query-params route-params] :as request}]
|
||||
(let [valid-clients (extract-client-ids (:clients request)
|
||||
(:client-id request)
|
||||
@@ -184,12 +249,12 @@
|
||||
args query-params
|
||||
query
|
||||
(if (:exact-match-id args)
|
||||
{:query {:find '[?e]
|
||||
:in '[$ ?e [?c ...]]
|
||||
{:query {:find '[?e]
|
||||
:in '[$ ?e [?c ...]]
|
||||
:where '[[?e :journal-entry/client ?c]]}
|
||||
:args [db
|
||||
(:exact-match-id args)
|
||||
valid-clients]}
|
||||
:args [db
|
||||
(:exact-match-id args)
|
||||
valid-clients]}
|
||||
(cond-> {:query {:find []
|
||||
:in ['$ '[?clients ?start ?end]]
|
||||
:where '[[(iol-ion.query/scan-ledger $ ?clients ?start ?end) [[?e _ ?sort-default] ...]]]}
|
||||
@@ -202,28 +267,28 @@
|
||||
(merge-query {:query {:where ['(not [?e :journal-entry/original-entity])]}})
|
||||
|
||||
(seq (:external-id-like args))
|
||||
(merge-query {:query {:in ['?external-id-like]
|
||||
(merge-query {:query {:in ['?external-id-like]
|
||||
:where ['[?e :journal-entry/external-id ?external-id]
|
||||
'[(.contains ^String ?external-id ?external-id-like)]]}
|
||||
:args [(:external-id-like args)]})
|
||||
:args [(:external-id-like args)]})
|
||||
|
||||
(seq (:source args))
|
||||
(merge-query {:query {:in ['?source]
|
||||
(merge-query {:query {:in ['?source]
|
||||
:where ['[?e :journal-entry/source ?source]]}
|
||||
:args [(:source args)]})
|
||||
:args [(:source args)]})
|
||||
(:external? route-params)
|
||||
(merge-query {:query {:where ['[?e :journal-entry/external-id]]}})
|
||||
|
||||
(:vendor args)
|
||||
(merge-query {:query {:in ['?vendor-id]
|
||||
(merge-query {:query {:in ['?vendor-id]
|
||||
:where ['[?e :journal-entry/vendor ?vendor-id]]}
|
||||
:args [(:db/id (:vendor args))]})
|
||||
:args [(:db/id (:vendor args))]})
|
||||
|
||||
(:invoice-number args)
|
||||
(merge-query {:query {:in ['?invoice-number]
|
||||
(merge-query {:query {:in ['?invoice-number]
|
||||
:where ['[?e :journal-entry/original-entity ?oe]
|
||||
'[?oe :invoice/invoice-number ?invoice-number]]}
|
||||
:args [(:invoice-number args)]})
|
||||
:args [(:invoice-number args)]})
|
||||
|
||||
(or (:numeric-code-lte args)
|
||||
(:numeric-code-gte args)
|
||||
@@ -235,77 +300,75 @@
|
||||
|
||||
(or (:numeric-code-gte args)
|
||||
(:numeric-code-lte args))
|
||||
(merge-query {:query {:in '[?from-numeric-code ?to-numeric-code]
|
||||
(merge-query {:query {:in '[?from-numeric-code ?to-numeric-code]
|
||||
:where ['[?li :journal-entry-line/account ?a]
|
||||
'(or-join [?a ?c]
|
||||
[?a :account/numeric-code ?c]
|
||||
[?a :bank-account/numeric-code ?c])
|
||||
'[(>= ?c ?from-numeric-code)]
|
||||
'[(<= ?c ?to-numeric-code)]]}
|
||||
:args [(or (:numeric-code-gte args) 0) (or (:numeric-code-lte args) 99999)]})
|
||||
:args [(or (:numeric-code-gte args) 0) (or (:numeric-code-lte args) 99999)]})
|
||||
|
||||
(seq (:numeric-code args))
|
||||
(merge-query {:query {:in '[[[?from-numeric-code ?to-numeric-code] ...]]
|
||||
(merge-query {:query {:in '[[[?from-numeric-code ?to-numeric-code] ...]]
|
||||
:where ['[?li :journal-entry-line/account ?a]
|
||||
'(or-join [?a ?c]
|
||||
[?a :account/numeric-code ?c]
|
||||
[?a :bank-account/numeric-code ?c])
|
||||
'[(>= ?c ?from-numeric-code)]
|
||||
'[(<= ?c ?to-numeric-code)]]}
|
||||
:args [(map (juxt :from :to) (:numeric-code args))]})
|
||||
:args [(map (juxt :from :to) (:numeric-code args))]})
|
||||
(seq (:account args))
|
||||
(merge-query {:query {:in ['?a3]
|
||||
:where ['[?li :journal-entry-line/account ?a3]]}
|
||||
:args [(:db/id (:account args))]})
|
||||
(merge-query (account-filter-query db valid-clients (:db/id (:account args))))
|
||||
|
||||
(:amount-gte args)
|
||||
(merge-query {:query {:in ['?amount-gte]
|
||||
(merge-query {:query {:in ['?amount-gte]
|
||||
:where ['[?e :journal-entry/amount ?a]
|
||||
'[(>= ?a ?amount-gte)]]}
|
||||
:args [(:amount-gte args)]})
|
||||
:args [(:amount-gte args)]})
|
||||
|
||||
(:amount-lte args)
|
||||
(merge-query {:query {:in ['?amount-lte]
|
||||
(merge-query {:query {:in ['?amount-lte]
|
||||
:where ['[?e :journal-entry/amount ?a]
|
||||
'[(<= ?a ?amount-lte)]]}
|
||||
:args [(:amount-lte args)]})
|
||||
:args [(:amount-lte args)]})
|
||||
|
||||
(:db/id (:bank-account args))
|
||||
(merge-query {:query {:in ['?a]
|
||||
(merge-query {:query {:in ['?a]
|
||||
:where ['[?li :journal-entry-line/account ?a]]}
|
||||
:args [(:db/id (:bank-account args))]})
|
||||
:args [(:db/id (:bank-account args))]})
|
||||
|
||||
(:account-id args)
|
||||
(merge-query {:query {:in ['?a2]
|
||||
(merge-query {:query {:in ['?a2]
|
||||
:where ['[?e :journal-entry/line-items ?li2]
|
||||
'[?li2 :journal-entry-line/account ?a2]]}
|
||||
:args [(:account-id args)]})
|
||||
:args [(:account-id args)]})
|
||||
|
||||
(not-empty (:location args))
|
||||
(merge-query {:query {:in ['?location]
|
||||
(merge-query {:query {:in ['?location]
|
||||
:where ['[?li :journal-entry-line/location ?location]]}
|
||||
:args [(:location args)]})
|
||||
:args [(:location args)]})
|
||||
|
||||
(not-empty (:locations args))
|
||||
(merge-query {:query {:in ['[?location ...]]
|
||||
(merge-query {:query {:in ['[?location ...]]
|
||||
:where ['[?li :journal-entry-line/location ?location]]}
|
||||
:args [(:locations args)]})
|
||||
:args [(:locations args)]})
|
||||
|
||||
(:sort args) (add-sorter-fields {"client" ['[?e :journal-entry/client ?c]
|
||||
'[?c :client/name ?sort-client]]
|
||||
"date" ['[?e :journal-entry/date ?sort-date]]
|
||||
"vendor" '[(or-join [?e ?sort-vendor]
|
||||
(and
|
||||
[?e :journal-entry/vendor ?v]
|
||||
[?v :vendor/name ?sort-vendor])
|
||||
(and [(missing? $ ?e :journal-entry/vendor)]
|
||||
[(ground "") ?sort-vendor]))]
|
||||
"amount" ['[?e :journal-entry/amount ?sort-amount]]
|
||||
(:sort args) (add-sorter-fields {"client" ['[?e :journal-entry/client ?c]
|
||||
'[?c :client/name ?sort-client]]
|
||||
"date" ['[?e :journal-entry/date ?sort-date]]
|
||||
"vendor" '[(or-join [?e ?sort-vendor]
|
||||
(and
|
||||
[?e :journal-entry/vendor ?v]
|
||||
[?v :vendor/name ?sort-vendor])
|
||||
(and [(missing? $ ?e :journal-entry/vendor)]
|
||||
[(ground "") ?sort-vendor]))]
|
||||
"amount" ['[?e :journal-entry/amount ?sort-amount]]
|
||||
"external-id" ['[?e :journal-entry/external-id ?sort-external-id]]
|
||||
"source" '[(or-join [?e ?sort-source]
|
||||
[?e :journal-entry/source ?sort-source]
|
||||
(and [(missing? $ ?e :journal-entry/source)]
|
||||
[(ground "") ?sort-source]))]}
|
||||
"source" '[(or-join [?e ?sort-source]
|
||||
[?e :journal-entry/source ?sort-source]
|
||||
(and [(missing? $ ?e :journal-entry/source)]
|
||||
[(ground "") ?sort-source]))]}
|
||||
args)
|
||||
|
||||
true
|
||||
@@ -334,11 +397,11 @@
|
||||
:journal-entry/external-id
|
||||
:db/id
|
||||
[:journal-entry/date :xform clj-time.coerce/from-date]
|
||||
{:journal-entry/vendor [:vendor/name :db/id]
|
||||
{:journal-entry/vendor [:vendor/name :db/id]
|
||||
:journal-entry/original-entity [:invoice/invoice-number
|
||||
:invoice/source-url
|
||||
:transaction/description-original :db/id]
|
||||
:journal-entry/client [:client/name :client/code :db/id]
|
||||
:journal-entry/client [:client/name :client/code :db/id]
|
||||
:journal-entry/line-items [:journal-entry-line/debit
|
||||
:journal-entry-line/location
|
||||
:journal-entry-line/running-balance
|
||||
@@ -362,8 +425,8 @@
|
||||
(defn sum-outstanding [ids]
|
||||
|
||||
(->>
|
||||
(dc/q {:find ['?id '?o]
|
||||
:in ['$ '[?id ...]]
|
||||
(dc/q {:find ['?id '?o]
|
||||
:in ['$ '[?id ...]]
|
||||
:where ['[?id :invoice/outstanding-balance ?o]]}
|
||||
(dc/db conn)
|
||||
ids)
|
||||
@@ -375,8 +438,8 @@
|
||||
(defn sum-total-amount [ids]
|
||||
|
||||
(->>
|
||||
(dc/q {:find ['?id '?o]
|
||||
:in ['$ '[?id ...]]
|
||||
(dc/q {:find ['?id '?o]
|
||||
:in ['$ '[?id ...]]
|
||||
:where ['[?id :invoice/total ?o]]}
|
||||
(dc/db conn)
|
||||
ids)
|
||||
@@ -386,7 +449,7 @@
|
||||
0.0)))
|
||||
|
||||
(defn fetch-page [request]
|
||||
(let [db (dc/db conn)
|
||||
(let [db (dc/db conn)
|
||||
{ids-to-retrieve :ids matching-count :count
|
||||
all-ids :all-ids} (fetch-ids db request)]
|
||||
|
||||
@@ -410,12 +473,12 @@
|
||||
(if account-name
|
||||
[:div {:x-tooltip (hx/json (str "Running Balance: " (some->> (:journal-entry-line/running-balance jel)
|
||||
(format "$%,.2f"))))}
|
||||
[:div.text-left.underline.cursor-pointer {:x-ref "source"}
|
||||
[:div.text-left.underline.cursor-pointer {:x-ref "source"}
|
||||
(:journal-entry-line/location jel) ": "
|
||||
(or (:account/numeric-code account) (:bank-account/numeric-code account))
|
||||
" - " account-name]]
|
||||
[:div.text-left (com/pill {:color :yellow} "Unassigned")])
|
||||
[:div.text-right.text-underline (format "$%,.2f" (key jel))]))
|
||||
[:div.text-right.text-underline (format "$%,.2f" (key jel))]))
|
||||
|
||||
(when-not (= 1 (count lines))
|
||||
[:div.col-span-2 (com/pill {:color :primary} "Total: " (->> lines
|
||||
@@ -443,11 +506,14 @@
|
||||
[:to nat-int?]]]]]
|
||||
[:numeric-code-gte {:optional true} [:maybe nat-int?]]
|
||||
[:numeric-code-lte {:optional true} [:maybe nat-int?]]
|
||||
[:vendor {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :vendor/name]}]]]
|
||||
[:bank-account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :bank-account/name]}]]]
|
||||
[:account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :account/name]}]]]
|
||||
[:vendor {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :vendor/name]}]]]
|
||||
[:bank-account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :bank-account/name]}]]]
|
||||
[:account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :account/name]}]]]
|
||||
[:check-number {:optional true} [:maybe [:string {:decode/string strip}]]]
|
||||
[:invoice-number {:optional true} [:maybe [:string {:decode/string strip}]]]
|
||||
[:source {:optional true} [:maybe [:string {:decode/string strip}]]]
|
||||
[:external-id-like {:optional true} [:maybe [:string {:decode/string strip}]]]
|
||||
[:location {: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]]
|
||||
@@ -459,23 +525,42 @@
|
||||
[:maybe clj-date-schema]]]]))
|
||||
|
||||
(def grid-page
|
||||
(helper/build {:id "entity-table"
|
||||
:nav com/main-aside-nav
|
||||
(helper/build {:id "entity-table"
|
||||
:nav com/main-aside-nav
|
||||
:check-boxes? true
|
||||
:check-box-warning? (fn [e]
|
||||
(some? (:invoice/scheduled-payment e)))
|
||||
:page-specific-nav filters
|
||||
:fetch-page fetch-page
|
||||
:page-specific-nav filters
|
||||
: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)])
|
||||
[(assoc-in (dr/date-range-field {:value {:start (:start-date (:query-params request))
|
||||
:end (:end-date (:query-params request))}
|
||||
:id "date-range"
|
||||
:apply-button? true}) [1 :hx-swap-oob] true)
|
||||
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)])
|
||||
:query-schema query-schema
|
||||
:action-buttons (fn [request]
|
||||
[(when-not (:external? (:route-params request)) (com/button {:color :primary
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/new)}
|
||||
"Add journal entry"))])
|
||||
[(when-not (:external? (:route-params request))
|
||||
(com/button {:color :primary
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/new)}
|
||||
"Add journal entry"))
|
||||
(when (and (:external? (:route-params request))
|
||||
(= "admin" (:user/role (:identity request))))
|
||||
(com/button {:color :red
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
|
||||
;; target the persistent modal shell content slot directly so the
|
||||
;; request never relies on the outerHTML swap inherited from the
|
||||
;; data-grid card (which would replace #modal-holder and break the
|
||||
;; next click). modal-response also retargets here.
|
||||
:hx-target "#modal-content"
|
||||
:hx-swap "innerHTML"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"x-bind:disabled" "selected.length === 0 && !all_selected"
|
||||
"hx-include" "#ledger-filters"
|
||||
:hx-confirm "Are you sure you want to delete these ledger entries?"}
|
||||
"Delete selected"))])
|
||||
:row-buttons (fn [request entity]
|
||||
[(when (and (= :invoice-status/unpaid (:invoice/status entity))
|
||||
(can? (:identity request) {:subject :invoice :activity :delete}))
|
||||
@@ -485,7 +570,7 @@
|
||||
:hx-confirm "Are you sure you want to void this invoice?"}
|
||||
svg/trash))
|
||||
(when (and (can? (:identity request) {:subject :invoice :activity :edit})
|
||||
(#{:invoice-status/unpaid :invoice-status/paid} (:invoice/status entity)))
|
||||
(#{:invoice-status/unpaid :invoice-status/paid} (:invoice/status entity)))
|
||||
(com/icon-button {:hx-put (bidi/path-for ssr-routes/only-routes
|
||||
::route/edit-wizard
|
||||
:db/id (:db/id entity))}
|
||||
@@ -497,14 +582,16 @@
|
||||
:db/id (:db/id entity))}
|
||||
svg/undo))])
|
||||
|
||||
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
|
||||
"Ledger"]]
|
||||
:title (fn [r]
|
||||
(str
|
||||
(some-> r :route-params :status name str/capitalize (str " "))
|
||||
"Register"))
|
||||
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
|
||||
"Ledger"]]
|
||||
:title (fn [r]
|
||||
(str
|
||||
(some-> r :route-params :status name str/capitalize (str " "))
|
||||
(if (:external? (:route-params r))
|
||||
"External Register"
|
||||
"Register")))
|
||||
:entity-name "register"
|
||||
:route ::route/table
|
||||
:route ::route/table
|
||||
:csv-route ::route/csv
|
||||
:break-table (fn [request entity]
|
||||
(cond
|
||||
@@ -521,102 +608,109 @@
|
||||
(for [je journal-entries
|
||||
jel (:journal-entry/line-items je)]
|
||||
(merge jel je)))
|
||||
:headers [{:key "id"
|
||||
:name "Id"
|
||||
:render-csv :db/id
|
||||
:render-for #{:csv}}
|
||||
{: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 :journal-entry/client :client/name)])
|
||||
:render-csv (fn [x] (-> x :journal-entry/client :client/name))}
|
||||
:headers [{:key "id"
|
||||
:name "Id"
|
||||
:render-csv :db/id
|
||||
:render-for #{:csv}}
|
||||
{: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 :journal-entry/client :client/name)])
|
||||
:render-csv (fn [x] (-> x :journal-entry/client :client/name))}
|
||||
|
||||
{:key "vendor"
|
||||
:name "Vendor"
|
||||
:sort-key "vendor"
|
||||
:render (fn [e] (or (-> e :journal-entry/vendor :vendor/name)
|
||||
[:span.italic.text-gray-400 (-> e :journal-entry/alternate-description)]))
|
||||
:render-csv (fn [e] (or (-> e :journal-entry/vendor :vendor/name)
|
||||
(-> e :journal-entry/alternate-description)))}
|
||||
{:key "source"
|
||||
:name "Source"
|
||||
:sort-key "source"
|
||||
:hide? (fn [args]
|
||||
(not (:external? (:route-params args))))
|
||||
:render :journal-entry/source
|
||||
:render-csv :journal-entry/source}
|
||||
{:key "external-id"
|
||||
:name "External Id"
|
||||
:sort-key "external-id"
|
||||
:class "max-w-[12rem]"
|
||||
:hide? (fn [args]
|
||||
(not (:external? (:route-params args))))
|
||||
:render (fn [x] [:p.truncate (:journal-entry/external-id x)])
|
||||
:render-csv :journal-entry/external-id}
|
||||
{:key "date"
|
||||
:sort-key "date"
|
||||
:name "Date"
|
||||
:show-starting "lg"
|
||||
:render (fn [{:journal-entry/keys [date]}]
|
||||
(some-> date (atime/unparse-local atime/normal-date)))}
|
||||
{:key "amount"
|
||||
:sort-key "amount"
|
||||
:name "Amount"
|
||||
:show-starting "lg"
|
||||
:render (fn [{:journal-entry/keys [amount]}]
|
||||
(some->> amount
|
||||
(format "$%,.2f")))}
|
||||
{:key "account"
|
||||
:name "Account"
|
||||
:sort-key "account"
|
||||
:class "text-right"
|
||||
:render-csv #(or (-> % :journal-entry-line/account :account/name)
|
||||
(-> % :journal-entry-line/account :bank-account/name))
|
||||
:render-for #{:csv}}
|
||||
{:key "debit"
|
||||
:name "Debit"
|
||||
:class "text-right"
|
||||
:render (partial render-lines :journal-entry-line/debit)
|
||||
:render-csv :journal-entry-line/debit}
|
||||
{:key "vendor"
|
||||
:name "Vendor"
|
||||
:sort-key "vendor"
|
||||
:render (fn [e] (or (-> e :journal-entry/vendor :vendor/name)
|
||||
[:span.italic.text-gray-400 (-> e :journal-entry/alternate-description)]))
|
||||
:render-csv (fn [e] (or (-> e :journal-entry/vendor :vendor/name)
|
||||
(-> e :journal-entry/alternate-description)))}
|
||||
{:key "source"
|
||||
:name "Source"
|
||||
:sort-key "source"
|
||||
:hide? (fn [args]
|
||||
(not (:external? (:route-params args))))
|
||||
:render :journal-entry/source
|
||||
:render-csv :journal-entry/source}
|
||||
{:key "external-id"
|
||||
:name "External Id"
|
||||
:sort-key "external-id"
|
||||
:class "max-w-[12rem]"
|
||||
:hide? (fn [args]
|
||||
(not (:external? (:route-params args))))
|
||||
:render (fn [x] [:p.truncate (:journal-entry/external-id x)])
|
||||
:render-csv :journal-entry/external-id}
|
||||
{:key "date"
|
||||
:sort-key "date"
|
||||
:name "Date"
|
||||
:show-starting "lg"
|
||||
:render (fn [{:journal-entry/keys [date]}]
|
||||
(some-> date (atime/unparse-local atime/normal-date)))}
|
||||
{:key "amount"
|
||||
:sort-key "amount"
|
||||
:name "Amount"
|
||||
:show-starting "lg"
|
||||
:render (fn [{:journal-entry/keys [amount]}]
|
||||
(some->> amount
|
||||
(format "$%,.2f")))}
|
||||
{:key "account-number"
|
||||
:name "Account Number"
|
||||
:class "text-right"
|
||||
:render-csv #(or (-> % :journal-entry-line/account :account/numeric-code)
|
||||
(-> % :journal-entry-line/account :bank-account/numeric-code))
|
||||
:render-for #{:csv}}
|
||||
{:key "account"
|
||||
:name "Account"
|
||||
:sort-key "account"
|
||||
:class "text-right"
|
||||
:render-csv #(or (-> % :journal-entry-line/account :account/name)
|
||||
(-> % :journal-entry-line/account :bank-account/name))
|
||||
:render-for #{:csv}}
|
||||
{:key "debit"
|
||||
:name "Debit"
|
||||
:class "text-right"
|
||||
:render (partial render-lines :journal-entry-line/debit)
|
||||
:render-csv :journal-entry-line/debit}
|
||||
|
||||
{:key "credit"
|
||||
:name "Credit"
|
||||
:class "text-right"
|
||||
:render (partial render-lines :journal-entry-line/credit)
|
||||
:render-csv :journal-entry-line/credit}
|
||||
{:key "credit"
|
||||
:name "Credit"
|
||||
:class "text-right"
|
||||
:render (partial render-lines :journal-entry-line/credit)
|
||||
:render-csv :journal-entry-line/credit}
|
||||
|
||||
{:key "links"
|
||||
:name "Links"
|
||||
:show-starting "lg"
|
||||
:class "w-8"
|
||||
:render (fn [i]
|
||||
(link-dropdown
|
||||
(cond-> []
|
||||
(-> i :journal-entry/original-entity :invoice/invoice-number)
|
||||
(conj
|
||||
{:link (hu/url (bidi/path-for ssr-routes/only-routes
|
||||
::invoice-route/all-page)
|
||||
{:exact-match-id (:db/id (:journal-entry/original-entity i))})
|
||||
:color :primary
|
||||
:content (format "Invoice '%s'" (-> i :journal-entry/original-entity :invoice/invoice-number))})
|
||||
(-> i :journal-entry/original-entity :invoice/source-url)
|
||||
{:link (-> i :journal-entry/original-entity :invoice/source-url)
|
||||
:color :secondary
|
||||
:content (str "File")}
|
||||
{:key "links"
|
||||
:name "Links"
|
||||
:show-starting "lg"
|
||||
:class "w-8"
|
||||
:render (fn [i]
|
||||
(link-dropdown
|
||||
(cond-> []
|
||||
(-> i :journal-entry/original-entity :invoice/invoice-number)
|
||||
(conj
|
||||
{:link (hu/url (bidi/path-for ssr-routes/only-routes
|
||||
::invoice-route/all-page)
|
||||
{:exact-match-id (:db/id (:journal-entry/original-entity i))})
|
||||
:color :primary
|
||||
:content (format "Invoice '%s'" (-> i :journal-entry/original-entity :invoice/invoice-number))})
|
||||
(-> i :journal-entry/original-entity :invoice/source-url)
|
||||
(conj
|
||||
{:link (-> i :journal-entry/original-entity :invoice/source-url)
|
||||
:color :secondary
|
||||
:content (str "File")})
|
||||
|
||||
(-> i :journal-entry/original-entity :transaction/description-original)
|
||||
(conj
|
||||
{:link (hu/url (bidi/path-for ssr-routes/only-routes
|
||||
::transaction-routes/all-page)
|
||||
{:exact-match-id (:db/id (:journal-entry/original-entity i))})
|
||||
:color :primary
|
||||
:content (format "Transaction '%s'" (-> i :journal-entry/original-entity :transaction/description-original))})
|
||||
(-> i :journal-entry/memo)
|
||||
(conj {:color :secondary
|
||||
:content (str "Memo: " (:journal-entry/memo i))}))))
|
||||
:render-for #{:html}}]}))
|
||||
(-> i :journal-entry/original-entity :transaction/description-original)
|
||||
(conj
|
||||
{:link (hu/url (bidi/path-for ssr-routes/only-routes
|
||||
::transaction-routes/page)
|
||||
{:exact-match-id (:db/id (:journal-entry/original-entity i))})
|
||||
:color :primary
|
||||
:content (format "Transaction '%s'" (-> i :journal-entry/original-entity :transaction/description-original))})
|
||||
(-> i :journal-entry/memo)
|
||||
(conj {:color :secondary
|
||||
:content (str "Memo: " (:journal-entry/memo i))}))))
|
||||
:render-for #{:html}}]}))
|
||||
|
||||
(def row* (partial helper/row* grid-page))
|
||||
148
src/clj/auto_ap/ssr/ledger/export_modal.clj
Normal file
148
src/clj/auto_ap/ssr/ledger/export_modal.clj
Normal file
@@ -0,0 +1,148 @@
|
||||
(ns auto-ap.ssr.ledger.export-modal
|
||||
"Shared \"your report is ready\" modal for the SSR ledger reports.
|
||||
|
||||
Offers the generated PDF for download and, for admins running a report for a
|
||||
single client, a `mailto:` hand-off pre-filled with that client's email
|
||||
contacts. Nothing is sent by the server - the link opens the user's own mail
|
||||
client so they can review before sending."
|
||||
(:require
|
||||
[auto-ap.datomic :refer [conn pull-many]]
|
||||
[auto-ap.graphql.utils :refer [is-admin?]]
|
||||
[auto-ap.routes.transactions :as transaction-routes]
|
||||
[auto-ap.ssr-routes :as ssr-routes]
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.utils :refer [modal-response]]
|
||||
[bidi.bidi :as bidi]
|
||||
[clojure.string :as str]
|
||||
[config.core :refer [env]]
|
||||
[datomic.api :as dc])
|
||||
(:import
|
||||
[java.net URLEncoder]))
|
||||
|
||||
(def recipient-separator
|
||||
"RFC 6068 separator for multiple mailto recipients. Outlook, Gmail and Apple
|
||||
Mail all accept a comma; the legacy SPA used a semicolon, which only Outlook
|
||||
understood."
|
||||
",")
|
||||
|
||||
(defn url-encode
|
||||
"Percent-encode a mailto header value. URLEncoder does form encoding, so
|
||||
spaces come back as `+`; mail clients want %20."
|
||||
[s]
|
||||
(-> (URLEncoder/encode (str s) "UTF-8")
|
||||
(str/replace "+" "%20")))
|
||||
|
||||
(defn app-url [route]
|
||||
(str (:base-url env) (bidi/path-for ssr-routes/only-routes route)))
|
||||
|
||||
(defn email-body [report-url]
|
||||
(str
|
||||
"Hello,
|
||||
Click here (" report-url ") to download your financial reports. We have not finished reviewing and reconciling these numbers with you. Please review and let us know if anything seems missing or in need of correction.
|
||||
Click here (" (:base-url env) ") to login to the Financials app to review the details here.
|
||||
Click here (https://share.vidyard.com/watch/MHTo5PyXPxXUpVH93RWFM9?) for a video on how to run a P&L on your own.
|
||||
To see a history of past financial reports, click here: " (app-url :company-reports) "
|
||||
|
||||
NOTE: Please review the transactions we may have question for you here: " (app-url ::transaction-routes/requires-feedback-page) ". You can either edit the transaction to what expense account it should be or email back what it should be."))
|
||||
|
||||
(defn email-contacts
|
||||
"Email contacts configured on `clients` (a seq of entity maps with :db/id)."
|
||||
[clients]
|
||||
(->> (pull-many (dc/db conn)
|
||||
[{:client/emails [:email-contact/email :email-contact/description]}]
|
||||
(map :db/id clients))
|
||||
(mapcat :client/emails)
|
||||
(filter :email-contact/email)))
|
||||
|
||||
(defn recipients
|
||||
"The contacts to offer a mailto hand-off to, or nil when we should not offer
|
||||
one: only admins may email a report out, and only for a single client - a
|
||||
multi-client report would expose one client's numbers to another's contacts."
|
||||
[request clients]
|
||||
(when (and (is-admin? (:identity request))
|
||||
(= 1 (count clients)))
|
||||
(seq (email-contacts clients))))
|
||||
|
||||
(defn mailto-href [contacts {:report/keys [name url]}]
|
||||
(str "mailto:" (str/join recipient-separator (map :email-contact/email contacts))
|
||||
"?subject=" (url-encode (str name " is ready"))
|
||||
"&body=" (url-encode (email-body url))))
|
||||
|
||||
(def eyebrow-class
|
||||
"Section label for the two hand-offs. The modal is a transmittal slip: each
|
||||
section is one destination the report can go to."
|
||||
"text-[11px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500")
|
||||
|
||||
(defn contact-row
|
||||
"One recipient, as a role/address pair. The contacts used to be joined into
|
||||
the prose of a sentence, which ran on past two of them - as a distribution
|
||||
list they stay scannable however many the client has. Returns the grid cells;
|
||||
the columns are sized by the parent so every address lines up. `roles?` is
|
||||
false when no contact has a description, so the empty role column is dropped
|
||||
rather than indenting every address past nothing."
|
||||
[roles? {:email-contact/keys [email description]}]
|
||||
(list
|
||||
(when roles?
|
||||
[:dt {:class "max-w-[10rem] truncate text-[11px] font-medium uppercase tracking-wide text-gray-400 dark:text-gray-500"}
|
||||
description])
|
||||
[:dd {:class "min-w-0 truncate text-xs text-gray-700 dark:text-gray-200" :title email}
|
||||
email]))
|
||||
|
||||
(defn modal-header []
|
||||
[:div {:class "flex items-start justify-between gap-4 border-b border-gray-200 px-6 py-4 dark:border-gray-600"}
|
||||
[:div {:class "flex items-center gap-3"}
|
||||
[:span {:class "flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300"}
|
||||
[:div.h-3.w-3 svg/checkmark]]
|
||||
[:h3 {:class "text-base font-semibold text-gray-900 dark:text-white"} "Your report is ready"]]
|
||||
[:button {:type "button"
|
||||
"@click" "$dispatch('modalclose')"
|
||||
:aria-label "Close"
|
||||
:class "shrink-0 rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-900 focus:ring-2 focus:ring-green-400 dark:hover:bg-gray-600 dark:hover:text-white"}
|
||||
[:div.h-4.w-4 svg/x]]])
|
||||
|
||||
(defn download-section
|
||||
"`labelled?` is false when the report has nowhere else to go - with no second
|
||||
destination to contrast it against, \"Your copy\" labels nothing."
|
||||
[labelled? {:report/keys [name url]}]
|
||||
[:section {:class "px-6 py-5"}
|
||||
(when labelled?
|
||||
[:p {:class eyebrow-class} "Your copy"])
|
||||
[:div {:class (str (when labelled? "mt-3 ") "flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4")}
|
||||
[:div {:class "flex min-w-0 grow items-center gap-3"}
|
||||
[:div {:class "flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500"}
|
||||
[:div.h-5.w-5 svg/accounting-invoice-mail]]
|
||||
[:p {:class "min-w-0 break-words text-sm font-medium text-gray-900 dark:text-gray-100"} name]]
|
||||
(com/a-button {:href url :color :primary :download name :indicator? false
|
||||
:class "mr-0 w-full shrink-0 sm:w-auto"}
|
||||
[:div.h-4.w-4 svg/download]
|
||||
"Download PDF")]])
|
||||
|
||||
(defn email-section [contacts report]
|
||||
(let [roles? (boolean (some (comp not str/blank? :email-contact/description) contacts))]
|
||||
[:section {:class "border-t border-gray-200 px-6 py-5 dark:border-gray-600"}
|
||||
[:p {:class eyebrow-class} "Send to client"]
|
||||
[:dl {:class (str "mt-3 grid max-h-40 items-baseline gap-x-4 gap-y-1.5 overflow-y-auto "
|
||||
(if roles? "grid-cols-[max-content_minmax(0,1fr)]" "grid-cols-1"))}
|
||||
(map (partial contact-row roles?) contacts)]
|
||||
[:div {:class "mt-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"}
|
||||
[:p {:class "text-xs text-gray-500 dark:text-gray-400"}
|
||||
"Nothing sends until you send it."]
|
||||
(com/a-button {:href (mailto-href contacts report) :indicator? false
|
||||
:class "mr-0 w-full shrink-0 sm:w-auto"}
|
||||
[:div.h-4.w-4 svg/envelope]
|
||||
"Open email draft")]]))
|
||||
|
||||
(defn export-modal
|
||||
"Modal response for a freshly printed `report` - the map returned by the
|
||||
report namespaces' print-* functions."
|
||||
[request {:report/keys [clients] :as report}]
|
||||
(let [contacts (recipients request clients)]
|
||||
(modal-response
|
||||
(com/modal {}
|
||||
(com/modal-card-advanced
|
||||
{:class "m-4 w-full md:m-0 md:w-[560px]"}
|
||||
(modal-header)
|
||||
(download-section (boolean contacts) report)
|
||||
(when contacts
|
||||
(email-section contacts report)))))))
|
||||
@@ -16,13 +16,13 @@
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.form-cursor :as fc]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.ledger.export-modal :refer [export-modal]]
|
||||
[auto-ap.ssr.ledger.report-table :refer [cell-count concat-tables table]]
|
||||
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.ui :refer [base-page]]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [apply-middleware-to-all-handlers clj-date-schema html-response
|
||||
modal-response wrap-form-4xx-2 wrap-schema-enforce]]
|
||||
wrap-form-4xx-2 wrap-schema-enforce]]
|
||||
[auto-ap.time :as atime]
|
||||
[bidi.bidi :as bidi]
|
||||
[clj-pdf.core :as pdf]
|
||||
@@ -101,17 +101,18 @@
|
||||
:account-type (:account_type account)
|
||||
:numeric-code (:numeric_code account)
|
||||
:name (:name account)
|
||||
:bank-account-name? (:bank_account_name? account)
|
||||
:sample sample
|
||||
:period {:start (coerce/to-date (:start p)) :end (coerce/to-date (:end p))}}))
|
||||
:period {:start (coerce/to-date (:start p)) :end (coerce/to-date (:end p))}}))
|
||||
|
||||
args (assoc (:form-params request)
|
||||
:periods (map (fn [d]
|
||||
{:start (coerce/to-date (:start d)) :end (coerce/to-date (:end d))}) periods))
|
||||
clients (pull-many (dc/db conn) [:client/code :client/name :db/id :client/feature-flags] client-ids)
|
||||
args (assoc (:form-params request)
|
||||
:periods (map (fn [d]
|
||||
{:start (coerce/to-date (:start d)) :end (coerce/to-date (:end d))}) periods))
|
||||
clients (pull-many (dc/db conn) [:client/code :client/name :db/id :client/feature-flags] client-ids)
|
||||
|
||||
pnl-data (l-reports/->PNLData args data (by :db/id :client/code clients))
|
||||
pnl-data (l-reports/->PNLData args data (by :db/id :client/code clients))
|
||||
#_#__ (clojure.pprint/pprint pnl-data)
|
||||
report (l-reports/summarize-pnl pnl-data)]
|
||||
report (l-reports/summarize-pnl pnl-data)]
|
||||
(alog/info ::profit-and-loss :params args)
|
||||
{:data report
|
||||
:report report})))
|
||||
@@ -129,7 +130,17 @@
|
||||
(let [{:keys [client warning]} (maybe-trim-clients request client)
|
||||
{:keys [data report]} (get-report (assoc-in request [:form-params :client] client))
|
||||
client-count (count (set (map :client-id (:data data))))
|
||||
table-contents (concat-tables (concat (:summaries report) (:details report)))]
|
||||
table-contents (concat-tables (concat (:summaries report) (:details report)))
|
||||
warning-text (not-empty (str/join "\n " (filter not-empty [warning (:warning report)])))
|
||||
sample-links (when (can? (:identity request)
|
||||
{:subject :history
|
||||
:activity :view})
|
||||
(seq (for [n (:invalid-ids report)]
|
||||
[:div
|
||||
(com/link {:href (str (bidi/path-for ssr-routes/only-routes
|
||||
:admin-history)
|
||||
"/" n)}
|
||||
"Sample")])))]
|
||||
(list
|
||||
[:div.text-2xl.font-bold.text-gray-600 (str "Profit and loss - " (str/join ", " (map :client/name client)))]
|
||||
(table {:widths (into [20] (take (dec (cell-count table-contents))
|
||||
@@ -139,19 +150,9 @@
|
||||
[13 6 13]
|
||||
[13 6])))))
|
||||
:investigate-url (bidi.bidi/path-for ssr-routes/only-routes ::route/investigate)
|
||||
:table table-contents
|
||||
:warning [:div
|
||||
(not-empty (str (str/join "\n " (filter not-empty [warning (:warning report)]))))
|
||||
|
||||
(when (can? (:identity request)
|
||||
{:subject :history
|
||||
:activity :view})
|
||||
(for [n (:invalid-ids report)]
|
||||
[:div
|
||||
(com/link {:href (str (bidi/path-for ssr-routes/only-routes
|
||||
:admin-history)
|
||||
"/" n)}
|
||||
"Sample")]))]}))))])
|
||||
:table table-contents
|
||||
:warning (when (or warning-text sample-links)
|
||||
[:div warning-text sample-links])}))))])
|
||||
|
||||
(defn form* [request & children]
|
||||
(let [params (or (:query-params request) {})]
|
||||
@@ -169,12 +170,12 @@
|
||||
(fc/with-field :client
|
||||
(com/validated-inline-field
|
||||
{:label "Customers" :errors (fc/field-errors)}
|
||||
(com/multi-typeahead {:name (fc/field-name)
|
||||
(com/multi-typeahead {:name (fc/field-name)
|
||||
:placeholder "Search for companies..."
|
||||
:class "w-64"
|
||||
:id "client"
|
||||
:id "client"
|
||||
:url (bidi/path-for ssr-routes/only-routes :company-search)
|
||||
:value (fc/field-value)
|
||||
:value (fc/field-value)
|
||||
:value-fn :db/id
|
||||
:content-fn :client/name})))
|
||||
(fc/with-field :periods
|
||||
@@ -204,12 +205,12 @@
|
||||
(defn profit-and-loss [request]
|
||||
(base-page
|
||||
request
|
||||
(com/page {:nav com/main-aside-nav
|
||||
(com/page {:nav com/main-aside-nav
|
||||
|
||||
:client-selection (:client-selection request)
|
||||
:clients (:clients request)
|
||||
:client (:client request)
|
||||
:identity (:identity request)
|
||||
:clients (:clients request)
|
||||
:client (:client request)
|
||||
:identity (:identity request)
|
||||
:request request}
|
||||
(apply com/breadcrumbs {} [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
|
||||
"Ledger"]])
|
||||
@@ -222,9 +223,9 @@
|
||||
table (concat-tables (:details report))]
|
||||
(pdf/pdf
|
||||
(-> [{:left-margin 10 :right-margin 10 :top-margin 15 :bottom-margin 15
|
||||
:size :letter
|
||||
:font {:size 6
|
||||
:ttf-name "fonts/calibri-light.ttf"}}
|
||||
:size :letter
|
||||
:font {:size 6
|
||||
:ttf-name "fonts/calibri-light.ttf"}}
|
||||
[:heading (str "Profit and Loss - " (str/join ", " (map :client/name (seq (:client (:form-params request))))))]]
|
||||
|
||||
(conj [:paragraph {:color [128 0 0] :size 9} (:warning report)])
|
||||
@@ -254,59 +255,40 @@
|
||||
(str/replace (->> client-ids (pull-many (dc/db conn) [:client/name]) (map :client/name) (str/join "-")) #"[^\w]" "_"))
|
||||
|
||||
(defn profit-and-loss-args->name [request]
|
||||
(let [date (atime/unparse-local
|
||||
(:date (:query-params request))
|
||||
atime/iso-date)
|
||||
name (->> request :query-params :client (map :db/id) join-names)]
|
||||
(let [{:keys [client periods]} (:form-params request)
|
||||
client (if (= :all client) (:clients request) client)
|
||||
date (some-> periods last :end (atime/unparse-local atime/iso-date))
|
||||
name (->> client (map :db/id) join-names)]
|
||||
(format "Profit-and-loss-%s-for-%s" date name)))
|
||||
|
||||
(defn print-profit-and-loss [request]
|
||||
(let [uuid (str (UUID/randomUUID))
|
||||
(let [uuid (str (UUID/randomUUID))
|
||||
{:keys [client warning]} (maybe-trim-clients request (:client (:form-params request)))
|
||||
request (assoc-in request [:form-params :client] client)
|
||||
request (assoc-in request [:form-params :client] client)
|
||||
pdf-data (binding [*report-pedantic* (boolean ((set (:client/feature-flags (first client)))
|
||||
"report-pedantic"))] (make-profit-and-loss-pdf request (:report (get-report request))))
|
||||
name (profit-and-loss-args->name request)
|
||||
key (str "reports/profit-and-loss/" uuid "/" name ".pdf")
|
||||
url (str "https://" (:data-bucket env) "/" key)]
|
||||
name (profit-and-loss-args->name request)
|
||||
key (str "reports/profit-and-loss/" uuid "/" name ".pdf")
|
||||
url (str "https://" (:data-bucket env) "/" key)]
|
||||
(s3/put-object :bucket-name (:data-bucket env/env)
|
||||
:key key
|
||||
:input-stream (io/make-input-stream pdf-data {})
|
||||
:metadata {:content-length (count pdf-data)
|
||||
:content-type "application/pdf"})
|
||||
:content-type "application/pdf"})
|
||||
@(dc/transact conn
|
||||
[{:report/name name
|
||||
:report/client (map :db/id client)
|
||||
:report/key key
|
||||
:report/url url
|
||||
[{:report/name name
|
||||
:report/client (map :db/id client)
|
||||
:report/key key
|
||||
:report/url url
|
||||
:report/creator (:user (:identity request))
|
||||
:report/created (java.util.Date.)}])
|
||||
{:report/name name
|
||||
:report/url url}))
|
||||
:report/url url
|
||||
:report/clients client}))
|
||||
|
||||
;; TODO PRINT WARNING
|
||||
(defn export [request]
|
||||
(modal-response
|
||||
(com/modal {}
|
||||
(com/modal-card
|
||||
{}
|
||||
"Ready!"
|
||||
(com/modal-body {}
|
||||
(let [bs (print-profit-and-loss request)]
|
||||
[:div.flex.flex-col.mt-4.space-y-4.items-center
|
||||
[:a {:href (:report/url bs)}
|
||||
[:div.w-24.h-24.bg-green-50.rounded-full.p-4.text-green-300 {:class " hover:scale-110 transition duration-100"}
|
||||
|
||||
svg/download]]
|
||||
[:span.text-gray-800
|
||||
"Click "
|
||||
(com/link {:href (:report/url bs)} "here")
|
||||
" to download"]]))
|
||||
|
||||
nil))
|
||||
:headers (-> {}
|
||||
(assoc "hx-retarget" ".modal-stack")
|
||||
(assoc "hx-reswap" "beforeend"))))
|
||||
(export-modal request (print-profit-and-loss request)))
|
||||
|
||||
(def key->handler
|
||||
(apply-middleware-to-all-handlers
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
[:div {:id "exact-match-id-tag"}]))
|
||||
|
||||
(defn filters [request]
|
||||
[:form#payment-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form#payment-filters {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/table)
|
||||
"hx-target" "#entity-table"
|
||||
@@ -423,7 +423,7 @@
|
||||
{:exact-match-id (:db/id invoice)})
|
||||
:content (str "Inv. " (:invoice/invoice-number invoice))})))
|
||||
(some-> p :transaction/_payment ((fn [t]
|
||||
[{:link (hu/url (bidi/path-for ssr-routes/only-routes ::transaction-routes/all-page)
|
||||
[{:link (hu/url (bidi/path-for ssr-routes/only-routes ::transaction-routes/page)
|
||||
{:exact-match-id (:db/id (first t))})
|
||||
:color :secondary
|
||||
:content "Transaction"}]))))))}]}))
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
default-grid-fields-schema)]))
|
||||
|
||||
(defn filters [params]
|
||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
:pos-cash-drawer-shift-table)
|
||||
"hx-target" "#cash-drawer-shift-table"
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
default-grid-fields-schema)]))
|
||||
|
||||
(defn filters [request]
|
||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
:pos-expected-deposit-table)
|
||||
"hx-target" "#expected-deposit-table"
|
||||
@@ -165,7 +165,7 @@
|
||||
svg/external-link))
|
||||
(when-let [transaction-id (-> e (:transaction/_expected-deposit) first :db/id)]
|
||||
(com/a-button {:href (str (bidi/path-for ssr-routes/only-routes
|
||||
::transaction-routes/all-page)
|
||||
::transaction-routes/page)
|
||||
"?exact-match-id="
|
||||
transaction-id)} "Transaction"))])
|
||||
:headers [{:key "client"
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
[:client {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :client/name]}]]]]
|
||||
default-grid-fields-schema)]))
|
||||
(defn filters [request]
|
||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
:pos-refund-table)
|
||||
"hx-target" "#refund-table"
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
default-grid-fields-schema)]))
|
||||
|
||||
(defn filters [request]
|
||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
:pos-sales-table)
|
||||
"hx-target" "#sales-table"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
(:require
|
||||
[auto-ap.datomic
|
||||
:refer [apply-pagination apply-sort-3 conn merge-query pull-many
|
||||
query2]]
|
||||
pull-many-by-id query2]]
|
||||
[auto-ap.datomic.accounts :as d-accounts]
|
||||
[auto-ap.graphql.utils :refer [extract-client-ids]]
|
||||
[auto-ap.query-params :refer [wrap-copy-qp-pqp]]
|
||||
@@ -44,7 +44,7 @@
|
||||
default-grid-fields-schema)]))
|
||||
|
||||
(defn filters [request]
|
||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/table)
|
||||
"hx-target" "#entity-table"
|
||||
@@ -128,6 +128,38 @@
|
||||
(map #(:ledger-mapped/amount % 0.0))
|
||||
(reduce + 0.0)))
|
||||
|
||||
(def csv-account-read
|
||||
[:db/id
|
||||
:account/name
|
||||
:account/code
|
||||
{:account/client-overrides [:account-client-override/name
|
||||
{:account-client-override/client [:db/id]}]}])
|
||||
|
||||
(defn- csv-amount
|
||||
"Renders an item's amount when it falls on `side`, fixed to cents so the CSV
|
||||
never carries floating point noise."
|
||||
[side item]
|
||||
(when (= side (:ledger-mapped/ledger-side item))
|
||||
(format "%.2f" (double (:ledger-mapped/amount item 0.0)))))
|
||||
|
||||
(defn summaries->csv-rows
|
||||
"Flattens each summary into one row per item, so every category is its own
|
||||
auditable CSV line. Accounts are resolved in a single batch pull and
|
||||
clientized against the summary's own client."
|
||||
[summaries]
|
||||
(let [account-ids (into #{}
|
||||
(comp (mapcat :sales-summary/items)
|
||||
(keep (comp :db/id :ledger-mapped/account)))
|
||||
summaries)
|
||||
id->account (if (seq account-ids)
|
||||
(pull-many-by-id (dc/db conn) csv-account-read account-ids)
|
||||
{})]
|
||||
(for [ss summaries
|
||||
item (sort-items (:sales-summary/items ss))]
|
||||
(assoc (merge item ss)
|
||||
::account (some-> item :ledger-mapped/account :db/id id->account
|
||||
(d-accounts/clientize (-> ss :sales-summary/client :db/id)))))))
|
||||
|
||||
(defn truncate [s max-len]
|
||||
(if (> (count s) max-len)
|
||||
(str (subs s 0 (- max-len 3)) "...")
|
||||
@@ -215,7 +247,15 @@
|
||||
:title "Sales Summaries"
|
||||
:entity-name "Daily Summary"
|
||||
:route ::route/table
|
||||
:headers [{:key "client"
|
||||
:csv-route ::route/csv
|
||||
:page->csv-entities (fn [[summaries]]
|
||||
(summaries->csv-rows summaries))
|
||||
:headers [{:key "id"
|
||||
:name "Id"
|
||||
:render-csv :db/id
|
||||
:render-for #{:csv}}
|
||||
|
||||
{:key "client"
|
||||
:name "Client"
|
||||
:sort-key "client"
|
||||
:hide? (fn [args]
|
||||
@@ -231,6 +271,7 @@
|
||||
:name "Debits"
|
||||
:sort-key "debits"
|
||||
:class "w-72 align-top"
|
||||
:render-for #{:html}
|
||||
:render (fn [ss]
|
||||
(let [items (:sales-summary/items ss)
|
||||
debit-items (filter #(= :ledger-side/debit (:ledger-mapped/ledger-side %)) (sort-items items))
|
||||
@@ -257,6 +298,7 @@
|
||||
:name "Credits"
|
||||
:sort-key "credits"
|
||||
:class "w-72 align-top"
|
||||
:render-for #{:html}
|
||||
:render (fn [ss]
|
||||
(let [items (:sales-summary/items ss)
|
||||
credit-items (filter #(= :ledger-side/credit (:ledger-mapped/ledger-side %)) (sort-items items))
|
||||
@@ -283,6 +325,7 @@
|
||||
:name "Status"
|
||||
:sort-key "balance"
|
||||
:class "w-28 align-top"
|
||||
:render-for #{:html}
|
||||
:render (fn [ss]
|
||||
(let [items (:sales-summary/items ss)
|
||||
total-debits (total-debits items)
|
||||
@@ -304,10 +347,41 @@
|
||||
[:span.text-xs.uppercase.tracking-wider.text-red-600.font-medium.mt-0.5
|
||||
(if (> total-debits total-credits) "Debit over" "Credit over")]])]))}
|
||||
|
||||
{:key "category"
|
||||
:name "Category"
|
||||
:render-csv :sales-summary-item/category
|
||||
:render-for #{:csv}}
|
||||
|
||||
{:key "account-code"
|
||||
:name "Account Code"
|
||||
:render-csv #(-> % ::account :account/code)
|
||||
:render-for #{:csv}}
|
||||
|
||||
{:key "account"
|
||||
:name "Account"
|
||||
:render-csv #(-> % ::account :account/name)
|
||||
:render-for #{:csv}}
|
||||
|
||||
{:key "debit"
|
||||
:name "Debit"
|
||||
:render-csv (partial csv-amount :ledger-side/debit)
|
||||
:render-for #{:csv}}
|
||||
|
||||
{:key "credit"
|
||||
:name "Credit"
|
||||
:render-csv (partial csv-amount :ledger-side/credit)
|
||||
:render-for #{:csv}}
|
||||
|
||||
{:key "manual"
|
||||
:name "Manual"
|
||||
:render-csv #(boolean (:sales-summary-item/manual? %))
|
||||
:render-for #{:csv}}
|
||||
|
||||
{:key "links"
|
||||
:name "Links"
|
||||
:show-starting "lg"
|
||||
:class "w-8"
|
||||
:render-for #{:html}
|
||||
:render (fn [ss]
|
||||
(let [ledger-entry (:journal-entry/original-entity ss)]
|
||||
(when (seq ledger-entry)
|
||||
@@ -754,6 +828,7 @@
|
||||
(->>
|
||||
{::route/page (helper/page-route grid-page)
|
||||
::route/table (helper/table-route grid-page)
|
||||
::route/csv (helper/csv-route grid-page)
|
||||
::route/edit-wizard (-> mm/open-wizard-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-init-multi-form-state initial-edit-wizard-state)
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
;; always should be fast
|
||||
|
||||
(defn filters [request]
|
||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
:pos-tender-table)
|
||||
"hx-target" "#tender-table"
|
||||
|
||||
@@ -114,6 +114,14 @@
|
||||
[:svg {:xmlns "http://www.w3.org/2000/svg", :fill "none", :viewbox "0 0 24 24", :stroke-width "2", :stroke "currentColor", :aria-hidden "true"}
|
||||
[:path {:stroke-linecap "round", :stroke-linejoin "round", :d "M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"}]])
|
||||
|
||||
(def checkmark
|
||||
[:svg {:xmlns "http://www.w3.org/2000/svg", :fill "none", :viewbox "0 0 24 24", :stroke-width "3", :stroke "currentColor", :aria-hidden "true"}
|
||||
[:path {:stroke-linecap "round", :stroke-linejoin "round", :d "M4.5 12.75l6 6 9-13.5"}]])
|
||||
|
||||
(def envelope
|
||||
[:svg {:xmlns "http://www.w3.org/2000/svg", :fill "none", :viewbox "0 0 24 24", :stroke-width "1.5", :stroke "currentColor", :aria-hidden "true"}
|
||||
[:path {:stroke-linecap "round", :stroke-linejoin "round", :d "M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"}]])
|
||||
|
||||
(def vendors
|
||||
[:svg {:xmlns "http://www.w3.org/2000/svg", :viewbox "0 0 24 24"}
|
||||
[:defs]
|
||||
|
||||
@@ -185,28 +185,28 @@
|
||||
:hx-target "#account-entries"
|
||||
:hx-swap "innerHTML"
|
||||
:hx-include "closest form"}
|
||||
(fc/with-field :vendor
|
||||
(com/validated-field {:label "Vendor"
|
||||
:errors (fc/field-errors)}
|
||||
(com/typeahead {:name (fc/field-name)
|
||||
:placeholder "Search for vendor..."
|
||||
: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 :vendor
|
||||
(com/validated-field {:label "Vendor"
|
||||
:errors (fc/field-errors)}
|
||||
(com/typeahead {:name (fc/field-name)
|
||||
:placeholder "Search for vendor..."
|
||||
: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))})))]
|
||||
|
||||
;; Status field
|
||||
[:div
|
||||
(fc/with-field :approval-status
|
||||
(com/validated-field {:label "Status"
|
||||
:errors (fc/field-errors)}
|
||||
(com/select {:name (fc/field-name)
|
||||
:value (some-> (fc/field-value)
|
||||
name)
|
||||
:options [["" "No Change"]
|
||||
["approved" "Approved"]
|
||||
["unapproved" "Unapproved"]
|
||||
["suppressed" "Suppressed"]
|
||||
["requires_feedback" "Requires Feedback"]]})))]
|
||||
[:div
|
||||
(fc/with-field :approval-status
|
||||
(com/validated-field {:label "Status"
|
||||
:errors (fc/field-errors)}
|
||||
(com/select {:name (fc/field-name)
|
||||
:value (some-> (fc/field-value)
|
||||
name)
|
||||
:options [["" "No Change"]
|
||||
["approved" "Approved"]
|
||||
["unapproved" "Unapproved"]
|
||||
["suppressed" "Suppressed"]
|
||||
["requires-feedback" "Client Review"]]})))]
|
||||
|
||||
;; Accounts section
|
||||
[:div.col-span-2.pt-4
|
||||
@@ -219,10 +219,10 @@
|
||||
(com/data-grid {:headers [(com/data-grid-header {} "Account")
|
||||
(com/data-grid-header {:class "w-32"} "Location")
|
||||
(com/data-grid-header {:class "w-16"} "%")
|
||||
(com/data-grid-header {:class "w-16"})]}
|
||||
(fc/cursor-map #(transaction-account-row* {:value %}))
|
||||
(com/data-grid-header {:class "w-16"})]}
|
||||
(fc/cursor-map #(transaction-account-row* {:value %}))
|
||||
|
||||
(com/data-grid-new-row {:colspan 4
|
||||
(com/data-grid-new-row {:colspan 4
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/bulk-code-new-account)
|
||||
:row-offset 0
|
||||
@@ -326,7 +326,7 @@
|
||||
(html-response
|
||||
(com/success-modal {:title "Transactions Coded"}
|
||||
[:p (str "Successfully coded " (count all-ids) " transactions.")])
|
||||
:headers {"hx-trigger" "refreshTable"})))))
|
||||
:headers {"hx-trigger" "refreshTable, reset-selection"})))))
|
||||
|
||||
(defn- vendor-default-account [vendor-id client-id]
|
||||
"Returns the vendor's standard default account. For single-client contexts,
|
||||
@@ -357,10 +357,10 @@
|
||||
{:errors (fc/field-errors)}
|
||||
(com/data-grid {:headers [(com/data-grid-header {} "Account")
|
||||
(com/data-grid-header {:class "w-32"} "Location")
|
||||
(com/data-grid-header {:class "w-16"} "%")
|
||||
(com/data-grid-header {:class "w-16"})]}
|
||||
(fc/cursor-map #(transaction-account-row* {:value %}))
|
||||
(com/data-grid-new-row {:colspan 4
|
||||
(com/data-grid-header {:class "w-16"} "%")
|
||||
(com/data-grid-header {:class "w-16"})]}
|
||||
(fc/cursor-map #(transaction-account-row* {:value %}))
|
||||
(com/data-grid-new-row {:colspan 4
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/bulk-code-new-account)
|
||||
:row-offset 0
|
||||
|
||||
@@ -36,9 +36,9 @@
|
||||
[:import-batch-id {:optional true} [:maybe entity-id]]
|
||||
[:unresolved {:optional true}
|
||||
[:maybe [:boolean {:decode/string {:enter #(cond (= % "on") true
|
||||
(= % "") false
|
||||
:else
|
||||
(boolean %))}}]]]
|
||||
(= % "true") true
|
||||
(boolean? %) %
|
||||
:else false)}}]]]
|
||||
[:description {:optional true} [:maybe [:string {:decode/string strip}]]]
|
||||
[:memo {:optional true} [:maybe [:string {:decode/string strip}]]]
|
||||
[:vendor {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :vendor/name]}]]]
|
||||
@@ -50,9 +50,9 @@
|
||||
[:location {:optional true} [:maybe [:string {:decode/string strip}]]]
|
||||
[:potential-duplicates {:optional true}
|
||||
[:maybe [:boolean {:decode/string {:enter #(cond (= % "on") true
|
||||
(= % "") false
|
||||
:else
|
||||
(boolean %))}}]]]
|
||||
(= % "true") true
|
||||
(boolean? %) %
|
||||
:else false)}}]]]
|
||||
#_[:status {:optional true} [:maybe (ref->enum-schema "transaction-status")]]
|
||||
[:exact-match-id {:optional true} [:maybe entity-id]]
|
||||
[:all-selected {:optional true :default nil} [:maybe :boolean]]
|
||||
@@ -248,6 +248,19 @@
|
||||
[?v :vendor/name ?sort-vendor])
|
||||
(and [(missing? $ ?e :transaction/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]]
|
||||
"amount" ['[?e :transaction/amount ?sort-amount]]
|
||||
"description" ['[?e :transaction/description-original ?sort-description]]}
|
||||
@@ -316,7 +329,7 @@
|
||||
:content (:bank-account/name ba)}))}))))])
|
||||
|
||||
(defn filters [request]
|
||||
[:form#transaction-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
[:form#transaction-filters {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/table)
|
||||
"hx-target" "#entity-table"
|
||||
@@ -421,6 +434,35 @@
|
||||
(import-batch-id* request)
|
||||
(exact-match-id* request)]])
|
||||
|
||||
(def non-date-filter-params
|
||||
"Query-param keys that represent transaction filters other than the date range."
|
||||
[:vendor :account :bank-account :description :memo :location
|
||||
:amount-gte :amount-lte :linked-to :unresolved :potential-duplicates
|
||||
:import-batch-id :exact-match-id])
|
||||
|
||||
(defn- filter-value-active? [v]
|
||||
(cond
|
||||
(nil? v) false
|
||||
(false? v) false
|
||||
(string? v) (not (str/blank? v))
|
||||
:else true))
|
||||
|
||||
(defn non-date-filters-active? [request]
|
||||
(boolean (some (comp filter-value-active? #(get (:query-params request) %))
|
||||
non-date-filter-params)))
|
||||
|
||||
(defn clear-filters-href
|
||||
"URL for the transactions page with every non-date filter cleared, preserving
|
||||
the active date range (and an implied status, if any)."
|
||||
[request]
|
||||
(let [qp (:query-params request)
|
||||
status (:status qp)]
|
||||
(str (hu/url (bidi/path-for ssr-routes/only-routes ::route/page)
|
||||
(cond-> {}
|
||||
(:start-date qp) (assoc "start-date" (atime/unparse (:start-date qp) atime/normal-date))
|
||||
(:end-date qp) (assoc "end-date" (atime/unparse (:end-date qp) atime/normal-date))
|
||||
(keyword? status) (assoc "status" (name status)))))))
|
||||
|
||||
(def grid-page
|
||||
(helper/build {:id "entity-table"
|
||||
:nav com/main-aside-nav
|
||||
@@ -434,26 +476,34 @@
|
||||
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)
|
||||
(some-> (import-batch-id* request) (assoc-in [1 :hx-swap-oob] true))])
|
||||
:action-buttons (fn [request]
|
||||
[(com/button {:color :primary
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/bulk-code)
|
||||
:hx-target "#modal-holder"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"hx-include" "#transaction-filters"}
|
||||
"Code")
|
||||
(com/button {:color :primary
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
|
||||
:hx-target "#modal-holder"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"hx-include" "#transaction-filters"
|
||||
:hx-confirm "Are you sure you want to delete these transactions?"}
|
||||
"Delete")
|
||||
(com/button {:color :primary
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
|
||||
:hx-target "#modal-holder"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"hx-include" "#transaction-filters"
|
||||
:hx-confirm "Are you sure you want to suppress these transactions?"}
|
||||
"Suppress")])
|
||||
(cond-> [(com/button {:color :primary
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/bulk-code)
|
||||
:hx-target "#modal-holder"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"x-bind:disabled" "selected.length === 0 && !all_selected"
|
||||
"hx-include" "#transaction-filters"}
|
||||
"Code")
|
||||
(com/button {:color :primary
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
|
||||
:hx-target "#modal-holder"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"x-bind:disabled" "selected.length === 0 && !all_selected"
|
||||
"hx-include" "#transaction-filters"
|
||||
:hx-confirm "Are you sure you want to delete these transactions?"}
|
||||
"Delete")
|
||||
(com/button {:color :primary
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
|
||||
:hx-target "#modal-holder"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"x-bind:disabled" "selected.length === 0 && !all_selected"
|
||||
"hx-include" "#transaction-filters"
|
||||
:hx-confirm "Are you sure you want to suppress these transactions?"}
|
||||
"Suppress")]
|
||||
(non-date-filters-active? request)
|
||||
(conj (com/a-button {:color :secondary
|
||||
:hx-boost "true"
|
||||
:href (clear-filters-href request)}
|
||||
"Clear filters"))))
|
||||
:row-buttons (fn [request entity]
|
||||
(let [client (:transaction/client entity)
|
||||
locked-until (:client/locked-until client)
|
||||
@@ -499,6 +549,18 @@
|
||||
(= 1 (count (:client/locations (:client args))))))
|
||||
:render (fn [x] [:div.flex.items-center.gap-2 (-> x :transaction/client :client/name)])
|
||||
:render-csv (fn [x] (-> x :transaction/client :client/name))}
|
||||
{:key "bank-account"
|
||||
:name "Bank Account"
|
||||
:sort-key "bank-account"
|
||||
:show-starting "lg"
|
||||
:render (fn [x]
|
||||
(let [ba (:transaction/bank-account x)]
|
||||
(or (:bank-account/name ba)
|
||||
(:bank-account/numeric-code ba))))
|
||||
:render-csv (fn [x]
|
||||
(let [ba (:transaction/bank-account x)]
|
||||
(or (:bank-account/name ba)
|
||||
(:bank-account/numeric-code ba))))}
|
||||
{:key "vendor"
|
||||
:name "Vendor"
|
||||
:sort-key "vendor"
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
(def transaction-approval-status
|
||||
{:transaction-approval-status/unapproved "Unapproved"
|
||||
:transaction-approval-status/approved "Approved"
|
||||
:transaction-approval-status/suppressed "Client Review"})
|
||||
:transaction-approval-status/requires-feedback "Client Review"})
|
||||
|
||||
(def row* (partial helper/row* grid-page))
|
||||
|
||||
@@ -72,6 +72,27 @@
|
||||
(or (not= approval-status :transaction-approval-status/approved)
|
||||
(seq accounts)))]])
|
||||
|
||||
(def account-coding-schema
|
||||
"Validation for manually-coded transaction account rows. Applied only for
|
||||
the :manual action: link / apply-rule actions build their own accounts
|
||||
server-side, so the (often blank) account row carried along by the manual
|
||||
tab must not be required when one of those actions is submitted."
|
||||
[:maybe
|
||||
[:vector {:coerce? true}
|
||||
[:and
|
||||
[:map
|
||||
[:db/id {:optional true} [:maybe [:or temp-id entity-id]]]
|
||||
[:transaction-account/account [:and entity-id
|
||||
[:fn {:error/message "Not an allowed account."}
|
||||
#(check-allowance % :account/default-allowance)]]]
|
||||
[:transaction-account/location :string]
|
||||
[:transaction-account/amount :double]]
|
||||
[:fn {:error/fn (fn [r x] (:type r))
|
||||
:error/path [:transaction-account/location]}
|
||||
(fn [iea]
|
||||
(check-location-belongs (:transaction-account/location iea)
|
||||
(:transaction-account/account iea)))]]]])
|
||||
|
||||
(def edit-form-schema
|
||||
(mc/schema
|
||||
[:and
|
||||
@@ -81,23 +102,7 @@
|
||||
[:transaction/memo {:optional true} [:maybe [:string {:decode/string strip}]]]
|
||||
[:transaction/vendor {:optional true} [:maybe entity-id]]
|
||||
[:transaction/approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]]
|
||||
[:amount-mode {:optional true} [:maybe [:enum "$" "%"]]]
|
||||
[:transaction/accounts {:optional true}
|
||||
[:maybe
|
||||
[:vector {:coerce? true}
|
||||
[:and
|
||||
[:map
|
||||
[:db/id {:optional true} [:maybe [:or temp-id entity-id]]]
|
||||
[:transaction-account/account [:and entity-id
|
||||
[:fn {:error/message "Not an allowed account."}
|
||||
#(check-allowance % :account/default-allowance)]]]
|
||||
[:transaction-account/location :string]
|
||||
[:transaction-account/amount :double]]
|
||||
[:fn {:error/fn (fn [r x] (:type r))
|
||||
:error/path [:transaction-account/location]}
|
||||
(fn [iea]
|
||||
(check-location-belongs (:transaction-account/location iea)
|
||||
(:transaction-account/account iea)))]]]]]]
|
||||
[:amount-mode {:optional true} [:maybe [:enum "$" "%"]]]]
|
||||
[:multi {:dispatch :action}
|
||||
[:apply-rule [:map
|
||||
[:rule-id {:optional true} [:maybe entity-id]]]]
|
||||
@@ -110,7 +115,8 @@
|
||||
[:autopay-invoice-ids {:decode/string (fn [x] (edn/read-string x))} [:vector {:coerce? true} entity-id]]]]
|
||||
[:link-payment [:map
|
||||
[:payment-id entity-id]]]
|
||||
[:manual (require-approval [:map])]]]))
|
||||
[:manual (require-approval [:map
|
||||
[:transaction/accounts {:optional true} account-coding-schema]])]]]))
|
||||
|
||||
(defn clientize-vendor [{:vendor/keys [terms-overrides automatically-paid-when-due default-account account-overrides] :as vendor} client-id]
|
||||
(if (nil? vendor)
|
||||
@@ -230,7 +236,7 @@
|
||||
:x-dispatch:changed "simpleAccountId"
|
||||
:hx-trigger "changed"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
|
||||
:hx-target "find *"
|
||||
:hx-target "find select"
|
||||
:hx-swap "outerHTML"}
|
||||
(location-select*
|
||||
{:name (fc/field-name)
|
||||
@@ -259,8 +265,12 @@
|
||||
(defn transaction-account-row* [{:keys [value client-id amount-mode total]}]
|
||||
(com/data-grid-row
|
||||
(-> {:class "account-row"
|
||||
;; accountId is bound to the typeahead's value.value (and thus the
|
||||
;; submitted hidden input). Normalize a {:db/id n} ref-map down to a bare
|
||||
;; id so it doesn't serialize to "[object Object]" on submit.
|
||||
:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value))))
|
||||
:accountId (fc/field-value (:transaction-account/account value))})
|
||||
:accountId (let [a (fc/field-value (:transaction-account/account value))]
|
||||
(or (:db/id a) a))})
|
||||
:data-key "show"
|
||||
:x-ref "p"}
|
||||
hx/alpine-mount-then-appear)
|
||||
@@ -287,7 +297,7 @@
|
||||
:x-dispatch:changed "accountId"
|
||||
:hx-trigger "changed"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
|
||||
:hx-target "find *"
|
||||
:hx-target "find select"
|
||||
:hx-swap "outerHTML"}
|
||||
(location-select* {:name (fc/field-name)
|
||||
:account-location (:account/location (cond->> (:transaction-account/account @value)
|
||||
@@ -325,8 +335,12 @@
|
||||
(defn transaction-account-row-no-cursor* [{:keys [account index client-id amount-mode total]}]
|
||||
(com/data-grid-row
|
||||
(-> {:class "account-row"
|
||||
;; accountId drives the typeahead's x-model and is bound to value.value.
|
||||
;; Use a bare entity id, not the {:db/id n} ref-map, or the bound hidden
|
||||
;; input serializes to "[object Object]" on submit (rejected server-side).
|
||||
:x-data (hx/json {:show true
|
||||
:accountId (:transaction-account/account account)})
|
||||
:accountId (let [a (:transaction-account/account account)]
|
||||
(or (:db/id a) a))})
|
||||
:data-key "show"
|
||||
:x-ref "p"}
|
||||
hx/alpine-mount-then-appear)
|
||||
@@ -974,8 +988,8 @@
|
||||
":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'unapproved' }"
|
||||
:class "rounded-r-lg"}
|
||||
"Unapproved")
|
||||
(com/button-group-button {"@click" "approvalStatus = 'suppressed'"
|
||||
":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'suppressed' }"
|
||||
(com/button-group-button {"@click" "approvalStatus = 'requires-feedback'"
|
||||
":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'requires-feedback' }"
|
||||
:class "rounded-r-lg"}
|
||||
"Client Review")]])))]]]])
|
||||
:footer
|
||||
@@ -1035,7 +1049,7 @@
|
||||
|
||||
(when-not (dollars= (- (:transaction/amount transaction))
|
||||
(:payment/amount payment))
|
||||
(throw (ex-info "Amounts don't match" {:validation-error "Amounts don't match"})))
|
||||
(form-validation-error "Amounts don't match"))
|
||||
(if (is-already-linked-to-this-payment? transaction payment-id)
|
||||
(save-memo-only request)
|
||||
(save-linked-transaction request payment))
|
||||
@@ -1063,16 +1077,16 @@
|
||||
(exception->4xx #(assert-not-locked (-> transaction :transaction/client :db/id) (:transaction/date transaction)))
|
||||
|
||||
(when (:transaction/payment transaction)
|
||||
(throw (ex-info "Transaction already linked" {:validation-error "Transaction already linked"})))
|
||||
(form-validation-error "Transaction already linked"))
|
||||
|
||||
(when (or (> (count invoice-clients) 1)
|
||||
(not= (-> transaction :transaction/client :db/id)
|
||||
(first invoice-clients)))
|
||||
(throw (ex-info "Clients don't match" {:validation-error "Invoice(s) and transaction client do not match."})))
|
||||
(form-validation-error "Invoice(s) and transaction client do not match."))
|
||||
|
||||
(when-not (dollars= (- (:transaction/amount transaction))
|
||||
invoice-amount)
|
||||
(throw (ex-info "Amounts don't match" {:validation-error "Amounts don't match"})))
|
||||
(form-validation-error "Amounts don't match"))
|
||||
|
||||
(let [payment-tx (i-transactions/add-new-payment
|
||||
(dc/pull db [:transaction/amount :transaction/date :db/id] (:db/id transaction))
|
||||
@@ -1108,16 +1122,16 @@
|
||||
(when (or (> (count invoice-clients) 1)
|
||||
(not= (-> transaction :transaction/client :db/id)
|
||||
(first invoice-clients)))
|
||||
(throw (ex-info "Clients don't match" {:validation-error "Invoice(s) and transaction client do not match."
|
||||
:transaction-client (-> transaction :transaction/client :db/id)
|
||||
:invoice-clients invoice-clients})))
|
||||
(form-validation-error "Invoice(s) and transaction client do not match."
|
||||
:transaction-client (-> transaction :transaction/client :db/id)
|
||||
:invoice-clients invoice-clients))
|
||||
|
||||
(when-not (dollars= (- (:transaction/amount transaction))
|
||||
invoice-amount)
|
||||
(throw (ex-info "Amounts don't match" {:validation-error "Amounts don't match"})))
|
||||
(form-validation-error "Amounts don't match"))
|
||||
|
||||
(when (:transaction/payment transaction)
|
||||
(throw (ex-info "Transaction already linked" {:validation-error "Transaction already linked"})))
|
||||
(form-validation-error "Transaction already linked"))
|
||||
|
||||
(let [payment-tx (i-transactions/add-new-payment
|
||||
(dc/pull db [:transaction/amount :transaction/date :db/id] (:db/id transaction))
|
||||
@@ -1164,12 +1178,10 @@
|
||||
|
||||
(let [description-pattern (some-> transaction-rule :transaction-rule/description iol-ion.query/->pattern)]
|
||||
(when (not (rm/rule-applies? transaction {:transaction-rule/description description-pattern}))
|
||||
(throw (ex-info "Transaction rule does not apply"
|
||||
{:validation-error "Transaction rule does not apply"}))))
|
||||
(form-validation-error "Transaction rule does not apply")))
|
||||
|
||||
(when (:transaction/payment transaction)
|
||||
(throw (ex-info "Transaction already associated with a payment"
|
||||
{:validation-error "Transaction already associated with a payment"})))
|
||||
(form-validation-error "Transaction already associated with a payment"))
|
||||
|
||||
(let [locations (-> transaction :transaction/client :client/locations)
|
||||
updated-tx (rm/apply-rule {:db/id (:db/id transaction)
|
||||
@@ -1296,7 +1308,7 @@
|
||||
(alog/error ::cant-save-solr :error e)))
|
||||
|
||||
(html-response
|
||||
(row* (:identity request) (d-transactions/get-by-id tx-id) {:flash? true})
|
||||
(row* (:identity request) (d-transactions/get-by-id tx-id) {:flash? true :request request})
|
||||
:headers {"hx-trigger" "modalclose"
|
||||
"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" tx-id)
|
||||
"hx-reswap" "outerHTML"}))))
|
||||
@@ -1322,8 +1334,7 @@
|
||||
(exception->4xx #(assert-not-locked (-> transaction :transaction/client :db/id) (:transaction/date transaction)))
|
||||
|
||||
(when (not= :payment-status/cleared (-> payment :payment/status))
|
||||
(throw (ex-info "Payment can't be undone because it isn't cleared."
|
||||
{:validation-error "Payment can't be undone because it isn't cleared."})))
|
||||
(form-validation-error "Payment can't be undone because it isn't cleared."))
|
||||
|
||||
(let [is-autopay-payment? (some->> (dc/q {:find ['?sp]
|
||||
:in ['$ '?payment]
|
||||
@@ -1466,7 +1477,7 @@
|
||||
(let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID))
|
||||
:transaction-account/location (or (:account/location default-account) "Shared")
|
||||
:transaction-account/amount (if (= amount-mode "%") 100.0 total)}
|
||||
default-account (assoc :transaction-account/account (:db/id default-account)))]
|
||||
default-account (assoc :transaction-account/account (:db/id default-account)))]
|
||||
(-> request
|
||||
(assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account])
|
||||
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
|
||||
@@ -1480,7 +1491,9 @@
|
||||
(defn edit-wizard-toggle-mode-handler [request]
|
||||
(let [step-params (-> request :multi-form-state :step-params)
|
||||
snapshot (-> request :multi-form-state :snapshot)
|
||||
current-mode (keyword (or (:mode step-params) "simple"))
|
||||
current-mode (keyword (or (:mode step-params)
|
||||
(get (:form-params request) "mode")
|
||||
"simple"))
|
||||
target-mode (if (= current-mode :simple) :advanced :simple)
|
||||
;; When switching simple→advanced, promote simple-mode values into accounts
|
||||
render-request
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
(:form-params request) (:form-errors request)
|
||||
(fc/with-field :table
|
||||
(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)
|
||||
(parsed-banner request))
|
||||
[:form {:hx-post (bidi/path-for ssr-routes/only-routes ::route/external-import-import)
|
||||
@@ -186,8 +186,9 @@
|
||||
:autocomplete "off"}
|
||||
[:div.flex.gap-4.items-center.my-2
|
||||
(com/checkbox {"@click" "showTable=!showTable"} "Show table")
|
||||
(com/checkbox {:x-model "errorsOnly"} "Only show errors")
|
||||
(com/button {:color :primary :type "submit"} "Import")]
|
||||
[:div {:x-show "showTable"}
|
||||
[:div {:x-show "showTable || errorsOnly"}
|
||||
(com/data-grid-card
|
||||
{:id "transaction-import-data"
|
||||
:route nil
|
||||
@@ -204,14 +205,17 @@
|
||||
(fc/cursor-map
|
||||
(fn [_]
|
||||
(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
|
||||
{}
|
||||
(cond-> {}
|
||||
(not (seq row-errors)) (assoc :x-show "!errorsOnly"))
|
||||
(com/data-grid-cell {} (fc/with-field :raw-date
|
||||
(com/text-input {:value (fc/field-value) :name (fc/field-name) :class "w-28"})))
|
||||
(com/data-grid-cell {} (fc/with-field :description-original
|
||||
(com/text-input {:value (fc/field-value) :name (fc/field-name)})))
|
||||
(com/data-grid-cell {} (fc/with-field :amount
|
||||
(com/money-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 text-right" :inputmode "decimal"})))
|
||||
(com/data-grid-cell {} (fc/with-field :bank-account-code
|
||||
(com/text-input {:value (fc/field-value) :name (fc/field-name) :class "w-28"})))
|
||||
(com/data-grid-cell {} (fc/with-field :client-code
|
||||
|
||||
@@ -329,7 +329,7 @@
|
||||
:request request}
|
||||
(com/breadcrumbs {}
|
||||
[:a {:href (bidi/path-for ssr-routes/only-routes
|
||||
::transaction-routes/all-page)}
|
||||
::transaction-routes/page)}
|
||||
"Transactions"]
|
||||
[:a {:href (bidi/path-for ssr-routes/only-routes
|
||||
:transaction-insights)}
|
||||
|
||||
@@ -23,8 +23,6 @@
|
||||
[:title (str "Integreat | " page-name)]
|
||||
[:link {:href "/css/font.min.css", :rel "stylesheet"}]
|
||||
[:link {:rel "icon" :type "image/png" :href "/favicon.png"}]
|
||||
[:link {:rel "stylesheet" :href "//cdn.jsdelivr.net/chartist.js/latest/chartist.min.css"}]
|
||||
[:script {:src "//cdn.jsdelivr.net/chartist.js/latest/chartist.min.js"}]
|
||||
[:link {:rel "stylesheet", :href "/output.css"}]
|
||||
[:script {:src "https://cdn.plaid.com/link/v2/stable/link-initialize.js"}]
|
||||
[:script {:src "https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-tooltip@1.x.x/dist/cdn.min.js" :defer true}]
|
||||
|
||||
@@ -334,7 +334,7 @@
|
||||
(and (map? data)
|
||||
(every? #(try (Long/parseLong %) true (catch Exception _ false)) (keys data)))
|
||||
(into [] (->> (keys data)
|
||||
sort
|
||||
(sort-by #(Long/parseLong %))
|
||||
(map data)))
|
||||
(nil? data)
|
||||
nil
|
||||
|
||||
@@ -9,15 +9,29 @@
|
||||
(with-open [s (ServerSocket. 0)]
|
||||
(.getLocalPort s)))
|
||||
|
||||
(defn- mcp-repl-task [& _args]
|
||||
"Start nREPL server and HTTP server on random ports.
|
||||
(defn- read-port [path]
|
||||
"Read a previously-recorded port from `path`, or nil if missing/unparseable."
|
||||
(let [f (io/file path)]
|
||||
(when (.exists f)
|
||||
(try (Integer/parseInt (.trim ^String (slurp f)))
|
||||
(catch Exception _ nil)))))
|
||||
|
||||
Writes ports to nrepl-port and .http-port files.
|
||||
Connect with: clj-nrepl-eval -p $(cat nrepl-port)"
|
||||
(let [nrepl-port (available-port)
|
||||
http-port (available-port)]
|
||||
(spit "nrepl-port" (str nrepl-port))
|
||||
(spit ".http-port" (str http-port))
|
||||
(defn- stable-port [path]
|
||||
"Reuse the port recorded in `path` if present, otherwise pick a random
|
||||
available one. Always (re)writes the file so the port stays stable for this
|
||||
worktree across REPL restarts and reloads."
|
||||
(let [port (or (read-port path) (available-port))]
|
||||
(spit path (str port))
|
||||
port))
|
||||
|
||||
(defn- mcp-repl-task [& _args]
|
||||
"Start nREPL server and HTTP server.
|
||||
|
||||
Reuses the ports recorded in nrepl-port and .http-port if present (keeping
|
||||
them stable per worktree), otherwise picks random available ports and records
|
||||
them. Connect with: clj-nrepl-eval -p $(cat nrepl-port)"
|
||||
(let [nrepl-port (stable-port "nrepl-port")
|
||||
http-port (stable-port ".http-port")]
|
||||
(println (format "nREPL port: %d (nrepl-port)" nrepl-port))
|
||||
(println (format "HTTP port: %d (.http-port)" http-port))
|
||||
(nrepl/start-server :port nrepl-port)
|
||||
|
||||
@@ -267,12 +267,33 @@
|
||||
account))
|
||||
accounts))))
|
||||
|
||||
(defn used-accounts [pnl-datas]
|
||||
(defn used-accounts
|
||||
"One entry per numeric code in play, with the name to label its row.
|
||||
|
||||
Rows are keyed on the code alone, never on [code name]: the amount a row
|
||||
reports is the total for its code, so two names at one code would print the
|
||||
same figure twice and the section would stop footing to its own subtotal.
|
||||
|
||||
`build-account-lookup` has already resolved a client's shared codes down to
|
||||
the bank account's name, so the name here is normally unanimous. It can
|
||||
still differ across a multi-client report, where only some of the clients
|
||||
have a bank account at the code. A bank account's name wins there; failing
|
||||
that the most common name does, ties broken alphabetically so the report is
|
||||
stable across runs."
|
||||
[pnl-datas]
|
||||
(->>
|
||||
pnl-datas
|
||||
(mapcat :data)
|
||||
(map #(select-keys % [:numeric-code :name]))
|
||||
(set)
|
||||
(map #(select-keys % [:numeric-code :name :bank-account-name?]))
|
||||
(group-by :numeric-code)
|
||||
(map (fn [[numeric-code entries]]
|
||||
{:numeric-code numeric-code
|
||||
:name (->> (or (seq (filter :bank-account-name? entries))
|
||||
entries)
|
||||
(map :name)
|
||||
frequencies
|
||||
(sort-by (juxt (comp - val) key))
|
||||
ffirst)}))
|
||||
(sort-by :numeric-code)))
|
||||
|
||||
(defn subtotal-by-column-row [pnl-datas title & [cell-args]]
|
||||
@@ -477,11 +498,11 @@
|
||||
(map
|
||||
(fn [p]
|
||||
(let [pnl-data (-> p (filter-numeric-code numeric-code numeric-code))
|
||||
this-name-exists? (->> (:data p)
|
||||
(filter (comp #{name} :name))
|
||||
seq)]
|
||||
;; Code, not name: the row is keyed on the code, and a
|
||||
;; client can reach it under a name another client won.
|
||||
this-code-exists? (seq (:data pnl-data))]
|
||||
(merge
|
||||
(if this-name-exists?
|
||||
(if this-code-exists?
|
||||
{:format :dollar
|
||||
:filters (:filters pnl-data)
|
||||
:value (aggregate-accounts pnl-data)}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"/line-item" {:get ::new-line-item}}
|
||||
|
||||
"/external-new" ::external-page
|
||||
"/bulk-delete" ::bulk-delete
|
||||
"/external-import-new" {"" ::external-import-page
|
||||
"/parse" ::external-import-parse
|
||||
"/import" ::external-import-import}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
(def routes {"" {:get ::page
|
||||
:put ::edit-wizard-submit}
|
||||
"/table" ::table
|
||||
"/csv" ::csv
|
||||
["/" [#"\d+" :db/id]] {:get ::edit-wizard}
|
||||
"/edit/navigate" ::edit-wizard-navigate
|
||||
"/edit/sales-summary-item" ::new-summary-item
|
||||
|
||||
129
test/clj/auto_ap/ledger/reports_test.clj
Normal file
129
test/clj/auto_ap/ledger/reports_test.clj
Normal file
@@ -0,0 +1,129 @@
|
||||
(ns auto-ap.ledger.reports-test
|
||||
(:require
|
||||
[auto-ap.ledger.reports :as sut]
|
||||
[clojure.test :refer [deftest is testing]]))
|
||||
|
||||
;; A client's bank account and the financial account it posts to share a
|
||||
;; numeric code. Reports key their rows off that code, so before the fix the
|
||||
;; pair rendered as two rows carrying the same code-level total, and the
|
||||
;; section stopped footing to its own subtotal.
|
||||
|
||||
(def ^:private period #inst "2026-08-14")
|
||||
|
||||
(defn- account
|
||||
[numeric-code name amount]
|
||||
{:client-id 1
|
||||
:location "M"
|
||||
:numeric-code numeric-code
|
||||
:name name
|
||||
:amount amount
|
||||
:debits 0.0
|
||||
:credits amount
|
||||
:count 1
|
||||
:account-type :account-type/liability
|
||||
:period period})
|
||||
|
||||
(defn- bank-account
|
||||
"Same as `account`, but its name came from a :bank-account rather than the
|
||||
chart of accounts — which is what earns it the row label."
|
||||
[numeric-code name amount]
|
||||
(assoc (account numeric-code name amount) :bank-account-name? true))
|
||||
|
||||
(defn- pnl-data
|
||||
[data]
|
||||
(sut/->PNLData {:periods [period]} data {1 "CLIENT"}))
|
||||
|
||||
(defn- labels
|
||||
[rows]
|
||||
(map (comp :value first) rows))
|
||||
|
||||
(deftest used-accounts-collapses-a-shared-numeric-code
|
||||
(testing "two names on one code yield a single entry, named for the bank account"
|
||||
(is (= [{:numeric-code 21010 :name "Capital One CC - 3196"}]
|
||||
(sut/used-accounts
|
||||
[{:data [(bank-account 21010 "Capital One CC - 3196" -100.0)
|
||||
(account 21010 "Accounts Payable 10" -150.0)]}]))))
|
||||
|
||||
(testing "the bank account wins even when the financial name is more common"
|
||||
(is (= [{:numeric-code 21010 :name "Capital One CC - 3196"}]
|
||||
(sut/used-accounts
|
||||
[{:data [(bank-account 21010 "Capital One CC - 3196" -100.0)]}
|
||||
{:data [(account 21010 "Accounts Payable 10" -150.0)]}
|
||||
{:data [(account 21010 "Accounts Payable 10" -150.0)]}]))))
|
||||
|
||||
(testing "distinct codes are left alone, ordered by code"
|
||||
(is (= [{:numeric-code 21009 :name "Due to Grand Ventures"}
|
||||
{:numeric-code 21010 :name "Capital One CC - 3196"}]
|
||||
(sut/used-accounts
|
||||
[{:data [(bank-account 21010 "Capital One CC - 3196" -100.0)
|
||||
(account 21009 "Due to Grand Ventures" 25.0)]}]))))
|
||||
|
||||
(testing "with no bank account in play the most common name wins"
|
||||
(is (= [{:numeric-code 21010 :name "Accounts Payable 10"}]
|
||||
(sut/used-accounts
|
||||
[{:data [(account 21010 "Due to Sandwich Monkey" -100.0)]}
|
||||
{:data [(account 21010 "Accounts Payable 10" -150.0)]}
|
||||
{:data [(account 21010 "Accounts Payable 10" -150.0)]}]))))
|
||||
|
||||
(testing "an even split breaks alphabetically so runs are reproducible"
|
||||
(is (= [{:numeric-code 21010 :name "Accounts Payable 10"}]
|
||||
(sut/used-accounts
|
||||
[{:data [(account 21010 "Due to Sandwich Monkey" -100.0)]}
|
||||
{:data [(account 21010 "Accounts Payable 10" -150.0)]}])))
|
||||
(is (= [{:numeric-code 21010 :name "Amex - 41001"}]
|
||||
(sut/used-accounts
|
||||
[{:data [(bank-account 21010 "BofA CC - 8779" -100.0)]}
|
||||
{:data [(bank-account 21010 "Amex - 41001" -150.0)]}]))
|
||||
"two bank accounts still resolve to one, deterministically")))
|
||||
|
||||
(deftest balance-sheet-renders-one-row-per-shared-code
|
||||
(let [report (sut/summarize-balance-sheet
|
||||
(pnl-data [(bank-account 21010 "Capital One CC - 3196" -100.0)
|
||||
(account 21010 "Accounts Payable 10" -150.0)
|
||||
(account 21009 "Due to Grand Ventures" 25.0)]))
|
||||
rows (:rows report)]
|
||||
|
||||
(testing "the shared code appears once, under the bank account's name"
|
||||
(is (= 1 (count (filter #(= "Capital One CC - 3196:21010" %) (labels rows)))))
|
||||
(is (not (some #{"Accounts Payable 10:21010"} (labels rows)))))
|
||||
|
||||
(testing "its row carries the total for the code, not one account's share"
|
||||
(is (= -250.0
|
||||
(->> rows
|
||||
(filter #(= "Capital One CC - 3196:21010" (:value (first %))))
|
||||
first
|
||||
second
|
||||
:value))))
|
||||
|
||||
(testing "the detail rows foot to the section subtotal"
|
||||
(let [detail (->> rows
|
||||
(filter #(re-find #":\d+$" (str (:value (first %)))))
|
||||
(map (comp :value second)))
|
||||
subtotal (->> rows
|
||||
(filter #(= "Liabilities" (:value (first %))))
|
||||
(keep (comp :value second))
|
||||
last)]
|
||||
(is (= 2 (count detail))
|
||||
"three accounts across two codes collapse to two rows")
|
||||
(is (= -225.0 (reduce + 0.0 detail) subtotal)
|
||||
"before the fix 21010 printed twice and the rows overshot the subtotal")))))
|
||||
|
||||
(deftest balance-sheet-keeps-a-clients-figure-when-another-client-named-the-code
|
||||
(testing "a column is blank only when that client has nothing at the code"
|
||||
(let [rows (:rows (sut/summarize-balance-sheet
|
||||
(sut/->PNLData
|
||||
{:periods [period]}
|
||||
[(assoc (bank-account 21010 "Capital One CC - 3196" -100.0) :client-id 1)
|
||||
(assoc (account 21010 "Accounts Payable 10" -150.0) :client-id 2)
|
||||
(assoc (account 21009 "Due to Grand Ventures" 25.0) :client-id 1)]
|
||||
{1 "ONE" 2 "TWO"})))
|
||||
shared (->> rows
|
||||
(filter #(re-find #":21010$" (str (:value (first %)))))
|
||||
first)
|
||||
absent (->> rows
|
||||
(filter #(re-find #":21009$" (str (:value (first %)))))
|
||||
first)]
|
||||
(is (= [-100.0 -150.0] (map :value (drop-last (rest shared))))
|
||||
"client TWO reaches 21010 under a name client ONE won, and still shows its balance")
|
||||
(is (= [25.0 ""] (map :value (drop-last (rest absent))))
|
||||
"client TWO has nothing at 21009 and stays blank"))))
|
||||
@@ -1,10 +1,98 @@
|
||||
(ns auto-ap.ledger-test
|
||||
(:require
|
||||
[auto-ap.integration.util :refer [wrap-setup]]
|
||||
[clojure.test :as t]))
|
||||
[auto-ap.datomic :refer [conn]]
|
||||
[auto-ap.integration.util :refer [test-account test-bank-account test-client
|
||||
wrap-setup]]
|
||||
[auto-ap.ledger :refer [build-account-lookup]]
|
||||
[clojure.test :as t]
|
||||
[datomic.api :as dc]))
|
||||
|
||||
(t/use-fixtures :each wrap-setup)
|
||||
|
||||
(t/deftest build-account-lookup-prefers-the-bank-account-name
|
||||
(t/testing "a bank account and the financial account sharing its code resolve to one name"
|
||||
(let [{:strs [client bank financial]}
|
||||
(:tempids @(dc/transact conn
|
||||
[(test-account :db/id "financial"
|
||||
:account/name "Accounts Payable 10"
|
||||
:account/numeric-code 21010
|
||||
:account/type :account-type/liability)
|
||||
(test-client :db/id "client"
|
||||
:client/bank-accounts
|
||||
[(test-bank-account :db/id "bank"
|
||||
:bank-account/name "Capital One CC - 3196"
|
||||
:bank-account/numeric-code 21010
|
||||
:bank-account/type :bank-account-type/credit)])]))
|
||||
lookup (build-account-lookup client)]
|
||||
|
||||
(t/is (= "Capital One CC - 3196" (:name (lookup bank))))
|
||||
(t/is (= "Capital One CC - 3196" (:name (lookup financial)))
|
||||
"the financial account takes the bank account's name, so reports render one row")
|
||||
|
||||
(t/testing "and both are flagged as bank-named for downstream report labelling"
|
||||
(t/is (true? (:bank_account_name? (lookup bank))))
|
||||
(t/is (true? (:bank_account_name? (lookup financial)))))
|
||||
|
||||
(t/testing "without changing the code or the account type"
|
||||
(t/is (= 21010 (:numeric_code (lookup bank))))
|
||||
(t/is (= 21010 (:numeric_code (lookup financial))))
|
||||
(t/is (= :account-type/liability (:account_type (lookup financial))))))))
|
||||
|
||||
(t/deftest build-account-lookup-leaves-unshared-codes-alone
|
||||
(t/testing "an account with no bank account at its code keeps its own name"
|
||||
(let [{:strs [client financial]}
|
||||
(:tempids @(dc/transact conn
|
||||
[(test-account :db/id "financial"
|
||||
:account/name "Sales Taxes Payable"
|
||||
:account/numeric-code 23000
|
||||
:account/type :account-type/liability)
|
||||
(test-client :db/id "client"
|
||||
:client/bank-accounts
|
||||
[(test-bank-account :db/id "bank"
|
||||
:bank-account/name "Capital One CC - 3196"
|
||||
:bank-account/numeric-code 21010
|
||||
:bank-account/type :bank-account-type/credit)])]))
|
||||
lookup (build-account-lookup client)]
|
||||
(t/is (= "Sales Taxes Payable" (:name (lookup financial))))
|
||||
(t/is (false? (:bank_account_name? (lookup financial))))))
|
||||
|
||||
(t/testing "another client's bank account never renames this client's accounts"
|
||||
(let [{:strs [mine financial]}
|
||||
(:tempids @(dc/transact conn
|
||||
[(test-account :db/id "financial"
|
||||
:account/name "Accounts Payable 10"
|
||||
:account/numeric-code 21010
|
||||
:account/type :account-type/liability)
|
||||
(test-client :db/id "mine")
|
||||
(test-client :db/id "theirs"
|
||||
:client/bank-accounts
|
||||
[(test-bank-account :db/id "bank"
|
||||
:bank-account/name "Capital One CC - 3196"
|
||||
:bank-account/numeric-code 21010
|
||||
:bank-account/type :bank-account-type/credit)])]))
|
||||
lookup (build-account-lookup mine)]
|
||||
(t/is (= "Accounts Payable 10" (:name (lookup financial))))
|
||||
(t/is (false? (:bank_account_name? (lookup financial)))))))
|
||||
|
||||
(t/deftest build-account-lookup-breaks-ties-between-bank-accounts
|
||||
(t/testing "two bank accounts on one code resolve deterministically by sort-order"
|
||||
(let [{:strs [client first-bank second-bank]}
|
||||
(:tempids @(dc/transact conn
|
||||
[(test-client :db/id "client"
|
||||
:client/bank-accounts
|
||||
[(test-bank-account :db/id "second-bank"
|
||||
:bank-account/name "US Bank 2974"
|
||||
:bank-account/numeric-code 13101
|
||||
:bank-account/sort-order 3)
|
||||
(test-bank-account :db/id "first-bank"
|
||||
:bank-account/name "Fremont Gyro HB Main 8576"
|
||||
:bank-account/numeric-code 13101
|
||||
:bank-account/sort-order 0)])]))
|
||||
lookup (build-account-lookup client)]
|
||||
(t/is (= "Fremont Gyro HB Main 8576"
|
||||
(:name (lookup first-bank))
|
||||
(:name (lookup second-bank)))))))
|
||||
|
||||
(t/deftest entity-change->ledger
|
||||
#_(t/testing "Should code an expected deposit"
|
||||
(let [{:strs [ed ccp receipts-split client]}
|
||||
|
||||
@@ -70,3 +70,66 @@
|
||||
(is (= "NICK THE GREEK" (:customer-identifier result)))
|
||||
(is (= "600 VISTA WAY" (str/trim (:account-number result))))
|
||||
(is (= "946.24" (:total result)))))))
|
||||
|
||||
(deftest parse-bonanza-produce-invoice-03932070
|
||||
(testing "Should parse a Bonanza invoice whose bill-to block carries a mixed-case, punctuated street address"
|
||||
(let [pdf-file (io/file "dev-resources/Bonanza Sample Reno 2.pdf")
|
||||
pdf-text (:out (clojure.java.shell/sh "pdftotext" "-layout" (str pdf-file) "-"))
|
||||
results (sut/parse pdf-text)
|
||||
result (first results)]
|
||||
(is (some? result) "Template should match and return a result")
|
||||
(when result
|
||||
(is (= "Bonanza Produce" (:vendor-code result)))
|
||||
(is (= "03932070" (:invoice-number result)))
|
||||
(let [d (:date result)]
|
||||
(is (= 2026 (time/year d)))
|
||||
(is (= 8 (time/month d)))
|
||||
(is (= 4 (time/day d))))
|
||||
;; "10310 N McCARRAN BLVD STE.400" has both lowercase letters and a
|
||||
;; period; an uppercase/digit-only capture used to drop it entirely,
|
||||
;; leaving the invoice with no identifier to match a client against.
|
||||
(is (= "10310 N McCARRAN BLVD STE.400" (str/trim (:account-number result))))
|
||||
(is (str/starts-with? (:customer-identifier result) "NICK THE GREEK"))
|
||||
(is (str/includes? (:customer-identifier result) "McCARRAN"))
|
||||
(is (= "524.17" (:total result)))))))
|
||||
|
||||
(deftest parse-bonanza-produce-invoice-03933054
|
||||
(testing "Should keep parsing the sibling Reno location whose address was already extractable"
|
||||
(let [pdf-file (io/file "dev-resources/Bonanza Sample Reno.pdf")
|
||||
pdf-text (:out (clojure.java.shell/sh "pdftotext" "-layout" (str pdf-file) "-"))
|
||||
results (sut/parse pdf-text)
|
||||
result (first results)]
|
||||
(is (some? result) "Template should match and return a result")
|
||||
(when result
|
||||
(is (= "Bonanza Produce" (:vendor-code result)))
|
||||
(is (= "03933054" (:invoice-number result)))
|
||||
(let [d (:date result)]
|
||||
(is (= 2026 (time/year d)))
|
||||
(is (= 8 (time/month d)))
|
||||
(is (= 7 (time/day d))))
|
||||
(is (= "5140 KIETZKE" (str/trim (:account-number result))))
|
||||
(is (str/starts-with? (:customer-identifier result) "NICK THE GREEK"))
|
||||
(is (str/includes? (:customer-identifier result) "KIETZKE"))
|
||||
;; Two-page invoice: the totals only appear on the final page.
|
||||
(is (= "955.75" (:total result)))))))
|
||||
|
||||
(deftest parse-reel-produce-statement-28676
|
||||
(testing "Should parse the Reel Produce statement layout that no longer prints 'Reel Produce' on the page"
|
||||
(let [pdf-file (io/file "dev-resources/Statement1_from_REEL_Produce_Inc.28676.pdf")
|
||||
pdf-text (:out (clojure.java.shell/sh "pdftotext" "-layout" (str pdf-file) "-"))
|
||||
results (sut/parse pdf-text)]
|
||||
(is (seq results) "Template should match and return results")
|
||||
(is (= 7 (count results)) "Should parse 7 invoices from statement")
|
||||
(doseq [result results]
|
||||
(is (= "Reel Produce" (:vendor-code result)))
|
||||
(is (= "Sushi Confidential - San Jose" (:customer-identifier result))))
|
||||
(is (= ["454379" "454826" "455120" "455683" "456654" "456774" "457171"]
|
||||
(mapv :invoice-number results)))
|
||||
(is (= ["1003.10" "530.85" "605.00" "1187.40" "164.00" "675.60" "265.75"]
|
||||
(mapv :total results)))
|
||||
;; totals add up to the statement's $4,431.70 amount due
|
||||
(is (= 4431.70 (->> results (map #(Double/parseDouble (:total %))) (reduce +))))
|
||||
(let [d (:date (first results))]
|
||||
(is (= 2026 (time/year d)))
|
||||
(is (= 6 (time/month d)))
|
||||
(is (= 23 (time/day d)))))))
|
||||
|
||||
40
test/clj/auto_ap/query_params_test.clj
Normal file
40
test/clj/auto_ap/query_params_test.clj
Normal file
@@ -0,0 +1,40 @@
|
||||
(ns auto-ap.query-params-test
|
||||
(:require
|
||||
[auto-ap.query-params :as sut]
|
||||
[auto-ap.ssr.invoices :as invoices]
|
||||
[auto-ap.ssr.transaction.common :as transaction]
|
||||
[clojure.edn :as edn]
|
||||
[clojure.test :refer [deftest is testing]]))
|
||||
|
||||
(defn- sortable-keys [grid]
|
||||
(->> (:headers grid) (keep :sort-key)))
|
||||
|
||||
(deftest parse-sort-round-trips-through-edn
|
||||
;; The bulk wizards copy :query-params into their form snapshot, serialize it with
|
||||
;; pr-str into a hidden field, and read it back with clojure.edn/read-string on
|
||||
;; submit. Anything parse-sort puts in :query-params has to survive that trip --
|
||||
;; a bare fn or other unprintable object pr-strs as #object[...], which edn cannot
|
||||
;; read, and the submit 500s before the handler ever runs.
|
||||
(doseq [[label grid] [["transactions" transaction/grid-page]
|
||||
["invoices" invoices/grid-page]]
|
||||
sort-key (sortable-keys grid)
|
||||
direction ["asc" "desc"]]
|
||||
(testing (str label " sorted by " sort-key ":" direction)
|
||||
(let [parsed (sut/parse-sort grid (str sort-key ":" direction))]
|
||||
(is (seq parsed) "should produce a sort entry")
|
||||
(is (= parsed (edn/read-string (pr-str parsed)))
|
||||
"parsed sort must survive a pr-str / edn round trip")))))
|
||||
|
||||
(deftest parse-sort-behaviour
|
||||
(testing "Unknown columns are dropped"
|
||||
(is (= [] (sut/parse-sort transaction/grid-page "not-a-column:asc"))))
|
||||
(testing "An empty sort query yields no sort"
|
||||
(is (= [] (sut/parse-sort transaction/grid-page ""))))
|
||||
(testing "Multiple sort keys are preserved in order"
|
||||
(is (= ["client" "vendor"]
|
||||
(mapv :sort-key (sut/parse-sort transaction/grid-page "client:asc,vendor:desc")))))
|
||||
(testing "Direction is parsed per key"
|
||||
(is (= [true false]
|
||||
(mapv :asc (sut/parse-sort transaction/grid-page "client:asc,vendor:desc")))))
|
||||
(testing "The display name is carried over from the matching header"
|
||||
(is (= "Date" (:name (first (sut/parse-sort transaction/grid-page "date:asc")))))))
|
||||
86
test/clj/auto_ap/ssr/ledger/export_modal_test.clj
Normal file
86
test/clj/auto_ap/ssr/ledger/export_modal_test.clj
Normal file
@@ -0,0 +1,86 @@
|
||||
(ns auto-ap.ssr.ledger.export-modal-test
|
||||
(:require
|
||||
[auto-ap.datomic :refer [conn]]
|
||||
[auto-ap.integration.util :refer [admin-token test-client user-token
|
||||
wrap-setup]]
|
||||
[auto-ap.ssr.ledger.export-modal :as sut]
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer [deftest is testing use-fixtures]]
|
||||
[datomic.api :as dc]))
|
||||
|
||||
(use-fixtures :each wrap-setup)
|
||||
|
||||
(def report {:report/name "Profit-and-loss-2026-07-31-for-Acme"
|
||||
:report/url "https://data.example.com/reports/pnl/abc/report.pdf"})
|
||||
|
||||
(defn client-with-emails
|
||||
"Transacts a client with `emails` contacts. :client/emails is a plain ref, not
|
||||
a component, so each nested contact needs its own tempid."
|
||||
[emails]
|
||||
(let [suffix (rand-int 1000000)
|
||||
client-tid (str "client-" suffix)
|
||||
contacts (map-indexed (fn [i e] (assoc e :db/id (str "email-contact-" suffix "-" i)))
|
||||
emails)
|
||||
tempids (:tempids @(dc/transact conn
|
||||
(into [(test-client :db/id client-tid
|
||||
:client/emails (map :db/id contacts))]
|
||||
contacts)))]
|
||||
{:db/id (get tempids client-tid)}))
|
||||
|
||||
(defn modal-body [request report]
|
||||
(:body (sut/export-modal request report)))
|
||||
|
||||
(deftest export-modal-download-link
|
||||
(testing "Should always offer the report for download"
|
||||
(let [client (client-with-emails [])
|
||||
body (modal-body {:identity (admin-token)}
|
||||
(assoc report :report/clients [client]))]
|
||||
(is (str/includes? body (:report/url report))
|
||||
"the S3 url should be linked")
|
||||
(is (str/includes? body "Download PDF")))))
|
||||
|
||||
(deftest export-modal-email-handoff
|
||||
(testing "Should offer a mailto hand-off addressed to the client's contacts"
|
||||
(let [client (client-with-emails [{:email-contact/email "owner@acme.com"
|
||||
:email-contact/description "Owner"}
|
||||
{:email-contact/email "cpa@acme.com"}])
|
||||
body (modal-body {:identity (admin-token)}
|
||||
(assoc report :report/clients [client]))]
|
||||
(is (str/includes? body "mailto:"))
|
||||
(is (str/includes? body "owner@acme.com"))
|
||||
(is (str/includes? body "cpa@acme.com"))
|
||||
(is (str/includes? body "Owner")
|
||||
"contacts should be described so the sender can see who it goes to")))
|
||||
|
||||
(testing "Should not offer a hand-off when the client has no email contacts"
|
||||
(let [client (client-with-emails [])
|
||||
body (modal-body {:identity (admin-token)}
|
||||
(assoc report :report/clients [client]))]
|
||||
(is (not (str/includes? body "mailto:")))))
|
||||
|
||||
(testing "Should not offer a hand-off to non-admins"
|
||||
(let [client (client-with-emails [{:email-contact/email "owner@acme.com"}])
|
||||
body (modal-body {:identity (user-token (:db/id client))}
|
||||
(assoc report :report/clients [client]))]
|
||||
(is (not (str/includes? body "mailto:")))))
|
||||
|
||||
(testing "Should not offer a hand-off for a multi-client report"
|
||||
(let [a (client-with-emails [{:email-contact/email "owner@acme.com"}])
|
||||
b (client-with-emails [{:email-contact/email "owner@beta.com"}])
|
||||
body (modal-body {:identity (admin-token)}
|
||||
(assoc report :report/clients [a b]))]
|
||||
(is (not (str/includes? body "mailto:"))
|
||||
"one client's numbers must not be mailed to another client's contacts"))))
|
||||
|
||||
(deftest mailto-href-encoding
|
||||
(let [contacts [{:email-contact/email "owner@acme.com"}
|
||||
{:email-contact/email "cpa@acme.com"}]
|
||||
href (sut/mailto-href contacts report)]
|
||||
(testing "Should address every contact"
|
||||
(is (str/starts-with? href "mailto:owner@acme.com,cpa@acme.com?")))
|
||||
(testing "Should percent-encode the subject and body"
|
||||
(is (str/includes? href "subject=Profit-and-loss-2026-07-31-for-Acme%20is%20ready"))
|
||||
(is (not (re-find #"[\s\n]" href))
|
||||
"raw whitespace would truncate the href"))
|
||||
(testing "Should carry the report url in the body"
|
||||
(is (str/includes? (sut/email-body (:report/url report)) (:report/url report))))))
|
||||
@@ -3,7 +3,7 @@
|
||||
[auto-ap.datomic :refer [conn audit-transact transact-schema install-functions]]
|
||||
[auto-ap.datomic.accounts :as a]
|
||||
[auto-ap.integration.util :refer [wrap-setup test-client test-vendor test-bank-account test-account
|
||||
setup-test-data admin-token]]
|
||||
setup-test-data admin-token user-token]]
|
||||
[auto-ap.ssr.ledger :as sut]
|
||||
[auto-ap.ssr.utils :refer [main-transformer]]
|
||||
[auto-ap.ssr.ledger.common :as common]
|
||||
@@ -13,6 +13,7 @@
|
||||
[clojure.data.csv :as csv]
|
||||
[clojure.string :as str]
|
||||
[datomic.api :as dc]
|
||||
[hiccup2.core :as hiccup]
|
||||
[malli.core :as mc]))
|
||||
|
||||
(use-fixtures :each wrap-setup)
|
||||
@@ -369,6 +370,55 @@
|
||||
(is (= "TEST" (:client-code (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
|
||||
(testing "Should upsert hidden vendors and create transactions"
|
||||
(let [_ (setup-test-data [(test-client :db/id "import-client-1"
|
||||
@@ -396,7 +446,44 @@
|
||||
(let [vendor-id (dc/q '[:find ?e .
|
||||
:where [?e :vendor/name "New Vendor Import Unique"]]
|
||||
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
|
||||
(testing "Should throw exception when entries have errors - client not found"
|
||||
@@ -557,3 +644,156 @@
|
||||
:identity (admin-token)})]
|
||||
(is (= (format "#entity-table tr[data-id=\"%d\"]" invoice-id)
|
||||
(get-in response [:headers "hx-retarget"])))))))))
|
||||
|
||||
;; =============================================================================
|
||||
;; Bulk Delete - all-ids-not-locked, bulk-delete
|
||||
;; =============================================================================
|
||||
|
||||
(defn- create-journal-entry [client-id date external-id]
|
||||
(let [temp (str (java.util.UUID/randomUUID))
|
||||
tx @(dc/transact conn [{:db/id temp
|
||||
:journal-entry/client client-id
|
||||
:journal-entry/date date
|
||||
:journal-entry/external-id external-id
|
||||
:journal-entry/source "manual"
|
||||
:journal-entry/amount 100.0}])]
|
||||
(get-in tx [:tempids temp])))
|
||||
|
||||
(deftest all-ids-not-locked-test
|
||||
(testing "Should exclude entries dated before the client's locked-until date"
|
||||
(let [tempids (setup-test-data [(test-client :db/id "lock-client"
|
||||
:client/code "LOCKTEST"
|
||||
:client/locked-until #inst "2099-01-01")])
|
||||
client-id (get tempids "lock-client")
|
||||
locked-id (create-journal-entry client-id #inst "2020-01-01" "ext-locked")
|
||||
open-id (create-journal-entry client-id #inst "2099-06-01" "ext-open")
|
||||
result (set (sut/all-ids-not-locked [locked-id open-id]))]
|
||||
(is (contains? result open-id))
|
||||
(is (not (contains? result locked-id))))))
|
||||
|
||||
(deftest bulk-delete-test
|
||||
(testing "Admin can delete selected ledger entries"
|
||||
(let [tempids (setup-test-data [(test-client :db/id "bd-client"
|
||||
:client/code "BDTEST")])
|
||||
client-id (get tempids "bd-client")
|
||||
id1 (create-journal-entry client-id #inst "2021-01-01" "ext-bd-1")
|
||||
id2 (create-journal-entry client-id #inst "2021-02-01" "ext-bd-2")
|
||||
response (sut/bulk-delete {:identity (admin-token)
|
||||
:form-params {:selected [id1 id2]}})
|
||||
db-after (dc/db conn)]
|
||||
(is (= 200 (:status response)))
|
||||
;; modal-response retargets to the persistent #modal-content shell (innerHTML)
|
||||
;; so the modal-holder survives repeated deletes; it also appends modalopen.
|
||||
(is (= "invalidated, reset-selection, modalopen" (get-in response [:headers "hx-trigger"])))
|
||||
(is (= "#modal-content" (get-in response [:headers "hx-retarget"])))
|
||||
(is (= "innerHTML" (get-in response [:headers "hx-reswap"])))
|
||||
(is (nil? (:journal-entry/external-id (dc/pull db-after [:journal-entry/external-id] id1))))
|
||||
(is (nil? (:journal-entry/external-id (dc/pull db-after [:journal-entry/external-id] id2))))))
|
||||
|
||||
(testing "Should preserve entries in a locked period even when selected"
|
||||
(let [tempids (setup-test-data [(test-client :db/id "bd-lock-client"
|
||||
:client/code "BDLOCK"
|
||||
:client/locked-until #inst "2099-01-01")])
|
||||
client-id (get tempids "bd-lock-client")
|
||||
locked-id (create-journal-entry client-id #inst "2020-01-01" "ext-bd-locked")
|
||||
open-id (create-journal-entry client-id #inst "2099-06-01" "ext-bd-open")
|
||||
_ (sut/bulk-delete {:identity (admin-token)
|
||||
:form-params {:selected [locked-id open-id]}})
|
||||
db-after (dc/db conn)]
|
||||
(is (some? (:journal-entry/external-id (dc/pull db-after [:journal-entry/external-id] locked-id))))
|
||||
(is (nil? (:journal-entry/external-id (dc/pull db-after [:journal-entry/external-id] open-id))))))
|
||||
|
||||
(testing "Non-admin cannot bulk-delete"
|
||||
(is (thrown? Exception (sut/bulk-delete {:identity (user-token)
|
||||
:form-params {:selected [1]}})))))
|
||||
|
||||
;; =============================================================================
|
||||
;; External Import - removing an entry from the review grid
|
||||
;; =============================================================================
|
||||
|
||||
(defn- import-row [external-id account-code debit credit]
|
||||
{:external-id external-id
|
||||
:client-code "REMOVE-TEST"
|
||||
:source "manual"
|
||||
:vendor-name "Remove Vendor"
|
||||
:date (coerce/to-date-time #inst "2021-01-01")
|
||||
:account-code account-code
|
||||
:location "HQ"
|
||||
:debit debit
|
||||
:credit credit})
|
||||
|
||||
(defn- entry-rows
|
||||
"The two rows a balanced ledger entry is typically pasted as."
|
||||
[external-id debit-account amount]
|
||||
[(import-row external-id debit-account amount 0.0)
|
||||
(import-row external-id 2000 0.0 amount)])
|
||||
|
||||
(deftest external-import-remove-button-test
|
||||
(testing "Every row is tagged with the entry id the importer groups on"
|
||||
(let [table (vec (concat (entry-rows "ext-a" 1100 100.0)
|
||||
(entry-rows "ext-b" 1100 50.0)))
|
||||
html (str (hiccup/html (sut/external-import-table-form*
|
||||
{:form-params {:table table}
|
||||
:form-errors {}})))]
|
||||
(is (= 2 (count (re-seq #"data-entry-id=\"REMOVE-TEST-manual-ext-a\"" html))))
|
||||
(is (= 2 (count (re-seq #"data-entry-id=\"REMOVE-TEST-manual-ext-b\"" html))))
|
||||
(testing "and offers a remove button that drops the whole entry"
|
||||
(is (= 4 (count (re-seq #"\$dispatch\('remove-import-entry'" html))))
|
||||
(is (= 4 (count (re-seq #"remove-import-entry\.window" html))))
|
||||
(is (= 2 (count (re-seq #"data-remove-entry-id=\"REMOVE-TEST-manual-ext-a\"" html))))
|
||||
(is (= 2 (count (re-seq #"aria-label=\"Remove ledger entry REMOVE-TEST-manual-ext-a\"" html))))))))
|
||||
|
||||
(deftest external-import-remove-entry-then-import-test
|
||||
(testing "Removing the failing entry lets the remaining entries import"
|
||||
(let [_ (setup-test-data [(test-client :db/id "remove-client"
|
||||
:client/code "REMOVE-TEST"
|
||||
:client/locations ["HQ"])
|
||||
(test-vendor :db/id "remove-vendor"
|
||||
:vendor/name "Remove Vendor")
|
||||
{:db/id "remove-account-1100"
|
||||
:account/numeric-code 1100
|
||||
:account/account-set "default"
|
||||
:account/name "Cash"}
|
||||
{:db/id "remove-account-2000"
|
||||
:account/numeric-code 2000
|
||||
:account/account-set "default"
|
||||
:account/name "Accounts Payable"}])
|
||||
;; Three entries, six rows. The middle one posts to an account that
|
||||
;; does not exist, which is the kind of error a user has to resolve.
|
||||
table (vec (concat (entry-rows "ext-good-1" 1100 100.0)
|
||||
(entry-rows "ext-bad" 99999 75.0)
|
||||
(entry-rows "ext-good-2" 1100 25.0)))
|
||||
admin (admin-token)
|
||||
external-id (fn [id] (str "REMOVE-TEST-manual-" id))
|
||||
imported? (fn [db id]
|
||||
(boolean (dc/q '[:find ?je .
|
||||
:in $ ?ext
|
||||
:where [?je :journal-entry/external-id ?ext]]
|
||||
db (external-id id))))]
|
||||
|
||||
(testing "the bad entry blocks the whole paste"
|
||||
(is (thrown? Exception (sut/import-ledger {:form-params {:table table}
|
||||
:identity admin})))
|
||||
(let [db (dc/db conn)]
|
||||
(is (not (imported? db "ext-good-1")))
|
||||
(is (not (imported? db "ext-good-2")))))
|
||||
|
||||
(testing "removing it drops both of its rows"
|
||||
(let [remaining (vec (remove #(= (external-id "ext-bad") (sut/line->id %)) table))]
|
||||
(is (= 4 (count remaining)))
|
||||
|
||||
(testing "and the other two entries import"
|
||||
(let [result (sut/import-ledger {:form-params {:table remaining}
|
||||
:identity admin})
|
||||
db (dc/db conn)]
|
||||
(is (= 2 (:successful result)))
|
||||
(is (imported? db "ext-good-1"))
|
||||
(is (imported? db "ext-good-2"))
|
||||
(is (not (imported? db "ext-bad")))
|
||||
(let [entry (dc/pull db
|
||||
[:journal-entry/amount
|
||||
{:journal-entry/line-items [:journal-entry-line/debit
|
||||
:journal-entry-line/credit]}]
|
||||
[:journal-entry/external-id (external-id "ext-good-1")])]
|
||||
(is (= 100.0 (:journal-entry/amount entry)))
|
||||
(is (= 2 (count (:journal-entry/line-items entry))))))))))))
|
||||
|
||||
@@ -194,6 +194,22 @@
|
||||
:body (cheshire.core/generate-string
|
||||
{:mode mode})}))
|
||||
|
||||
(defn reset-test-data! []
|
||||
"Recreate and re-seed the in-memory test database, returning to the same
|
||||
baseline the server starts with. Used by the /test-reset endpoint so each
|
||||
browser test can start from a clean, deterministic dataset."
|
||||
(reset! test-identity-mode :single-client)
|
||||
(let [conn (create-test-db)
|
||||
tx-id (seed-test-data conn)]
|
||||
(reset! test-transaction-id tx-id)
|
||||
tx-id))
|
||||
|
||||
(defn test-reset-handler [_request]
|
||||
{:status 200
|
||||
:headers {"Content-Type" "application/json"}
|
||||
:body (cheshire.core/generate-string {:ok true
|
||||
:transactionId (reset-test-data!)})})
|
||||
|
||||
(defn wrap-test-info [handler]
|
||||
(fn [request]
|
||||
(cond
|
||||
@@ -201,6 +217,8 @@
|
||||
(test-info-handler request)
|
||||
(= "/test-set-client-mode" (:uri request))
|
||||
(test-set-client-mode-handler request)
|
||||
(= "/test-reset" (:uri request))
|
||||
(test-reset-handler request)
|
||||
:else
|
||||
(handler request))))
|
||||
|
||||
|
||||
0
tmp/.gitkeep
Normal file
0
tmp/.gitkeep
Normal file
Reference in New Issue
Block a user