69 Commits

Author SHA1 Message Date
ab27d3a4da fix(ledger): match bank accounts sharing a code in register account search
A client's bank account and the financial account it posts to share a
numeric code, and :journal-entry-line/account points at either entity.
The register's Account search matched the selected entity id exactly, so
picking a financial account missed every line posted to the bank account
on that code -- while the Account Code range filter, which already
or-joins both namespaces, found them.

Resolve the shared bank accounts up front and pass the id set into the
existing clause rather than or-joining inside the query: an or-join turns
a selective indexed lookup into per-line work and measured 2.5-3.4x
slower on every account search, including ones that share no code. When
nothing shares the code the emitted query is unchanged, so the common
case stays at parity (0.97-1.04x, plus ~0.2ms to resolve).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:23:01 -07:00
19d936693a Merge pull request 'fix(reports): collapse accounts sharing a numeric code to one row' (#16) from integreat-fix-report into staging
Reviewed-on: #16
2026-08-14 16:40:45 -07:00
e9970bd41a fix(reports): collapse accounts sharing a numeric code to one row
A client's bank account and the financial account it posts to carry the
same numeric code. Reports keyed their detail rows on [code, name], so
the pair rendered as two rows — one labelled for the bank account, one
for the financial account.

The amount was duplicated too, not just the label: the row's figure is
filtered by code alone, so both rows printed the whole code's total. The
group subtotal counts the code once, so a section's visible rows stopped
footing to their own subtotal. Penelope's Coffee and Tea overshot the
2000 Accounts Payable subtotal by $251,751.80 this way.

Resolve it in two places:

- build-account-lookup now maps every account at a shared code down to
  the bank account's name, per client. Where a client has two bank
  accounts on one code, lowest :bank-account/sort-order wins, then
  lowest :db/id, so the label is stable across runs.
- used-accounts now keys rows on the code alone. Across a multi-client
  report the clients can still disagree, since only some of them have a
  bank account at the code; a bank-sourced name wins there, which the
  new :bank_account_name? flag carries through from the lookup.

Rows are code-keyed now, so detail-rows decides whether to print a
figure by asking whether the client has data at the code rather than
under the winning name — otherwise a client reaching a code under a name
another client won would blank out.

Balance sheet, profit and loss and cash flows all route through
used-accounts and are all fixed. The GraphQL and cljs balance sheets
pick up the unified name through build-account-lookup.

A sweep of all 146 clients with bank accounts finds duplicate rows on 8
of them before this change and none after, with every section total
unchanged.
2026-08-14 16:36:01 -07:00
366781e818 sort fix 2026-08-13 10:13:57 -07:00
bcb1978f44 fix(ledger): ignore external import rows with no debit or credit
A pasted row with both amount columns empty carries no accounting
information, but it still became a line item: it tripped the "Line item
amount 0.0 must be greater than 0." warning, which demoted the entire
journal entry to "ignored" -- so the good rows around it were dropped
and any existing entry with that external id was retracted.

Drop those rows in table->entries before add-errors runs, so they never
reach the balance check, :amount, or :line-items. An entry whose rows are
all blank disappears completely: not imported, not retracted, not
counted. Only genuinely empty values qualify (nil or a whitespace-only
string, since form input decodes "" to nil); an explicit 0 still warns as
before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 12:59:47 -07:00
785c6b3731 feat(ssr): add errors-only filter to the external import grids
Both external import review grids (ledger and transaction) can run to
hundreds of rows, so finding the handful that failed validation meant
scrolling the whole table. Add an "Only show errors" checkbox next to
"Show table" that hides every clean row.

Done with native Alpine: the wrapper's x-data carries errorsOnly, the
checkbox is x-model bound, and each row is rendered server-side knowing
whether it has errors -- flagged rows get no directive, clean rows get
x-show="!errorsOnly". The grid container becomes
x-show="showTable || errorsOnly" so ticking the filter reveals the table
in one click.

Submitting is unaffected: x-show only toggles display, so hidden rows
keep their inputs in the DOM and still post. Verified in the browser that
the full row set serializes while the filter is on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:30:44 -07:00
33cfbab54a fix(parse): extract Bonanza bill-to lines with mixed case and punctuation
The Bonanza Produce invoice template captured the bill-to name and street
with [A-Z\s] / [A-Z0-9\s] classes, so any store name or address containing
a lowercase letter or punctuation failed to match and came back nil. On the
McCarran invoice that meant both :customer-identifier and :account-number
were empty, leaving the import with nothing to look a client up by.

Anchor instead on the B/I/L/L letters printed down the left margin at the
start of a line (the ship-to block on the right reuses the same letters
mid-line) and take the whole column up to the next column gap, without
restricting the character set. The leading \d on the account-number capture
is what selects the street L line over the name L line.

Every value the template already extracted is unchanged; only the nils
moved. Adds regression tests for both Reno locations, covering the address
that used to drop out and the sibling one that already worked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:11:08 -07:00
e99ac6e978 fix(ssr): stop parse-sort leaking a render fn into wizard snapshots
Sorting a grid before opening a bulk wizard 500'd the submit with
"No reader function for tag object".

parse-sort returned the grid's whole :matching-header map, which carries a
:render fn. That sort rides along in :query-params, which the bulk wizards
copy verbatim into their form snapshot (bulk_code.clj:88, invoices.clj:1437).
The snapshot is serialized with pr-str into a hidden field and read back with
clojure.edn/read-string on submit; a fn pr-strs as #object[...], which edn has
no reader for, so wrap-decode-multi-form-state threw before the handler ran.

:matching-header was only ever read inside parse-sort itself - nothing
downstream consumes it, and apply-toggle-sort in this same namespace already
builds entries without it. Keep it as a local binding to derive :name and to
drop unknown columns, and leave it out of the result.

Fixes transaction bulk-code (11 production 500s over 2026-08-10/11) and the
same latent bug in invoice bulk-edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:10:26 -07:00
b43f107610 fix(ssr): make the CSV export button visible, add account number to ledger CSV
The grid export button rendered white-on-white: a-button's :secondary-light
asked for bg-white-200, which tailwind never generated (no white scale in the
config), then fell through to the generic color branch that layers text-white
on top. Give the variant a real light-blue fill and stop the fallthrough, and
label the button "CSV" so it isn't a lone download glyph.

The ledger register CSV named the account but not its number, so exports had
to be joined back by name. Add an Account Number column beside it, falling
back to the bank account's numeric code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:48:30 -07:00
379bfa78f7 feat(ssr): add clear button to typeahead
The single typeahead had no visible way to unset a selection — clearing
was only possible via the undiscoverable backspace keybinding.

Adds an x icon on the right side of the field, shown only when a value is
selected. It reuses the same `value = {value: '', label: ''}` assignment
the existing backspace handler uses, so it rides the already-wired clear
path: the hidden input's $watch dispatches `change` (driving htmx filter
refetches) and `x-modelable "value.value"` propagates the empty value to
any parent scope bound via x-model (driving dependent fetches such as the
transaction-rule Account -> Location and Client -> Bank Account chains).

The icon is a plain div with tabindex="-1" and aria-hidden, so it stays
out of the tab order; @click.prevent.stop keeps the click from bubbling
to the wrapping anchor and popping the dropdown. Matches the clear
affordance multi-typeahead already had.

Rebuilds output.css for the new dark:hover:text-gray-200 utility.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:32:25 -07:00
0575c88c4d cleans up ux. 2026-08-04 23:33:50 -07:00
bc2abf4cb1 fixes 2026-08-04 23:00:29 -07:00
acc5a7aa1b Merge branch 'staging' of gitea.story-basking.ts.net:notid/integreat into staging 2026-08-04 22:59:46 -07:00
46fdc29712 Merge pull request 'integreat-send-email' (#15) from integreat-send-email into staging
Reviewed-on: #15
2026-08-04 22:59:33 -07:00
0df9a29022 feat(ssr): add report email hand-off to ledger export modal
The SSR ledger reports could generate and download a PDF but had lost the
SPA's ability to hand the report off to the client's email contacts. This
restores it for Profit and Loss, Balance Sheet and Cash Flows.

The server still sends nothing: the modal offers a mailto: link, pre-filled
with the client's email contacts, subject and body, that opens in the user's
own mail client so they can review before sending.

Extracts the modal the three reports duplicated into a shared
auto-ap.ssr.ledger.export-modal namespace, and tightens the SPA's rules
along the way:

- admin-only, uniformly (the SPA's Cash Flows page skipped this check)
- single client only, uniformly (the SPA's Balance Sheet page did not check,
  so a multi-client report could be mailed to one client's contacts)
- recipients joined with "," per RFC 6068 rather than Outlook's ";"
- subject percent-encoded, like the body already was
- body links built from :base-url and bidi routes, fixing the dead
  /reports/ link (the page now lives at /company/reports) and the
  hardcoded prod domain in the requires-feedback link

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 22:55:53 -07:00
a17e16e31b feat(ledger): remove a whole ledger entry from the external import grid
The external ledger import review grid is one row per line item, so a
journal entry spans two or more rows. When one entry fails validation the
whole paste is rejected, and the only way forward was to re-paste without
it.

Each row now carries its entry id (client-source-externalId, the same key
table->entries groups on) and the last column gets a trash button that
drops every row sharing that id. The rows are the form inputs, so removing
them from the DOM removes them from the next import post -- which also
works for rows that fail schema validation. Index gaps left behind are
compacted by coerce-vector on the way back in.

Also drops two leftover pprint calls that dumped form-errors to stdout on
every render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 22:44:52 -07:00
e6507d5387 merged 2026-08-04 22:15:59 -07:00
5126f3799d data(sysco): apply client category verification sheet to line-item mapping
Recategorizes 131 Sysco line-item descriptions per the client-reviewed
"Product Category Verification with codes" sheet: 96 existing rows re-coded
and 35 new rows appended (ids 1796-1830).

Root cause this addresses: get-line-account matches on exact description
string and silently defaults anything unmapped to 50000 Food Costs. Only 147
of the sheet's 321 reviewed rows coded the way the client expected. Note the
sheet's "change" column understates the work -- 20 of its 53 change rows are
no-ops (Paper -> Paper, confirming the gloves/liners/hairnets) and 3 were
already fixed in 38575aa5 / 7a0e256f, while 214,398 lines of movement come
from rows the client ticked as correct against a suggestion that already
differed from production.

Moves, replayed over all 1,022,732 DET lines in sysco-poller:

  50000 -> 51500 Dry Goods            90,481 ln   $5,215,011.82  105 clients
  50000 -> 51450 Dressing & Sauce     58,191 ln   $4,445,726.56   98
  50000 -> 51400 Bread and Bun        37,705 ln   $3,964,462.24   96
  50000 -> 52000 Soft Beverage        36,244 ln   $1,033,497.52   94
  50000 -> 51200 Produce               6,519 ln     $460,629.32   98
  55000 -> 51500 Dry Goods             6,516 ln     $259,750.60   97
  50000 -> 74100 Cleaning Supplies     5,630 ln     $205,233.19   98
  55000 -> 74100 Cleaning Supplies     5,222 ln     $125,946.32   99
  50000 -> 51120 Chicken/Poultry         265 ln      $42,482.50    8
  50000 -> 51300 Dairy                    36 ln       $5,941.69    6
  50000 -> 55000 Paperware                54 ln       $1,751.22   18
  54400 -> 51450 Dressing & Sauce         24 ln       $1,413.26    1
  total                               246,887 ln  $15,761,846.24

Only three source accounts are touched: 50000 and 55000 (the two silent
defaults) plus the single intended 54400 -> 51450 vinaigrette row. Nothing
else leaves a deliberately assigned account.

The 7 Misc Charges descriptions are deliberately left alone per Bryce,
including PICKLE CHIP KOSH 1/4 KK, which therefore stays at the 50000
default rather than moving to Produce as the sheet originally suggested.

Also corrects the PAPER & DISP fallback comment in sysco.clj: 440 of its 455
mapped descriptions point at 55000, not all 454. The 15 exceptions (foil
pans -> 51500, scour pads -> 74100) are mapped explicitly, so the
description map still wins ahead of the fallback. No logic changed.

Affects only clients with the code-sysco-items feature flag, and only at
import time -- already-imported invoices keep their existing splits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 22:00:57 -07:00
7a0e256f07 fix(sysco): fall back to PAPER & DISP category when description is unmapped
The Sysco importer codes each line item by exact description match against
resources/sysco_line_item_mapping.csv, silently defaulting to GL 50000 (Food
Costs) when the description is absent. Every new or renamed Sysco SKU
therefore leaks into Food Costs until someone hand-patches the CSV, which is
what 38575aa5 did for 34 descriptions.

Add a category-level fallback consulted after the description map and before
the 50000 default, enabled for PAPER & DISP only. The description mapping
still wins wherever it exists, so nothing already mapped changes.

PAPER & DISP is safe to generalize: all 454 mapped PAPER & DISP rows point at
55000, with no exceptions. Of the 852 distinct descriptions ever invoiced
under that category, only 3 resolved elsewhere, each because a row with a
different category shared the description and won the later-wins (into {}).
One of those, DESSERT CUP, was simply mis-categorized -- it is paper, and its
own lid (id 1782 LID DOME DESSERT CUP) was already 55000 -- so correct id 1772
to PAPER & DISP / 55000. The remaining two stay at 50000 on purpose, since
they are not paper: PAD SCRUB S-S 35 GRAM 1.25 OZ (SUPP & EQUIP) and TEST
STRIP SANITIZER QUAT (CHEMICAL/JANTRL).

Verified by replaying both changes over all 1,022,732 DET lines in the 56,010
CSVs under sysco-poller/: every resulting transition is 50000 -> 55000 (10,994
lines, $728,106.62). No line that already resolved to a non-default account
moved.

Note this only affects clients carrying the code-sysco-items feature flag, and
only on import -- already-imported invoices need a separate recode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 07:43:58 -07:00
7155e199e8 Merge branch 'master' into staging 2026-07-29 10:25:04 -07:00
2b5fbaca00 Add template for new Reel Produce statement layout
The statement no longer prints "Reel Produce" as text (only
orders@reelproduce.com), switched to MM/DD/YYYY dates, and moved the
invoice number into an "INV #..." transaction description, so no
template matched and the file fell through to the glimpse2 fallback.

Adds a QuickBooks-statement-style template (same shape as Suncrest /
Ocean Queen) keyed on reelproduce.com + Statement, placed after the
existing Reel Produce statement template so the old layout still wins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 10:24:31 -07:00
473556a45d Backfill script for olo 2026-07-29 09:59:02 -07:00
604d1ee1cf changes 2026-07-25 21:13:44 -07:00
e095cb94e4 fix(config): propagate rotated Plaid secret to worker configs
The rotation in 111eca41 updated the Plaid secret-key only in prod.edn,
leaving prod-background-worker.edn, prod-cloud-background-worker.edn, and
prod-cloud.edn on the old (now-invalidated) secret. Background worker jobs
loading those files failed with INVALID_API_KEYS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:29:15 -07:00
9d007ee8e2 Merge branch 'master' into staging 2026-07-22 21:50:12 -07:00
fa25620b7a olo fixes 2026-07-22 21:49:24 -07:00
d012283362 olo fixes 2026-07-22 21:43:31 -07:00
14726d0208 re-synced secrets. 2026-07-22 21:17:31 -07:00
6429cf823d fixes 2026-07-12 20:45:36 -07:00
111eca413e rotates passwords 2026-07-12 20:44:05 -07:00
80e45c026e feat(ssr): add sales summary csv download
Exports one row per sales summary item so each category is individually
auditable, rather than one row per daily summary. Uses the grid helper's
page->csv-entities hook, the same seam the ledger export uses to fan a
journal entry out into its line items.

Includes the GL account code alongside the name so rows tie back to the
chart of accounts, and formats amounts to cents to keep float noise
(36.900000000000006) out of the export. Accounts are resolved in a single
batched pull and clientized per summary so name overrides are respected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:51:46 -07:00
9cd3d9c962 fixed. 2026-07-08 23:33:37 -07:00
5f5686c39c fix(ssr): link to the real transaction route from paperclip menus
::transaction-routes/all-page does not exist -- the transactions index
is registered as ::transaction-routes/page ("/transaction2"). bidi's
path-for returns nil for an unknown handler, so (hu/url nil {...})
produced the relative "?exact-match-id=123". Clicking a Transaction
link in the ledger paperclip tooltip therefore stayed on the ledger and
re-filtered it by a transaction entity id, matching nothing.

Repoint all five call sites (ledger, payments and invoice paperclip
menus, the insights breadcrumb, and the expected-deposit row button) at
::transaction-routes/page.

Also add the missing conj in the ledger :invoice/source-url cond->
branch. The bare map made cond-> invoke it as a function against the
accumulated vector, yielding nil, so any journal entry whose original
entity was an invoice with a file rendered no paperclip at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:15:17 -07:00
d0846f91aa fix(ssr): use form-validation-error for transaction edit validations
Untyped (ex-info ... {:validation-error ...}) throws escaped the
wrap-form-4xx-2 middleware, surfacing as a 500 / unexpectedError box
instead of an inline form error. Route them through
utils/form-validation-error, which throws with :type :form-validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:12:00 -07:00
56addf8c70 fix(ssr): order coerced vector form params by numeric index
Indexed form fields like periods[0]..periods[13] parse into a
string-keyed map; coerce-vector rebuilt the vector with a plain
lexicographic sort, so "10".."13" sorted before "2". Reports with
10+ periods (e.g. the 13/14-period option) rendered columns in the
order 0,1,10,11,12,13,2..9. Sort index keys numerically instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 05:22:52 -07:00
ebb48a0de4 feat(ssr): previous calendar year period + external register filters
- P&L period dropdown: replace "Calendar year (YYYY)" with
  "Previous Calendar Year (YYYY-1)" pulling the full prior year
- External register: title now reads "External Register"; add
  Source, External Id, and Location filters

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:22:31 -07:00
e2ccfc8d2c Merge branch 'ledger-bulk' into staging 2026-06-23 22:03:29 -07:00
e8cbd2760c fixes 2026-06-23 22:03:26 -07:00
e0da8e1866 feat(ssr): add delete selected to external ledger
Replicate the master CLJS "delete external ledger" feature on the SSR
external ledger page: an admin-only bulk delete that retracts the
selected journal entries, skipping any in a client's locked period and
capping at 1000 per request.

Return the result via modal-response (retargets the persistent
#modal-content shell) and target #modal-content from the button so the
request never relies on the outerHTML swap inherited from the data-grid
card, which previously replaced #modal-holder and broke the next click.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:48:39 -07:00
2e3c1e3646 feat(ssr): add clear filters button to transactions
Shows a "Clear filters" button in the transactions action bar whenever a
non-date filter is active. It's a boosted link back to the transactions
page that preserves the date range (and any implied status), so the
sidebar filters and table both reset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:43:43 -07:00
a7e9fbaf6b feat(ssr): disable bulk action buttons until a transaction is selected
Disable Code, Delete, and Suppress until at least one row is checked or
"select all" is active, matching the existing selection-aware UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:19:39 -07:00
8a676718a7 feat(ssr): reset transaction selection after bulk code
Bulk coding left the checked items selected after the table refreshed.
Add a dedicated reset-selection event that the grid's Alpine state
listens for, and fire it alongside refreshTable on bulk-code submit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:06:37 -07:00
3ffb661da3 fix(ssr): stop unresolved filter flipping to true on transactions navigation
The unresolved/potential-duplicates query-param decoders fell through to
(boolean %) for unrecognized strings. A round-tripped "false" (pushed into the
URL, re-read via HX-Current-URL) decoded to true since any non-nil string is
truthy, so navigating pages silently turned on the "Unresolved only" filter.

Handle "false" and already-boolean values symmetrically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:40:31 -07:00
f9438ba983 fix(ssr): only require account coding for manual transaction edits
Account coding lived in the always-applied base map of edit-form-schema, so
every action (including the link/apply-rule/unlink actions) required a valid
transaction-account/account. The edit modal always submits the Manual tab's
(usually blank) account row, so link submits failed validation before reaching
their save-handler and silently no-op'd. Move account validation into the
:manual branch of the action :multi so link actions validate without it.

Also surface whole-form validation errors in the wizard footer error bar:
default-step-footer only handled top-level/sequential error shapes, so nested
field-error maps (e.g. a hidden tab's account error) produced an empty bar and
a silent failure. Add flatten-form-errors to flatten the humanized error tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:30:29 -07:00
7d34b8a5f6 money 2026-06-18 20:26:07 -07:00
c09d85ede6 fix(ssr): fix Client Review (requires-feedback) status in bulk-code dialog
The bulk-code "Requires Feedback" option submitted "requires_feedback"
(underscore), which decoded to an enum keyword not present in the
schema (idents use a hyphen), so selecting it failed validation. Use
the hyphenated value and relabel the option, the reconciliation report
header to "Client Review" to unify with the sidebar terminology.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:38:00 -07:00
ec4f88b7fc fix(ssr): hide P&L warning box when there is no warning
The profit-and-loss report always passed :warning as a [:div ...] hiccup
vector, which is truthy even when empty. The shared report table renders
its red warning box with (when warning ...), so a clean report with no
warning and no unresolved entries still showed an empty red error box.

Only build the warning div when there is actual warning text or sample
links, matching how the balance-sheet and cash-flows reports pass nil.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:56:43 -07:00
8ca5e75c4d fix(ssr): hide client column in edited transaction row for single client
The save-handler re-rendered the edited row via row* without passing
:request, so the Client column's :hide? predicate received a nil request
and never hid the column. Pass :request request like table* does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:40:50 -07:00
4aed27b204 feat(ssr): add bank account column to transactions table
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:36:08 -07:00
d0028f403c fix(ssr): allow picking bank account when editing a transaction rule
The bank-account filter rendered "Please select a client" even when a
client was set on the rule. Two causes:

- Inside (fc/with-field :transaction-rule/bank-account ...) the cursor is
  rebound to the bank-account field, so (:transaction-rule/client
  (fc/field-value)) read the nil bank-account value and the server
  rendered the placeholder. The clientId watcher only fires on change, so
  when editing (client preset, unchanged) the htmx swap never corrected
  it. Read the client from the form root before entering the field.
- The clientId-change swap used innerHTML, nesting a fresh typeahead
  inside the stale one and breaking its Alpine refs. Use outerHTML so the
  typeahead is replaced in place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:32:30 -07:00
6b4392b74b fix(ssr): keep top bar to a fixed-height single row
The top bar grew vertically on narrower viewports when the environment
badge and company-selector labels wrapped, pushing content under the
fixed navbar (which the layout offsets with a fixed pt-16).

Rework the navbar into a fixed h-16 row with a priority-based responsive
layout:
- search fills the middle (flex-1) and shrinks first when space is tight
- company selector holds its size and truncates long names
- environment badge degrades full pill -> compact letter badge -> hidden
- harmonize control heights (40px controls, 32px badge/avatar accents) so
  the search no longer renders as a cramped thin strip

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 09:01:32 -07:00
cdc87d3710 fix 2026-06-16 20:36:00 -07:00
1e3952a7fb fix(auth): login error-details pre escaping Alpine scope
The error-details <pre> lived inside a <span x-data="{e:false}"> that was
itself inside a <p>. Since <pre> is block content, the HTML parser closed
the <p> and reparented the <pre> out of the span, so Alpine evaluated
x-show="e" with e no longer in scope ("e is not defined"). Use a <div>
wrapper instead of <p> so the pre stays within the e scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 20:05:43 -07:00
e099714af1 fix(ssr): transaction edit dropdown duplication and advanced->simple toggle
- Location field hx-target "find *" resolved to the <label> (first child),
  so changing an account swapped the reloaded <select> over the label and
  left a duplicate dropdown. Target "find select" instead (simple + advanced).
- edit-wizard-toggle-mode-handler read mode only from step-params, but the
  hidden "mode" field is a top-level form param, so current-mode always
  defaulted to "simple" and the toggle could never return from advanced.
  Read it from form-params too, matching edit-vendor-changed-handler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 20:02:41 -07:00
11024b7b89 fix(ssr): transaction rule wizard drops fields on Next
The EditModal step body wrapped all rule fields in a nested
<form id="my-form"> inside the wizard's own #wizard-form. By HTML
form-ownership rules those fields belonged to the inner form, so when
htmx serialized #wizard-form on Next, none of the step-params fields
were sent. The server saw an empty rule, reported "required" for
description/accounts, and re-rendered a blank wizard (losing input).

Replace the nested <form> with a plain <div>; the wizard form already
owns submission, so the inner form and its htmx attributes were
redundant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:58:36 -07:00
de2a1ab850 fixes pnl file name 2026-06-16 14:37:13 -07:00
fc54b92ddb fixes 2026-06-04 22:59:51 -07:00
019a1b4cd8 fixes 2026-06-04 22:56:01 -07:00
38575aa5bd data(sysco): add missing line-item GL mappings for paper & other items
The Sysco importer codes line items by exact-matching the item description
against resources/sysco_line_item_mapping.csv, falling back to GL 50000
(Food Costs) when no entry exists. On master, 8 of the paper-product
descriptions on recent invoices (e.g. BAG PAPER 250 CT, NAPKIN 2PLY INTR
FOLD 6.3X8.26, CONTAINER PAPER 4/110OZ NTG) were missing, so they
defaulted to 50000 instead of 55000 (Paper Costs).

Append the 34 curated mappings (Ids 1762-1795) covering these paper items
(-> 55000) plus the other new items from the same invoices, so they code
correctly on re-import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 09:28:23 -07:00
85aaf7b759 mcp repl fixes 2026-06-02 23:40:05 -07:00
3641846f70 Merge pull request 'docs: SSR rendering modernization rollout plan' (#12) from docs/ssr-rendering-modernization-plan into staging
Reviewed-on: #12
2026-06-02 23:26:45 -07:00
d360316590 docs: add swap-target selector strategy consideration
Note in 3.1 that targeted hx-select/hx-target swaps in repeated/nested
structures may want a consistent scheme -- semantic markup + data-attributes,
or a form-path->selector helper (mirroring cursors) -- instead of hand-minting
a unique id per element. Framed as a consideration for advanced cases, with a
Phase 5 task to settle the convention into the skill cookbook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 23:17:52 -07:00
8215e6376d Merge pull request 'fix(ssr): require Apply for all date-range filters' (#13) from integreat-fix-other-dates into staging
Reviewed-on: #13
2026-06-02 22:42:49 -07:00
3759258ebe fix(ssr): require Apply for all date-range filters
Most grid pages auto-submitted their date-range filter on every change
event, which fired mid-typing and re-rendered the date inputs, breaking
manual date entry. Invoices and ledgers already gated date submission
behind an explicit Apply button; this brings the other ten pages in line.

- date-range component: stop `change` from the date inputs bubbling to
  the form (@change.stop) and always render the Apply button, so typed or
  picked dates submit only via the Apply button's `datesApplied` event.
  The All/Week/Month/Year presets and all other filters are unaffected.
- payments, invoice import, transactions, import batches, sales
  summaries, expected deposits, cash drawer shifts, refunds, tenders,
  sales orders: add `datesApplied` to the form hx-trigger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:42:17 -07:00
0e02c489e0 docs: multi-step wizards use session-stored step state (Django formtools)
Replace the EDN snapshot + piecewise merge for multi-step wizards with per-step
form state stored in the session, combined only at the end -- the Django
formtools WizardView / SessionStorage model. Cite the inspiration and refs.

Adds rationale 2.4, reworks the engine snippet in 3.3 to thread session state
keyed by wizard-id (no snapshot, no merge), and updates goal 3, the Phase 6
engine tasks, the risk row, and Open decision 1 accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:09:40 -07:00
917b7f3857 docs: clarify cursors are fine; only faked positions are the smell
Reframe goal 2, the rationale (2.2), the render-function pattern (3.2), and
scorecard heuristic 1 so the target is top-rooted cursors. Cursors stay; what
we remove is faking a cursor to start deeper in the tree and the duplicate
*-no-cursor* variants that fakery forces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:02:25 -07:00
a8d8a8d111 docs: make SSR migration plan self-contained and executable
Rewrite the plan to stand on its own: state the goals and target patterns
directly (illustrated with code snippets) instead of reconciling experimental
workstreams. Spell out every migration as concrete, checkboxed tasks an agent
can execute, with per-modal rationale and specifics.

Reorder so the first step distils the proven transaction-edit migration into a
ssr-form-migration skill (Phase 1), then trials that skill on the same modal as
its first test subject (Phase 2), then rolls out simplest-first with every
phase feeding the skill. Adds an explicit migration inventory, per-migration
playbook, quality scorecard, and test-first strategy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:56:12 -07:00
360847fa58 docs: add SSR rendering modernization rollout plan
Synthesize three SSR refactor exercises into one low-risk, compounding
rollout plan: the render-whole-form HTMX swap doctrine, the critique-wizard
architecture simplification, and a Hiccup -> Selmer templating migration.

Includes a code-quality ratchet (per-migration scorecard), an explicit
test-first strategy with an e2e regression gate, simplest-first phasing, and
a self-reinforcing ssr-form-migration skill so each migration makes the next
cheaper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:39:04 -07:00
55650c2dab Merge pull request 'refactor(charts): unify on Chart.js, remove Chartist' (#11) from integreat-unify-charts into staging
Reviewed-on: #11
2026-06-02 09:23:29 -07:00
74 changed files with 3443 additions and 637 deletions

View File

@@ -173,4 +173,4 @@
:token "EAAAEO2xSqesDutZz71hz3eulKmrlKTiEqG3uZ4j25x5GYlOluQ2cj2JxNUXqXD7"}}
:plaid {:base-url "https://production.plaid.com"
:client-id "61bfab05f7e762001b323f79"
:secret-key "2be026ca5e7f7e9f23f2fb4d7c914d"}}
:secret-key "44a05fbe9f33a2975b3b3ac06b0b62"}}

View File

@@ -31,5 +31,5 @@
:yodlee2-proxy-port 8888
:plaid {:base-url "https://production.plaid.com"
:client-id "61bfab05f7e762001b323f79"
:secret-key "2be026ca5e7f7e9f23f2fb4d7c914d"}
:secret-key "44a05fbe9f33a2975b3b3ac06b0b62"}
}

View File

@@ -34,5 +34,5 @@
:yodlee2-proxy-port 8888
:plaid {:base-url "https://production.plaid.com"
:client-id "61bfab05f7e762001b323f79"
:secret-key "2be026ca5e7f7e9f23f2fb4d7c914d"}
:secret-key "44a05fbe9f33a2975b3b3ac06b0b62"}
}

View File

@@ -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"}}

View File

@@ -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"}
}

Binary file not shown.

Binary file not shown.

View File

@@ -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
1020 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)*

View File

@@ -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) {

View File

@@ -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);
});
});

View File

@@ -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:

View File

@@ -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);
});
});

View File

@@ -1 +1 @@
1`

View File

@@ -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',

View File

@@ -0,0 +1 @@
,noti,pop-os,01.06.2026 21:02,file:///home/noti/.config/libreoffice/4;

View File

@@ -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

View File

@@ -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,
1 Id Sysco Category Sysco Description Integreat Account Integreat Account Code Nick's changes
6 4 PAPER & DISP STRAW PLAS TRANS JMB WRPD 7.75 Paper Costs 55000
7 5 PAPER & DISP FORK PLAS BLK PLA COMPOSTABLE Paper Costs 55000
8 6 PAPER & DISP BAG PLAS LOGO 3 CLR Paper Costs 55000
9 7 FROZEN BREAD PITA GYRO PRE-OILED 7 Food Costs Bread and Bun Costs 50000 51400
10 8 DAIRY PRODUCTS YOGURT FRZN TART Dairy Costs 51300
11 9 POULTRY GYRO CHICKEN SHAWARMA CONE Chicken/ Poultry Costs 51120
12 10 FROZEN BAKLAVA CLASSIC 2X24 Food Costs Dry Goods Costs 50000 51500
13 11 MEATS PORK SLI GYRO CONE Beef/Pork Costs 51110
14 12 DAIRY PRODUCTS SAUCE TZATZIKI Dairy Costs 51300
15 13 POULTRY CHICKEN CVP THIGH BNLS SKLS Chicken/ Poultry Costs 51120
19 17 CANNED AND DRY RICE BASMATI STEAMED XTRA LNG Food Costs 50000
20 18 CANNED AND DRY WATER SPARKLING GREEK Beverages Costs 52000
21 19 DAIRY PRODUCTS SAUCE SPICY YOGURT LOGO Dairy Costs 51300
22 20 CANNED AND DRY WATER PURIFIED .5 Food Costs Soft Beverage Cost 50000 52000
23 21 FROZEN DOUGH PASTRY HNY PUFF Food Costs 50000
24 22 DAIRY PRODUCTS YOGURT PLAIN GREEK NON-FAT Dairy Costs 51300
25 23 PAPER & DISP GLOVE NITRILE LARGE Paper Costs 55000
43 41 CANNED AND DRY WATER BOTTLED DRINKING Food Costs 50000
44 42 CANNED AND DRY SODA CHERRY VISSINADA GRK PLAS Food Costs 50000
45 43 CANNED AND DRY SODA LEMON LEMONADA GREEK Soft Beverage Costs 52000
46 44 CANNED AND DRY WATER MINERAL CARNONATED GREEK Food Costs Soft Beverage Cost 50000 52000
47 45 DISPENSER BEVRG SYRUP COLA PEPSI BIB Soft Beverage Costs 52000
48 46 DISPENSER BEVRG SYRUP LEMONADE PNK BIB Soft Beverage Costs 52000
49 47 CANNED AND DRY RICE BASMATI PABROIL SELA CS Food Costs Dry Goods Costs 50000 51500
50 48 CANNED AND DRY KETCHUP FANCY Food Costs Dry Goods Costs 50000 51500
51 49 CANNED AND DRY TUB & HUMMUS Food Costs 50000
52 50 CHEMICAL/JANTRL SANITIZER MULTI QUAT LIQ Food Costs Cleaning Supplies 50000 74100
53 51 DAIRY PRODUCTS YOGURT PLAIN GRK 5% Dairy Costs 51300
54 52 FROZEN APTZR VEG FALAFEL BALL Food Costs Dry Goods Costs 50000 51500
55 53 PAPER & DISP BOWL PAPER FIBER RND 32OZ 8IN Paper Costs 55000
56 54 PAPER & DISP LID PLAS F/BOWL RND 8 Paper Costs 55000
57 55 MEATS BEEF GRND CHUCK FINE 80/20FRSH Beef/Pork Costs 51110
58 56 CANNED AND DRY SODA ORANGE CRSH Food Costs Soft Beverage Cost 50000 52000
59 57 PAPER & DISP CONTAINER PLAS CLR BAR LK 5 IN Paper Costs 55000
60 58 CANNED AND DRY KETCHUP PACKET FCY Food Costs Dry Goods Costs 50000 51500
61 59 PAPER & DISP BAG PLAS WAVE TOP LOGO 18X16 Paper Costs 55000
62 60 CANNED AND DRY DRESSING VINAIGRETTE LOGO Food Costs Dressing & Sauce Cost 50000 51450
63 61 PAPER & DISP CONTAINER PAPER MLD FBR 9X6 Paper Costs 55000
64 62 PAPER & DISP BOWL PAPER MLD FBR 32OZ NFA Paper Costs 55000
65 63 PAPER & DISP CONTAINER PLAS 120Z SUNDAE Paper Costs 55000
66 64 CANNED AND DRY DRESSING VINAIGRETTE GYRO Food Costs 50000
67 65 CANNED AND DRY VINEGAR WINE RED 5% 50 GRN Alcohol Costs 54000
68 66 DAIRY PRODUCTS EGG SHELL LG WHT AA CA CGFREE Dairy Costs 51300
69 67 CANNED AND DRY DRESSING MARINADE SOUVLAKI Food Costs Dressing & Sauce Cost 50000 51450
70 68 CANNED AND DRY SAUCE MUSTARD Food Costs Dressing & Sauce Cost 50000 51450
71 69 CANNED AND DRY TEA ICED SWEET PURELEAF Beverages Costs 52000
72 70 PAPER & DISP LINER TRASH 40X46 1.1 ML GRY Paper Costs 55000
73 71 CANNED AND DRY SODA COLA Soft Beverage Costs 52000
74 72 CANNED AND DRY HONEY PURE CLOVER GR A TSC JUG Food Costs Dry Goods Costs 50000 51500
75 73 DAIRY PRODUCTS CHEESE FETA RW Dairy Costs 51300
76 74 CANNED AND DRY WATER PURIFIED BTL PET LSE DW Food Costs Soft Beverage Cost 50000 52000
77 75 PRODUCE JUICE LEMON FRESH PSTRZD Produce Costs 51200
78 76 CANNED AND DRY SPREAD HUMMUS TRADITIONAL Food Costs Dressing & Sauce Cost 50000 51450
79 77 PRODUCE LETTUCE ROMAINE OF HEART FRSH Produce Costs 51200
80 78 PAPER & DISP CONTAINER PAPER #1/30OZ NTG Paper Costs 55000
81 79 CANNED AND DRY OIL SALAD CANOLA ZTF Food Costs 50000
84 82 POULTRY CHICKEN CVP WHL WOG NAE 3.5-4# Chicken/ Poultry Costs 51120
85 83 PAPER & DISP GLOVE NITRILE FDSRV PF BLU LRG Paper Costs 55000
86 84 CANNED AND DRY RICE BASMATI CHEF SECRT LG GRN Food Costs 50000
87 85 FROZEN APTZR VEG FALAFEL PUCK HALAL Food Costs Dry Goods Costs 50000 51500
88 86 PAPER & DISP LID PLAS PET FOR 32OZ BOWL Paper Costs 55000
89 87 PAPER & DISP FORK PLAS PP X-HVY BLK Paper Costs 55000
90 88 PRODUCE TOMATO ROMA JUMBO FRESH Produce Costs 51200
95 93 PRODUCE SPINACH BABY FRSH Produce Costs 51200
96 94 PRODUCE DILL BABY FRESH HERB Produce Costs 51200
97 95 PRODUCE CUCUMBER ENGLISH MED SEEDLESS Produce Costs 51200
98 96 CANNED AND DRY SODA ORANGE PORTOKALADA GREEK Food Costs Soft Beverage Cost 50000 52000
99 97 DAIRY PRODUCTS BUTTER SOLID USDA AA UNSLTD Dairy Costs 51300
100 98 DAIRY PRODUCTS CHEESE MONT JACK SLI INT .75OZ Dairy Costs 51300
101 99 DAIRY PRODUCTS CREAMER HALF & HALF SHF STBL Dairy Costs 51300
115 113 CANNED AND DRY FLOUR ALL PURP H&R BL EN MT Food Costs 50000
116 114 CANNED AND DRY JAM STRAWBERRY CUP Food Costs 50000
117 115 CANNED AND DRY MARMALADE ORANGE CUP Food Costs 50000
118 116 CANNED AND DRY SALT GRANULATED PLAIN Food Costs Dry Goods Costs 50000 51500
119 117 CANNED AND DRY SAUCE HOT PEPPER CALIFN STYLE Food Costs 50000
120 118 CANNED AND DRY SAUCE STEAK GLASS Food Costs 50000
121 119 CANNED AND DRY SHORTENING PAN & GRILL Food Costs 50000
122 120 CANNED AND DRY SUGAR GRANULATED XFINE CANE Food Costs Dry Goods Costs 50000 51500
123 121 CANNED AND DRY SYRUP BREAKFAST CUP Food Costs 50000
124 122 CANNED AND DRY SYRUP PANCAKE & WAFFLE Food Costs 50000
125 123 PAPER & DISP BAG PLAS TSHRT 11.5X6.5X21 TKU Paper Costs 55000
126 124 PAPER & DISP CONTAINER PLAS DELI TRANS W/LD Paper Costs 55000
127 125 PAPER & DISP CONTAINER PLAS HNG WHT 8.5 1C Paper Costs 55000
128 126 PAPER & DISP CUP PLAS PRTN TRANS 2OZ Paper Costs 55000
129 127 PAPER & DISP FOIL ALMN ROLL STD WGT 500FT Paper Costs Dry Goods Costs 55000 51500
130 128 PAPER & DISP LINER TRASH 40X46 1.6 ML BLK Paper Costs 55000
131 129 PAPER & DISP TOWEL MULTI 9.5X9.12 EARTH+ Paper Costs 55000
132 130 PRODUCE ASPARAGUS FRESH LARGE FX Produce Costs 51200
155 153 DAIRY PRODUCTS MILK WHL CORRUGATE Dairy Costs 51300
156 154 DAIRY PRODUCTS BUTTERMILK 1% HG Dairy Costs 51300
157 155 DAIRY PRODUCTS CHEESE CHDR MLD SLI INT .75 YL Dairy Costs 51300
158 156 CHEMICAL/JANTRL BLEACH LIQ GRMCDL ULTRA 6% Food Costs Cleaning Supplies 50000 74100
159 157 POULTRY TURKEY BRST NAT BRN PAN SKON Poultry Costs 51120
160 158 DAIRY PRODUCTS CREAM HEAVY 40% FRESH HG Dairy Costs 51300
161 159 MEATS BACON SHINGLE 10/12 HY GF PR12 Beef/Pork Costs 51110
194 192 PAPER & DISP BOWL PAPER MOLDED FIBER 32OZ Paper Costs 55000
195 193 PAPER & DISP BOX CORR CATER #1 LOGO 2021 Paper Costs 55000
196 194 DISPENSER BEVRG SYRUP LEMONADE BIB Soft Beverage Costs 52000
197 195 PAPER & DISP FOIL ALMN ROLL HVY WGT 500 FT Paper Costs Dry Goods Costs 55000 51500
198 196 DAIRY PRODUCTS CHEESE FETA CHUNKS IN BRNE Dairy Costs 51300
199 197 POULTRY CHICKEN CVP WOG WHL HAL Chicken/ Poultry Costs 51120
200 198 PAPER & DISP CONTAINER MFPP 1C HNG 9X6 WHT Paper Costs 55000
203 201 DISPENSER BEVRG SYRUP LEMON LIME BIB Soft Beverage Costs 52000
204 202 DAIRY PRODUCTS CHEESE FETA PAIL Dairy Costs 51300
205 203 CANNED AND DRY CHGS FOR MINIMUM ORDER Food Costs 50000
206 204 CANNED AND DRY BREAD CRUMB PLAIN MED Food Costs Dry Goods Costs 50000 51500
207 205 CANNED AND DRY DRESSING SALAD PRASINI Food Costs Dressing & Sauce Cost 50000 51450
208 206 CANNED AND DRY OIL CORN Food Costs Dressing & Sauce Cost 50000 51450
209 207 CANNED AND DRY OLIVE KALAMATA PTD BRNE 22 LB Food Costs Produce Costs 50000 51200
210 208 CANNED AND DRY SPICE TURMERIC GROUND Food Costs Dry Goods Costs 50000 51500
211 209 PRODUCE SQUASH ZUCCHINI MEDIUM FRESH Produce Costs 51200
212 210 PRODUCE ONION GREEN ICELS ROOTLESS Produce Costs 51200
213 211 CANNED AND DRY SODA COLA PEPSI ZERO Soft Beverage Costs 52000
219 217 FROZEN BAKLAVA GREEK PASTRY Food Costs 50000
220 218 DISPENSER BEVRG SYRUP DR PPR DIET BIB Soft Beverage Costs 52000
221 219 DISPENSER BEVRG SYRUP MOUNTAIN DEW BIB Soft Beverage Costs 52000
222 220 CANNED AND DRY SAUCE CHILI HOT SRIRACHA Food Costs Dry Goods Costs 50000 51500
223 221 PAPER & DISP SKEWER BAMBOO 10IN Paper Costs 55000
224 222 CANNED AND DRY RICE BASMATI Food Costs 50000
225 223 PAPER & DISP WRAP PAPER 14X14 LOGO Paper Costs 55000
227 225 MEATS PORK BUTT BNLS VP PR12 Beef/Pork Costs 51110
228 226 DAIRY PRODUCTS YOGURT PLAIN GREEK NONFAT Dairy Costs 51300
229 227 PAPER & DISP TONG PLAS 9 BLK SNAP N SERVE Paper Costs 55000
230 228 CANNED AND DRY SAUCE HOT SRIRACHA Food Costs Dry Goods Costs 50000 51500
231 229 DISPENSER BEVRG SYRUP DR PEPPER BIB Soft Beverage Costs 52000
232 230 HLTHCAR/HOSPITALITY BILLING MISC REGULAR Food Costs 50000
233 231 PAPER & DISP FORK PLAS BLK MEDHVY MDLNGTH Paper Costs 55000
234 232 DAIRY PRODUCTS YOGURT PLAIN ORIGINAL FTFR Dairy Costs 51300
235 233 SUPP & EQUIP MOP HEAD BLND LPD ALL PURP LRG Food Costs Cleaning Supplies 50000 74100
236 234 PAPER & DISP SPOON PLAS WHT MEDHVY MDLNGTH Paper Costs 55000
237 235 MEATS BEEF GROUND BULK NAT 80/20 Beef/Pork Costs 51110
238 236 PAPER & DISP CONTAINER PAPER HNG 9X6 PFF Paper Costs 55000
245 243 PRODUCE CUCUMBER ENGLISH FRSH Produce Costs 51200
246 244 PAPER & DISP FORK PLAS WHT MED HVY MDLNGTH Paper Costs 55000
247 245 PAPER & DISP CUP PLAS 12-14OZ CLR STRT WALL Paper Costs 55000
248 246 CANNED AND DRY SAUCE HOT BOTTLE Food Costs Dry Goods Costs 50000 51500
249 247 CANNED AND DRY OIL OLIVE BLEND 80/20 Food Costs Dry Goods Costs 50000 51500
250 248 CANNED AND DRY SPICE OREGANO LEAF RUBBED Food Costs Dry Goods Costs 50000 51500
251 249 DAIRY PRODUCTS YOGURT VANILLA GREEK NFAT Dairy Costs 51300
252 250 FROZEN BUN BRIOCHE HOMESTYLE 4 Food Costs 50000
253 251 CANNED AND DRY WATER SPRKLG IMPRTD MNERAL GLS Food Costs 50000
260 258 CANNED AND DRY PEPPER GREEN CHILI WHL Food Costs 50000
261 259 CANNED AND DRY PEPPER JALAPENO SLI FIELD RUN Food Costs 50000
262 260 CANNED AND DRY SAUCE MIX HOLLANDAISE GF Food Costs 50000
263 261 CANNED AND DRY VINEGAR DISTILLED WHITE 5% Food Costs Dry Goods Costs 50000 51500
264 262 PAPER & DISP COVER TOILET SEAT Paper Costs 55000
265 263 PAPER & DISP FILM PVC 2000FT ROLL Paper Costs 55000
266 264 PAPER & DISP DOILY PAPER NRMDY LACE 6 Paper Costs 55000
267 265 PAPER & DISP KIT CUTLERY MED PP KFS S&P NAP Paper Costs 55000
268 266 PAPER & DISP NAPKIN DNR 2P 15X16.25 1/8F WH Paper Costs 55000
269 267 CHEMICAL/JANTRL DETERGENT POT/PAN LIQ PINK RTU Food Costs Cleaning Supplies 50000 74100
270 268 CHEMICAL/JANTRL SALT GRANULE SOLAR WATER SOFT Food Costs 50000
271 269 PRODUCE CARROT FRESH JUMBO Produce Costs 51200
272 270 PRODUCE LIME FRESH 200CT Produce Costs 51200
273 271 PAPER & DISP CUP PLAS RPET CLR 16 OZ Paper Costs 55000
274 272 CANNED AND DRY SAUCE HOT Food Costs Dry Goods Costs 50000 51500
275 273 MEATS BACON SHINGLE 10/12 AW GF PR12 Beef/Pork Costs 51110
276 274 PAPER & DISP CRAYON RED BLUE YEL GREEN Paper Costs 55000
277 275 DAIRY PRODUCTS CREAMER HALF AND HALF PC ASEP Dairy Costs 51300
282 280 CANNED AND DRY KETCHUP SQUEEZE UPSIDE DOWN Food Costs 50000
283 281 PAPER & DISP GLOVE NITRILE FDSRV PF BLK LRG Paper Costs 55000
284 282 MEATS BEEF GRND CHUCK 81/19 CHUB FRS Beef/Pork Costs 51110
285 283 PAPER & DISP LID FOIL F/FULL STM TBL PAN Paper Costs Dry Goods Costs 55000 51500
286 284 PAPER & DISP FORK WOODEN DISP Paper Costs 55000
287 285 PAPER & DISP SKEWER BAMBOO THIN 8 IN Paper Costs 55000
288 286 PAPER & DISP WRAP DELI WHT 12X12 GRS RESIST Paper Costs 55000
292 290 PAPER & DISP TRAY FOOD PAPER #2 LOGO Paper Costs 55000
293 291 PAPER & DISP LID PLAS CLR F/1.5-2.5OZ PRTN Paper Costs 55000
294 292 DISPENSER BEVRG SYRUP TEA RASP 5X1 BRISK Soft Beverage Costs 52000
295 293 PAPER & DISP PAN FOIL STM TBL FULL DP 3-3/8 Paper Costs Dry Goods Costs 55000 51500
296 294 DISPENSER BEVRG SYRUP ROOT BEER BIB Soft Beverage Costs 52000
297 295 PAPER & DISP FOIL ALMN ROLL HVY WGT 1000 FT Paper Costs Dry Goods Costs 55000 51500
298 296 DAIRY PRODUCTS CHEESE GORGONZOLA WHEEL HALF Dairy Costs 51300
299 297 DAIRY PRODUCTS CHEESE MOZZ WM SHRED GOLD PREM Dairy Costs 51300
300 298 DAIRY PRODUCTS CREAM SOUR CULTRD GRADE A Dairy Costs 51300
311 309 SEAFOOD SCALLOP SEA WTR ADD 10/20 USA Seafood Costs 51130
312 310 MEATS BACON SLAB SLI 13/17 CT PR12 Beef/Pork Costs 51110
313 311 CANNED AND DRY DRESSING MIX RANCH Food Costs 50000
314 312 FROZEN BUN BRIOCHE HOMESTYLE 4.25 Food Costs Bread and Bun Costs 50000 51400
315 313 CANNED AND DRY SODA LEMON LIME Soft Beverage Costs 52000
316 314 FROZEN PUREE ORANGE BLOOD CONCENTRATE Food Costs 50000
317 315 CANNED AND DRY FLOUR HI-GLUTEN BL EN MT AA Dry Good Costs 51500
327 325 DAIRY PRODUCTS CHEESE FETA CHUNKS PAIL PREM Dairy Costs 51300
328 326 PRODUCE PEPPER GREEN BELL LARGE FRESH Produce Costs 51200
329 327 PRODUCE TOMATO ROMA FRSH Produce Costs 51200
330 328 CHEMICAL/JANTRL CLEANER DEGREASER OVEN RTU Food Costs Cleaning Supplies 50000 74100
331 329 PAPER & DISP BOX CORR CATER #4 LOGO 2021 Paper Costs 55000
332 330 PAPER & DISP LID PLAS HI DOME DESSRT Paper Costs 55000
333 331 MEATS PORK BUTT BNLS 1/4 6-9#EA Beef/Pork Costs 51110
334 332 PAPER & DISP CUP PAPER HOT WHT TALL 12OZ Paper Costs 55000
335 333 PAPER & DISP SPOON PLAS SOUP BLACK XHEAVY Paper Costs 55000
336 334 PAPER & DISP FOIL ALMN ROLL STD WGT 1000 FT Paper Costs Dry Goods Costs 55000 51500
337 335 SUPP & EQUIP PAD SCRUB STNLS 50GR 1.75OZ Food Costs Cleaning Supplies 50000 74100
338 336 DISPENSER BEVRG SYRUP TEA UNSWTD 5X1 Soft Beverage Costs 52000
339 337 FROZEN BUN BRIOCHE SPLIT TOP 4IN SLI Food Costs Bread and Bun Costs 50000 51400
340 338 CANNED AND DRY WATER MINERAL LIMONATA CAN Beverages Costs 52000
341 339 PRODUCE GARLIC PEELED CHINESE Produce Costs 51200
342 340 PAPER & DISP LID PLAS FLAT F/12-24Z PET CUP Paper Costs 55000
343 341 FROZEN BILLING MISC FROZEN Food Costs 50000
344 342 PAPER & DISP KNIFE PLAS BLK PLA COMPSTABLE Paper Costs 55000
345 343 PAPER & DISP GLOVE NITRILE MED Paper Costs 55000
346 344 SUPP & EQUIP GRILL BRICK 3.5IN THICK Food Costs Cleaning Supplies 50000 74100
347 345 PRODUCE CABBAGE SAVOY FRSH Produce Costs 51200
348 346 PRODUCE FLOWER ORCHID MULTI COLORED Produce Costs 51200
349 347 CANNED AND DRY OIL AVOCADO Food Costs 50000
393 391 PAPER & DISP TOWEL MULTIFOLD 9.4X9.2 WHT 1P Paper Costs 55000
394 392 PAPER & DISP TISSUE TOILET WRPD 4X3.8 2PLY Paper Costs 55000
395 393 MEATS BEEF GRND 80/20 BULK Beef/Pork Costs 51110
396 394 CANNED AND DRY MAYONNAISE HEAVY DUTY Food Costs Dressing & Sauce Cost 50000 51450
397 395 PAPER & DISP CONTAINER PLAS HNG 9X6 WHT Paper Costs 55000
398 396 PAPER & DISP SPOON PLASTIC BLK BAGGED Paper Costs 55000
399 397 PAPER & DISP FORK PLAS BLK HVY FULL LNGTH Paper Costs 55000
406 404 CANNED AND DRY BEAN BLACK Food Costs 50000
407 405 CANNED AND DRY SYRUP CHOCOLATE PLAS JUG Food Costs 50000
408 406 CANNED AND DRY TUNA LIGHT SKIPJACK CHUNK WTR Food Costs 50000
409 407 CHEMICAL/JANTRL DEGREASER HEAVY DUTY RTU Food Costs Cleaning Supplies 50000 74100
410 408 CANNED AND DRY JAM BLACKBERRY CUP Food Costs 50000
411 409 CANNED AND DRY PICKLE WHL DILL KO REF 75/85 Food Costs 50000
412 410 DAIRY PRODUCTS CHEESE QUESO FRESCO CASERO Dairy Costs 51300
423 421 PAPER & DISP CONTAINER PAPER HNG 9X6 FIB Paper Costs 55000
424 422 PAPER & DISP LINER REPRO 40X46 1.5 ML BLK Paper Costs 55000
425 423 PAPER & DISP TAPE PAPR REG THERMAL 3-1/8 Paper Costs 55000
426 424 SUPP & EQUIP PAD SCOUR GRN 6X9IN ANTIMICRO Food Costs Cleaning Supplies 50000 74100
427 425 PAPER & DISP GLOVE NITRILE BLUE XL Paper Costs 55000
428 426 PRODUCE CUCUMBER ENGLISH LONG Produce Costs 51200
429 427 PRODUCE TOMATO ROMA MED Produce Costs 51200
434 432 POULTRY CHICKEN CVP THIGH B/S HALAL JM Chicken/ Poultry Costs 51120
435 433 CANNED AND DRY DRESSING HONEY MUSTARD Food Costs 50000
436 434 PRODUCE JUICE LEMON PSTRZD ULTRA PREM Produce Costs 51200
437 435 CANNED AND DRY SPICE PAPRIKA GROUND Food Costs Dry Goods Costs 50000 51500
438 436 PAPER & DISP FORK PLAS WHT HVY FULL LENGTH Paper Costs 55000
439 437 PAPER & DISP LID FOIL F/ HALF STMTBL PAN Paper Costs Dry Goods Costs 55000 51500
440 438 SUPP & EQUIP PAN FOIL HALF DEEP 100CT Food Costs 50000
441 439 PAPER & DISP CONTAINER FOAM HNG LRG 1C Paper Costs 55000
442 440 PAPER & DISP LINER TRASH 40X48 13 MC NAT Paper Costs 55000
443 441 CANNED AND DRY KETCHUP FCY Food Costs Dry Goods Costs 50000 51500
444 442 PAPER & DISP STRAW PLAS WRPD FLEX WHT 7.625 Paper Costs 55000
445 443 CANNED AND DRY WATER SPRKLG CHRY/POMGRNT Food Costs 50000
446 444 PAPER & DISP APRON POLY EMBSD WHT 28X46 ECO Paper Costs 55000
457 455 SEAFOOD CALAMARI TUBE & TNT 5-8 INCH Seafood Costs 51130
458 456 SEAFOOD SHRIMP CKD & PLD BAY 90\150 FZ Seafood Costs 51130
459 457 SEAFOOD SHRIMP WHT GH 16-20 Seafood Costs 51130
460 458 SUPP & EQUIP BROOM ANGULAR FLAGGED Food Costs Cleaning Supplies 50000 74100
461 459 CANNED AND DRY SAUCE CHILI SRIRACHA Food Costs 50000
462 460 PAPER & DISP CONTAINER PAPER #1 TK OUT KRFT Paper Costs 55000
463 461 PAPER & DISP SPOON PLAS BLK MEDHVY MDLNGTH Paper Costs 55000
464 462 PAPER & DISP LID PLAS F/ 12/16/21/24 CUPS Paper Costs 55000
465 463 PAPER & DISP GLOVE NITRILE FDSRV PF BLU XL Paper Costs 55000
466 464 CANNED AND DRY SODA ORANGE Food Costs Soft Beverage Cost 50000 52000
467 465 PRODUCE SQUASH ZUCCHINI MED FRSH Produce Costs 51200
468 466 CANNED AND DRY SPICE TURMERIC GRND ORGANIC Food Costs 50000
469 467 CANNED AND DRY WATER SPRING IMPORTED GLS Food Costs 50000
474 472 PAPER & DISP GLOVE NITRILE BLK PEDRFREE LRG Paper Costs 55000
475 473 PAPER & DISP TRAY CARRYOUT 4CUP Paper Costs 55000
476 474 PAPER & DISP BAG PLAS T-SHRT THNKYOU12X7X22 Paper Costs 55000
477 475 PAPER & DISP GRILL BRICK 3.5IN THICK Paper Costs Cleaning Supplies 55000 74100
478 476 PAPER & DISP PAD SCOUR GRN 6X9IN ANTIMICRO Paper Costs Cleaning Supplies 55000 74100
479 477 PAPER & DISP PAD SCRUB STNLS 50GR 1.75OZ Paper Costs Cleaning Supplies 55000 74100
480 478 PAPER & DISP LID TOGO PLAS F/12-16-32 OZ Paper Costs 55000
481 479 PRODUCE PEPPER GREEN BELL FRSH LG Produce Costs 51200
482 480 DISPENSER BEVRG SYRUP COLA WILD CHERRY Soft Beverage Costs 52000
486 484 PRODUCE TOMATO ROMA FRESH Produce Costs 51200
487 485 DAIRY PRODUCTS YOGURT PLAIN GREEK WM 4% FAT Dairy Costs 51300
488 486 CHEMICAL/JANTRL CLEANER OVEN GREASESTRIP+ NP Food Costs 50000
489 487 FROZEN BUN BRIOCHE SLI 4.5 Food Costs Bread and Bun Costs 50000 51400
490 488 PRODUCE LEMON FRESH Produce Costs 51200
491 489 SUPP & EQUIP DISH SUPREME GLASS Food Costs 50000
492 490 PAPER & DISP CONTAINER POLYETHELYN Paper Costs 55000
493 491 PAPER & DISP FILTER GREASE CONE 10 IN Paper Costs Cleaning Supplies 55000 74100
494 492 PRODUCE SQUASH ZUCCHINI FCY FRESH Produce Costs 51200
495 493 CHEMICAL/JANTRL CLEANER ALL PURPOSE PINE RTU Food Costs Cleaning Supplies 50000 74100
496 494 CANNED AND DRY KETCHUP PACKET FCY FOIL Food Costs Dry Goods Costs 50000 51500
497 495 PRODUCE MUSHROOM PORTABELLA CP LRG FSH Produce Costs 51200
498 496 PAPER & DISP CONTAINER MINERAL 9X6 HNG 1CPT Paper Costs 55000
499 497 PAPER & DISP WRAP DRY WAX DELI HVY 10X10.75 Paper Costs 55000
500 498 PAPER & DISP LID PLAS 12/16/22 OZ CUP Paper Costs 55000
501 499 CHEMICAL/JANTRL POLISH S-S SATIN SHINE ARSL Food Costs Cleaning Supplies 50000 74100
502 500 CANNED AND DRY SPICE MARJORAM LVS Food Costs Dry Goods Costs 50000 51500
503 501 SUPP & EQUIP BOTTLE PLASTIC SQUEEZE WIDEMTH Food Costs Paperware Cost 50000 55000
504 502 PAPER & DISP PAN FOIL STM TBL DEEPXH 2-9/16 Paper Costs Dry Goods Costs 55000 51500
505 503 PAPER & DISP TONG PLAS BLK 6.25IN SM SRVING Paper Costs 55000
506 504 PAPER & DISP FORK PLAS WHT P/P Paper Costs 55000
507 505 CHEMICAL/JANTRL CLEANER DEGRSR HGH TMP GRL RTU Food Costs Cleaning Supplies 50000 74100
508 506 DISPENSER BEVRG SYRUP BASE ORG CRSH BIB Soft Beverage Costs 52000
509 507 PAPER & DISP CONTAINER PAPER #4 TAKEOUT WHT Paper Costs 55000
510 508 CANNED AND DRY CAPER NONPAREIL IMPORTED Food Costs 50000
515 513 PAPER & DISP FORK PLASTIC WRPD PP XHVY BLK Paper Costs 55000
516 514 MEATS LAMB RIBLET FZN Meats Costs 51110
517 515 CANNED AND DRY WATER BOTTLED Food Costs 50000
518 516 CHEMICAL/JANTRL SOAP HAND LIQ PINK RTU Food Costs Cleaning Supplies 50000 74100
519 517 PAPER & DISP LINER TRASH 40X46 1.5 ML BLU Paper Costs 55000
520 518 CANNED AND DRY SYSCO CUSTOMER AGREEMENT Food Costs 50000
521 519 CANNED AND DRY HONEY WILDFLOWER BLOSSOM Food Costs 50000
532 530 CANNED AND DRY JUICE APPLE GLASS FCY Food Costs 50000
533 531 DAIRY PRODUCTS MILK ALMOND BARISTA BLEND Dairy Costs 51300
534 532 CANNED AND DRY SPICE OREGANO LEAF Food Costs 50000
535 533 CANNED AND DRY SAUCE CHILI SRIRACHA CHA Food Costs Dry Goods Costs 50000 51500
536 534 CANNED AND DRY OLIVE KALAMATA PTD PLAS KEG Food Costs 50000
537 535 CANNED AND DRY MUSTARD YELLOW PRPD Food Costs 50000
538 536 CHEMICAL/JANTRL CLEANER DEGRSR GREASELIFT RTU Food Costs 50000
539 537 CANNED AND DRY SALT PKT .6 GM Food Costs 50000
540 538 CANNED AND DRY SPICE PEPPER PACKET .1 GM Food Costs Dry Goods Costs 50000 51500
541 539 PAPER & DISP LINER ROLL COMPOST47X60 1ML Paper Costs 55000
542 540 CANNED AND DRY WATER SPRKLG ORG ARANCAT CAN Food Costs 50000
543 541 DISPENSER BEVRG SYRUP COKE DIET 5X1 BIB Soft Beverage Costs 52000
564 562 CHEMICAL/JANTRL BLEACH LIQUID DISINFECT CLENER Food Costs 50000
565 563 PAPER & DISP CONTAINER PAPER FBR 9X6 1CPFF Paper Costs 55000
566 564 CANNED AND DRY SODA COKE MEXICO GLASS NON RET Soft Beverage Costs 52000
567 565 CANNED AND DRY SPICE CINNAMON STICK Food Costs Dry Goods Costs 50000 51500
568 566 CANNED AND DRY WALNUT HALVES AND PCS Food Costs 50000
569 567 DISPENSER BEVRG TEA ICED CONC RASP 5.5+1 Soft Beverage Costs 52000
570 568 MEATS BEEF GRND BULK 81/19 CHUB FRS Beef/Pork Costs 51110
658 656 PAPER & DISP CUP PLAS TRANS 16OZ SOFT Paper Costs 55000
659 657 PRODUCE PARSLEY BUNCH FDSVC Produce Costs 51200
660 658 PAPER & DISP LINER TRASH 24X32 .5 ML BLK Paper Costs 55000
661 659 CANNED AND DRY SPICE CINNAMON GRND Food Costs Dry Goods Costs 50000 51500
662 660 PAPER & DISP FORK PLAS HVY STY BLK Paper Costs 55000
663 661 PAPER & DISP SPOON PLAS PP HVY BLK FULL LEN Paper Costs 55000
664 662 DAIRY PRODUCTS EGG SHELL LG PAST CF Dairy Costs 51300
797 795 PAPER & DISP SUPPLY ACCESSORIES SOTF COM Paper Costs 55000
798 796 PAPER & DISP LID PLAS CLR FLT W/SLT 12-24OZ Paper Costs 55000
799 797 PAPER & DISP TRAY PAPER PULP CARRYOUT 4 CUP Paper Costs 55000
800 798 CHEMICAL/JANTRL SANITIZER OASIS 146 MULTI QUAT Food Costs Cleaning Supplies 50000 74100
801 799 PAPER & DISP CUP PLAS CLR TALL 8OZ RIGID Paper Costs 55000
802 800 PAPER & DISP BILLING MISC DISP Paper Costs 55000
803 801 PAPER & DISP LID FOIL F/ HALF STM TBL PAN Paper Costs 55000
816 814 CANNED AND DRY FLOUR SEMOLINA UNBLCH Food Costs 50000
817 815 CANNED AND DRY SAUCE HOT SRIRACHA BLUE AGAVE Food Costs 50000
818 816 PAPER & DISP LINER TRASH 43X48 16 MC NAT Paper Costs 55000
819 817 CANNED AND DRY WALNUT HALF & PCS Food Costs Produce Costs 50000 51200
820 818 MEATS BEEF SHORT RIB ASIAN CUT 1/4 Beef/Pork Costs 51110
821 819 PAPER & DISP LINER PLAS INSERT/WARMER 18X14 Paper Costs 55000
822 820 PAPER & DISP BOX PIZZA 14 W/K B-FLT 1-7/8 Paper Costs 55000
877 875 DAIRY PRODUCTS CHEESE CREAM LIGHT CUP Dairy Costs 51300
878 876 DAIRY PRODUCTS YOGURT BLUEBERRY GREEK NON FAT Dairy Costs 51300
879 877 HLTHCAR/HOSPITALITY PERKS MEMBERSHIP FEE Food Costs 50000
880 878 CANNED AND DRY DRESSING RED WINE VINGRT METRO Wine Costs Dressing & Sauce Cost 54400 51450
881 879 CANNED AND DRY SPICE GARLIC PWDR Food Costs 50000
882 880 CANNED AND DRY SPICE ONION POWDER Food Costs 50000
883 881 CANNED AND DRY SALT KOSHER FLAKE COARSE Food Costs 50000
925 923 CANNED AND DRY SAUCE WORCESTERSHIRE Food Costs 50000
926 924 PAPER & DISP CONTAINER PLAS 1C HNG 6X6 WHT Paper Costs 55000
927 925 PRODUCE PINEAPPLE FRESH Produce Costs 51200
928 926 SUPP & EQUIP MOP HEAD CTN CUT END VALUE #24 Food Costs Cleaning Supplies 50000 74100
929 927 DAIRY PRODUCTS CHEESE RICOTTA WMHM SEL Dairy Costs 51300
930 928 MEATS BACON LAYFLAT NT CC 13/17 PR12 Beef/Pork Costs 51110
931 929 CANNED AND DRY COOKIE CRUMB OREO MED CRUNCH Food Costs 50000
1005 1004 DAIRY PRODUCTS CHEESE BLUE STUFFED OLIVES Dairy Costs 51300
1006 1005 SUPP & EQUIP HANDLE MOP FIBRGLS QUICK CHNGE Food Costs 50000
1007 1006 SUPP & EQUIP SUPPLY SOTF JANSAN Food Costs 50000
1008 1007 CANNED AND DRY WALNUT HALVES & PCS Food Costs Produce Costs 50000 51200
1009 1008 CANNED AND DRY WATER SPARKLN ORG PRCKLY PEAR Beverages Costs 52000
1010 1009 CANNED AND DRY DRINK NATURAL CLMTN SPRKLG Food Costs 50000
1011 1010 CANNED AND DRY DRESSING MIX RNCH BTRMK NO MSG Food Costs 50000
1032 1031 CANNED AND DRY DRESSING BALSAMIC VINGT GARLIC Food Costs 50000
1033 1032 CANNED AND DRY SPREAD CHOC NUTELLA JAR FDSRV Food Costs 50000
1034 1033 CANNED AND DRY SUGAR BROWN LIGHT Food Costs 50000
1035 1034 PAPER & DISP PAD SCOUR 6X9 HVYDTY ANTIMICRO Paper Costs Cleaning Supplies 55000 74100
1036 1035 CHEMICAL/JANTRL DETERGENT POT/PAN LIQ GRN RTU Food Costs 50000
1037 1036 PAPER & DISP KNIFE PLAS HVY STY BLK Paper Costs 55000
1038 1037 CHEMICAL/JANTRL CLEANER DISINFECT PEROX RTU Food Costs 50000
1111 1110 PAPER & DISP BAG PAPER BRN W/HNDL REGAL 65# Paper Costs 55000
1112 1111 DAIRY PRODUCTS EGG SHELL WHT CAGEFREE GR A LG Dairy Costs 51300
1113 1112 CANNED AND DRY VINEGAR RICE SEASONED Food Costs 50000
1114 1113 CANNED AND DRY VINEGAR WHITE DSTD 5% Food Costs Dry Goods Costs 50000 51500
1115 1114 SEAFOOD SHRIMP WHT GH 13-15 Seafood Costs 51130
1116 1115 PAPER & DISP BAG PLAS PRTN 6.5X7 ORG SAT Paper Costs 55000
1117 1116 CANNED AND DRY BREAD CRUMB JAP PANKO TOASTED Food Costs 50000
1489 1488 POULTRY CHICKEN CVP WHL WOG FZ Chicken/ Poultry Costs 51120
1490 1489 PRODUCE LEEK BUNCH FRSH ICELS Produce Costs 51200
1491 1490 CANNED AND DRY BILLING MISC CANNED/DRY Food Costs 50000
1492 1491 PAPER & DISP PAN FOIL STEAM TBL HALF DEEP Paper Costs Dry Goods Costs 55000 51500
1493 1492 PAPER & DISP TRAY PAPER CARRIER 4 CUP Paper Costs 55000
1494 1493 PAPER & DISP KIT CUTLERY FKS/SP/NP HW PP BK Paper Costs 55000
1495 1494 MEATS BEEF PATTY 80/20 RND FRSH Beef/Pork Costs 51110
1498 1497 DAIRY PRODUCTS EGG HARDCOOKED CGFREE HARD PK Dairy Costs 51300
1499 1498 SEAFOOD SHRIMP WHT P&D TLOF 26/30 Seafood Costs 51130
1500 1499 PAPER & DISP CONTAINER PAPER HNG 9X6 1C FBR Paper Costs 55000
1501 1500 CHEMICAL/JANTRL DETERGENT POT & PAN LIQUID Food Costs Cleaning Supplies 50000 74100
1502 1501 FROZEN ASPARAGUS SPEAR MED IQF P Produce Costs 51200
1503 1502 PRODUCE ASPARAGUS FRESH STANDARD Produce Costs 51200
1504 1503 SUPP & EQUIP SCREEN GRIDDLE 4X6IN Food Costs 50000
1515 1514 PAPER & DISP GLOVE SYNTHETIC FDSRV PF MED Paper Costs 55000
1516 1515 POULTRY CHICKEN THIGH BNLS SKIN-ON RAW Chicken/ Poultry Costs 51120
1517 1516 CANNED AND DRY SPICE SAGE GRND Food Costs 50000
1518 1517 CANNED AND DRY KETCHUP SQUEEZE RED UPSIDE DWN Food Costs Dry Goods Costs 50000 51500
1519 1518 CANNED AND DRY SPICE NUTMEG WHL Food Costs 50000
1520 1519 DAIRY PRODUCTS YOGURT PLAIN FULL FAT Dairy Costs 51300
1521 1520 CANNED AND DRY VINEGAR WINE RED ITALY 6% GLS Wine Costs 54400
1555 1554 FROZEN BAKLAVA WALNT TRIANGLES Food Costs 50000
1556 1555 PAPER & DISP DISPENSER NAP XPRSNP STND BLK Paper Costs 55000
1557 1556 PAPER & DISP DISPENSER TOWEL MANUL COMP360 Paper Costs 55000
1558 1557 PAPER & DISP PAN FOIL STM TBL MED 2-3/16 Paper Costs Dry Goods Costs 55000 51500
1559 1558 PRODUCE ONION RED JUMBO CTN Produce Costs 51200
1560 1559 MEATS BEEF CHUCK SHORTRIB KOREAN1/2 Beef/Pork Costs 51110
1561 1560 FROZEN RICE MEXICAN STY Food Costs 50000
1653 1652 CANNED AND DRY SODA COKE CHERRY ZERO CONTOUR Soft Beverage Costs 52000
1654 1653 FROZEN ENTREE VEG FALAFEL BALLS VEGAN Food Costs 50000
1655 1654 SUPP & EQUIP BRUSH GRILL W/SCRPR 27 IN HNDL Food Costs 50000
1656 1655 CANNED AND DRY SAUCE HOT SRIRACHA HUY FONG Food Costs Dry Goods Costs 50000 51500
1657 1656 PAPER & DISP TOWEL MULTIFOLD PRM LEAF Paper Costs 55000
1658 1657 CANNED AND DRY SODA LEMON LIME 12OZ Soft Beverage Costs 52000
1659 1658 PAPER & DISP KNIFE PLAS WRP BLK Paper Costs 55000
1709 1708 PAPER & DISP CONTAINER PLAS CLR HNG 8IN Paper Costs 55000
1710 1709 PAPER & DISP TISSUE TOILET 2PL ADVC WHT WR Paper Costs 55000
1711 1710 SUPP & EQUIP SPATULA RUBBER SILICONE 10.25 Food Costs 50000
1712 1711 CANNED AND DRY BEAN GARBANZO FCY NO SULFITE Food Costs Dry Goods Costs 50000 51500
1713 1712 MEATS BACON SLI APLWD 13/17CT PR12 Beef/Pork Costs 51110
1714 1713 POULTRY SAUSAGE CHICKEN APPLE RAW 1 OZ Poultry Costs 51120
1715 1714 CANNED AND DRY TOMATO SUNDRIED JULENNE Food Costs 50000
1759 1758 MEATS PORK BELLY SKIN ON P12 COV Beef/Pork Costs 51110
1760 1759 MEATS PORK SHANK BONE KUROBUTA PR12 Beef/Pork Costs 51110
1761 1760 CANNED AND DRY SEASONING ITALIAN WHL Food Costs 50000
1762 1761 PRODUCE MUSHROOM PORTABELLA CAP 4-5 Produce Costs 51200
1763 1762 PAPER & DISP BAG PAPER 250 CT Paper Costs 55000
1764 1763 MEATS BEEF SHLDR TERES MAJOR SEL Beef/Pork Costs 51110
1765 1764 PAPER & DISP BOWL PLASTIC COATING 42 OZ Paper Costs 55000
1766 1765 PAPER & DISP BOX CATERING 21X13X4.25 LOGO Paper Costs 55000
1767 1766 CANNED AND DRY CANDY MILK CHOC SHELLS Food Costs Dry Goods Costs 50000 51500
1768 1767 CANNED AND DRY CHOCOLATE DUBAI PISTCHO KUNFEH Food Costs Dry Goods Costs 50000 51500
1769 1768 PAPER & DISP CONTAINER PAPER 1/30 OZ NTG Paper Costs 55000
1770 1769 PAPER & DISP CONTAINER PAPER 4/110OZ NTG Paper Costs 55000
1771 1770 PAPER & DISP CUP PAPER COLD 22 OZ LOGO NTG Paper Costs 55000
1772 1771 PAPER & DISP CUP PORTION PLAS CLR 1.50 OZ Paper Costs 55000
1773 1772 CANNED AND DRY PAPER & DISP DESSERT CUP Food Costs Paper Costs 50000 55000
1774 1773 FROZEN DESSERT MINI PLAIN BEIGNET Food Costs Bread and Bun Costs 50000 51400
1775 1774 CANNED AND DRY DIP GARLIC TOUM Food Costs Dressing & Sauce Cost 50000 51450
1776 1775 CANNED AND DRY DRINK ENERGY ORANGE SPRKLNG Soft Beverage Costs 52000
1777 1776 CANNED AND DRY DRINK ENERGY PEACH VIBE SPRKLG Soft Beverage Costs 52000
1778 1777 CANNED AND DRY DRINK ENERGY TROPICAL VIBE Soft Beverage Costs 52000
1779 1778 PAPER & DISP FILM PVC 18X2000 ROLL Paper Costs 55000
1780 1779 CANNED AND DRY JUICE CONC MANDARIN CARDAMOM Food Costs Soft Beverage Cost 50000 52000
1781 1780 CANNED AND DRY JUICE CONC STRAWB DRAGON Food Costs Soft Beverage Cost 50000 52000
1782 1781 PAPER & DISP LID CLEAR PET 42 OZ Paper Costs 55000
1783 1782 PAPER & DISP LID DOME DESSERT CUP Paper Costs 55000
1784 1783 PAPER & DISP NAPKIN 2PLY INTR FOLD 6.3X8.26 Paper Costs 55000
1785 1784 CANNED AND DRY PASTE HERB HARISSA MOROCCAN Food Costs Dressing & Sauce Cost 50000 51450
1786 1785 CANNED AND DRY PASTE TAHINI DRESSING Food Costs Dressing & Sauce Cost 50000 51450
1787 1786 FROZEN PASTRY BEIGNET MN FLD CHOCCRML Food Costs Bread and Bun Costs 50000 51400
1788 1787 CANNED AND DRY PEPPER BANANA MILD RING Food Costs Produce Costs 50000 51200
1789 1788 CANNED AND DRY RICE MIX NICKS Food Costs Dry Goods Costs 50000 51500
1790 1789 CANNED AND DRY SODA CHERRY VISSINADA GREEK Soft Beverage Costs 52000
1791 1790 CANNED AND DRY SODA COLA PEPSI ZERO SUGAR Soft Beverage Costs 52000
1792 1791 CANNED AND DRY SODA PEPSI COLA Soft Beverage Costs 52000
1793 1792 FROZEN SPANAKOPITA SPINACH COOKED Food Costs Bread and Bun Costs 50000 51400
1794 1793 PAPER & DISP SPOON PLAS TEA PP X-HVY BLK Paper Costs 55000
1795 1794 PAPER & DISP WRAP PAPER 14X14 LOGO VER2 Paper Costs 55000
1796 1795 DAIRY PRODUCTS YOGURT FRZN NF NICK THE GREEK Dairy Costs 51300
1797 1796 FROZEN BALL FALAFEL FRTTR 1 OZ IQF Dry Goods Costs 51500
1798 1797 SUPP & EQUIP BASKET PLAS 10.5X7X1.5 BLK Paperware Cost 55000
1799 1798 CANNED AND DRY BEAN GARBANZO LOW SODIUM Dry Goods Costs 51500
1800 1799 FROZEN BREAD POTATO ROLL 4 INCH Bread and Bun Costs 51400
1801 1800 FROZEN BUN HAMBURGER 4IN 1.75 OZ Bread and Bun Costs 51400
1802 1801 DAIRY PRODUCTS CHEESE FETA CRUMBLES Dairy Costs 51300
1803 1802 FROZEN CHEESE STICK HALLOUMI STYL Dairy Costs 51300
1804 1803 POULTRY CHICKEN CVP THGH B/S HALAL Chicken/ Poultry Costs 51120
1805 1804 CHEMICAL/JANTRL CLEANER DEGREASER CONCENTR RTU Cleaning Supplies 74100
1806 1805 CHEMICAL/JANTRL CLEANER DEGREASER GRSELFT RTU Cleaning Supplies 74100
1807 1806 CONTAINER PAPER CUSTOM LOGO8X5 Paperware Cost 55000
1808 1807 CONTAINER PAPER FBR 9X6 1C WHT Paperware Cost 55000
1809 1808 DRESSING RANCH SPICY Dairy Costs 51300
1810 1809 FILM PVC ROLL CRYS 2000 FT Paperware Cost 55000
1811 1810 FORK PLASTIC SERVING BLK 10IN Paperware Cost 55000
1812 1811 DAIRY PRODUCTS ICE CREAM COOKIE&CREAM Dairy Costs 51300
1813 1812 DAIRY PRODUCTS ICE CREAM STRAWBERRY Dairy Costs 51300
1814 1813 DAIRY PRODUCTS ICE CREAM VAN QUICK BLEND Dairy Costs 51300
1815 1814 DISPENSER BEVRG JUICE CONC BERRY PATCH ORG Soft Beverage Cost 52000
1816 1815 CANNED AND DRY JUICE LEMON PLAS RTU Produce Costs 51200
1817 1816 PRODUCE KALE CHOPPED Produce Costs 51200
1818 1817 PRODUCE KALE FRESH Produce Costs 51200
1819 1818 CANNED AND DRY KETCHUP FCY POUCH EQUALS 6/10# Dry Goods Costs 51500
1820 1819 PRODUCE LEMON FRESH BAGGED Produce Costs 51200
1821 1820 CANNED AND DRY OIL OLIVE SOYBEAN BLEND 75/25 Dry Goods Costs 51500
1822 1821 PRODUCE ONION WHITE JUMBO BAG Produce Costs 51200
1823 1822 CANNED AND DRY PEPPER BANANA MILD RING 7-9HUN Produce Costs 51200
1824 1823 CANNED AND DRY PEPPER BANANA RING Produce Costs 51200
1825 1824 FROZEN POTATO FRY 1/4 SS XLF PHANTM Produce Costs 51200
1826 1825 SANITIZER NO RINSE QUORUM Cleaning Supplies 74100
1827 1826 CANNED AND DRY SODA ROOT BEER CUBE Soft Beverage Cost 52000
1828 1827 DISPENSER BEVRG SYRUP FRUIT PUNCH BIB FLASHIN Soft Beverage Cost 52000
1829 1828 DISPENSER BEVRG SYRUP ORANGE 5X1 BIB Soft Beverage Cost 52000
1830 1829 PRODUCE TOMATO BULK 5X5 FRSH Produce Costs 51200
1831 1830 PRODUCE TOMATO GRAPE FRSH Produce Costs 51200

View 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,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
1 d
2 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
3 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
4 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
5 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
6 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
7 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
8 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
9 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
10 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
11 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
12 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
13 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
14 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
15 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
16 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
17 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
18 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
19 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
20 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
21 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
22 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
23 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
24 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
25 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
26 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
27 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
28 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
29 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
30 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
31 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
32 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
33 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
34 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
35 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
36 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
37 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
38 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
39 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
40 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
41 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
42 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
43 EEK 050 00175469 850081745 SUM 40 0 0 74 0 4625.36 6.25 00000463161

View 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)))

View 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.
)

View File

@@ -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))))

View File

@@ -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

View File

@@ -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]}}

View File

@@ -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 []))
[]))

View File

@@ -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"))))

View File

@@ -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

View File

@@ -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"

View File

@@ -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"}

View File

@@ -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})}
" "

View File

@@ -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)

View File

@@ -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]"

View File

@@ -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))

View File

@@ -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)))

View File

@@ -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")]])])

View File

@@ -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"}

View File

@@ -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

View File

@@ -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

View File

@@ -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"}]

View File

@@ -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}))"}

View File

@@ -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"

View File

@@ -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"})))))

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -109,6 +109,33 @@
:placeholder "e.g., ABC-456"
: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"
@@ -182,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)
@@ -261,9 +319,7 @@
'[(<= ?c ?to-numeric-code)]]}
: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]
@@ -455,6 +511,9 @@
[: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]]
@@ -482,10 +541,26 @@
(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}))
@@ -512,7 +587,9 @@
:title (fn [r]
(str
(some-> r :route-params :status name str/capitalize (str " "))
"Register"))
(if (:external? (:route-params r))
"External Register"
"Register")))
:entity-name "register"
:route ::route/table
:csv-route ::route/csv
@@ -579,6 +656,12 @@
: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"
@@ -613,14 +696,15 @@
: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")}
(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)
::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))})

View 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)))))))

View File

@@ -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

View File

@@ -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"}]))))))}]}))

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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)

View File

@@ -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"

View File

@@ -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]

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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)}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)}

View File

@@ -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}

View File

@@ -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

View 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"))))

View File

@@ -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]}

View File

@@ -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)))))))

View 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")))))))

View 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))))))

View File

@@ -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))))))))))))

View File

@@ -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))))