Compare commits
19 Commits
ddbb6abc3a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| fc54b92ddb | |||
| 019a1b4cd8 | |||
| 38575aa5bd | |||
| 08b948c24b | |||
| 83a739ac5b | |||
| 2c8985203e | |||
| 66b0b611e4 | |||
| baef2afc63 | |||
| a156ac99fe | |||
| de1c154706 | |||
| 31179278e4 | |||
|
|
455cec7828 | ||
| aeb7891efa | |||
|
|
1b2e2e4da7 | ||
| cc31d8849b | |||
|
|
bd82f555c2 | ||
| a78c818270 | |||
|
|
ec5e4e2e1d | ||
|
|
04bc7cae78 |
1
.envrc
1
.envrc
@@ -1 +1,2 @@
|
||||
export OPENROUTER_API_KEY=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
|
||||
export AWS_PROFILE=integreat
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
---
|
||||
title: Complete Automatic Sales Summary Calculations and Ledger Posting
|
||||
type: feat
|
||||
status: completed
|
||||
date: 2026-04-24
|
||||
---
|
||||
|
||||
# Complete Automatic Sales Summary Calculations and Ledger Posting
|
||||
|
||||
## What's Incomplete
|
||||
- **Automatic Totals**: Aggregate attributes (e.g., `:sales-summary/total-card-payments`) are not calculated/stored by the job.
|
||||
- **Data Persistence**: Automatic recalculations risk overwriting manual user adjustments.
|
||||
- **Automation Gap**: Ledger entries are currently imported from external Excel files rather than generated automatically from the summaries.
|
||||
- **UI Polish**: "Clientization" and HTMX context (`client-id`) TODOs remain in the admin interface.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
...
|
||||
|
||||
|
||||
This plan completes the implementation of automatic sales summary calculations and ensures they are correctly posted to the ledger. Currently, the `sales-summaries-v2` job calculates detailed daily summaries, but it doesn't store aggregate totals, preserve manual adjustments, or trigger the creation of actual ledger entries. Additionally, the admin UI has several unresolved TODOs.
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
The system currently aggregates raw sales data into a `sales-summary` entity, but the final step—creating balanced journal entries for the general ledger—is a manual process involving external Excel calculations and subsequent imports. This creates a dependency on external tools and increases the risk of data entry errors. The goal is to automate this pipeline entirely within the product.
|
||||
|
||||
---
|
||||
|
||||
## Requirements Trace
|
||||
|
||||
- R1. Calculate and store aggregate totals (e.g., `:sales-summary/total-card-payments`) on the `sales-summary` entity.
|
||||
- R2. Preserve user-made manual adjustments (`:sales-summary-item/manual? true`) during automatic recalculations.
|
||||
- R3. Aggregate detailed `sales-summary-item`s into balanced `journal-entry` lines by account and location.
|
||||
- R4. Automate the posting of these aggregated totals to the ledger.
|
||||
- R5. Resolve UI TODOs in the Sales Summaries admin page, specifically regarding client-scoping ("clientize") and HTMX context (`client-id`).
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- **In-Scope**:
|
||||
- Enhancements to the `sales-summaries-v2` job.
|
||||
- Implementation of the summary-to-ledger aggregation and posting logic.
|
||||
- Cleanup of the Sales Summaries admin UI.
|
||||
- **Out-of-Scope**:
|
||||
- Changing the fundamental calculation logic for sales orders/refunds.
|
||||
- Creating new ledger accounts (assume existing account mapping is sufficient).
|
||||
- Changing the naming of refunds/returns (user requested to keep as is).
|
||||
|
||||
---
|
||||
|
||||
## Context & Research
|
||||
|
||||
### Relevant Code and Patterns
|
||||
|
||||
- **Jobs**: `src/clj/auto_ap/jobs/sales_summaries.clj` contains the main calculation logic.
|
||||
- **UI**: `src/clj/auto_ap/ssr/admin/sales_summaries.clj` implements the admin interface.
|
||||
- **Ledger Posting**: `src/clj/auto_ap/ledger.clj` and `iol_ion/src/iol_ion/tx/upsert_ledger.clj` handle journal entry creation.
|
||||
- **Reconciliation Pattern**: `reconcile-ledger` in `src/clj/auto_ap/ledger.clj` shows how to find missing ledger entries and trigger their creation.
|
||||
|
||||
### Institutional Learnings
|
||||
|
||||
- No existing documented patterns for sales summary posting were found in `docs/solutions/`. This implementation will establish the pattern.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **Detailed Summary $\to$ Aggregated Ledger**: The `sales-summary` will maintain granular detail (line items, specific fee types), but the ledger posting will aggregate these items by account and location to create balanced `journal-entry` lines.
|
||||
- **Automatic Posting**: Posting to the ledger will be integrated into the reconciliation process, similar to how invoices and transactions are handled in `reconcile-ledger`.
|
||||
- **Location Handling**: Since `sales-summary-item`s don't have a location, a default location for the client will be used for ledger posting.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved During Planning
|
||||
|
||||
- **Architectural Decision**: Use a detailed summary that aggregates into the ledger.
|
||||
- **Renaming**: Keep "Refunds/Returns" as is.
|
||||
|
||||
### Deferred to Implementation
|
||||
|
||||
- **Default Location Logic**: Exactly how the "default location" for a client is retrieved or defined.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
- U1. **Enhance `sales-summaries-v2` Job**
|
||||
|
||||
**Goal:** Ensure the job stores aggregate totals and preserves manual adjustments.
|
||||
|
||||
**Requirements:** R1, R2
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/jobs/sales_summaries.clj`
|
||||
|
||||
**Approach:**
|
||||
- Update `sales-summaries-v2` to calculate totals for attributes like `:sales-summary/total-card-payments`, `:sales-summary/total-cash-payments`, etc., based on the generated items.
|
||||
- Implement a merge strategy: when updating a summary, keep any items where `:sales-summary-item/manual?` is true, and only replace the automatically calculated items.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: Running the job for a client with sales and refunds results in a `sales-summary` with correct `:sales-summary/total-*` attributes.
|
||||
- Edge case: Running the job on a summary that already has a manual item ensures the manual item is not overwritten.
|
||||
|
||||
**Verification:**
|
||||
- Datomic query shows `sales-summary` entities have populated total attributes and preserved manual items.
|
||||
|
||||
---
|
||||
|
||||
- U2. **Implement Summary-to-Ledger Aggregation**
|
||||
|
||||
**Goal:** Create a function to transform detailed summary items into balanced ledger lines.
|
||||
|
||||
**Requirements:** R3
|
||||
|
||||
**Dependencies:** U1
|
||||
|
||||
**Files:**
|
||||
- Create: `src/clj/auto_ap/ledger/sales_summaries.clj` (or add to `src/clj/auto_ap/ledger.clj`)
|
||||
- Test: `test/clj/auto_ap/ledger_test.clj`
|
||||
|
||||
**Approach:**
|
||||
- Create a function `aggregate-summary-items` that:
|
||||
1. Groups `sales-summary-item`s by `:ledger-mapped/account`.
|
||||
2. Sums the `:ledger-mapped/amount` based on `:ledger-mapped/ledger-side` (debit vs credit).
|
||||
3. Assigns a location (default client location).
|
||||
4. Returns a list of `journal-entry-line` maps.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: A set of items with mixed accounts and sides aggregates into the correct number of ledger lines with summed amounts.
|
||||
- Edge case: Items with `nil` accounts are handled gracefully (e.g., mapped to an "Unknown" account or logged as error).
|
||||
|
||||
**Verification:**
|
||||
- Unit tests verify that a list of `sales-summary-item`s is correctly transformed into `journal-entry-line`s.
|
||||
|
||||
---
|
||||
|
||||
- U3. **Implement Automatic Ledger Posting for Summaries**
|
||||
|
||||
**Goal:** Ensure sales summaries trigger the creation of ledger entries.
|
||||
|
||||
**Requirements:** R4
|
||||
|
||||
**Dependencies:** U2
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ledger.clj`
|
||||
- Modify: `src/clj/auto_ap/jobs/sales_summaries.clj`
|
||||
|
||||
**Approach:**
|
||||
- Implement a `:upsert-sales-summary-ledger` transaction or function that takes a `sales-summary` and uses the aggregation logic from U2 to post to the ledger.
|
||||
- Integrate this into the `reconcile-ledger` function in `src/clj/auto_ap/ledger.clj` to find summaries missing ledger entries and post them.
|
||||
|
||||
**Test scenarios:**
|
||||
- Integration: Running `reconcile-ledger` identifies a `sales-summary` missing a `journal-entry` and creates a balanced `journal-entry` for it.
|
||||
- Happy path: The created `journal-entry` has the correct total amount and matches the summary totals.
|
||||
|
||||
**Verification:**
|
||||
- A `sales-summary` entity is linked to a `journal-entry` via `:journal-entry/original-entity`.
|
||||
|
||||
---
|
||||
|
||||
- U4. **Resolve UI TODOs in Sales Summaries Admin**
|
||||
|
||||
**Goal:** Fix client-scoping and HTMX context in the admin UI.
|
||||
|
||||
**Requirements:** R5
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/admin/sales_summaries.clj`
|
||||
|
||||
**Approach:**
|
||||
- Resolve "clientize" TODOs: Ensure the data pulled for the table and edit wizard is correctly scoped and transformed using client-specific context.
|
||||
- Fix HTMX `client-id` passing: Update the `new-summary-item` trigger to correctly pass the `client-id` via `hx-vals` from the form state.
|
||||
- Clean up any remaining schema TODOs in the SSR file.
|
||||
|
||||
**Test scenarios:**
|
||||
- Integration: Adding a new summary item in the UI correctly sends the `client-id` and the item is created for the correct client.
|
||||
- Happy path: The summary table displays correctly and "missing account" warnings appear only for items without a mapped account.
|
||||
|
||||
**Verification:**
|
||||
- Manual verification in the browser: New items are added correctly, and the UI is free of "missing account" red pills for mapped items.
|
||||
|
||||
---
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Interaction graph**: The `sales-summaries-v2` job now feeds into the ledger system via `reconcile-ledger`.
|
||||
- **Error propagation**: Failures in the aggregation logic will prevent the `journal-entry` from being created, which will be surfaced by `reconcile-ledger` as a missing entry.
|
||||
- **State lifecycle risks**: Ensuring that `manual?` items are not overwritten during automatic recalculation is critical to avoid losing user adjustments.
|
||||
- **Integration coverage**: Integration tests must cover the full flow: `sales-orders` $\to$ `sales-summary` $\to$ `journal-entry`.
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Overwriting manual adjustments | Implement explicit merge logic based on the `:sales-summary-item/manual?` flag. |
|
||||
| Unbalanced ledger entries | Use a strict aggregation function that ensures debits = credits for every posted summary. |
|
||||
| Missing location data | Implement a robust fallback to a default client location. |
|
||||
|
||||
---
|
||||
|
||||
## Sources & References
|
||||
|
||||
- Related code: `src/clj/auto_ap/jobs/sales_summaries.clj`
|
||||
- Related code: `src/clj/auto_ap/ssr/admin/sales_summaries.clj`
|
||||
- Related code: `src/clj/auto_ap/ledger.clj`
|
||||
- Related code: `iol_ion/src/iol_ion/tx/upsert_ledger.clj`
|
||||
613
docs/superpowers/plans/2026-05-18-inline-account-editing.md
Normal file
613
docs/superpowers/plans/2026-05-18-inline-account-editing.md
Normal file
@@ -0,0 +1,613 @@
|
||||
# Inline Account Editing Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the sales summary wizard's flat data-grid with a two-column debit/credit layout matching the embedded grid, and add HTMX-based inline account editing (click pencil → typeahead → confirm → swap back to display mode).
|
||||
|
||||
**Architecture:** Each item's account cell renders in "display mode" (account name + hidden input + pencil icon). Clicking the pencil fires an HTMX GET that swaps in a typeahead + confirm/cancel buttons. Confirm fires an HTMX PUT that swaps back to display mode with an updated hidden input. No DB writes until the wizard form is submitted.
|
||||
|
||||
**Tech Stack:** Clojure, Hiccup, HTMX, Alpine.js (for typeahead), form-cursor, multi-modal wizard middleware, Datomic.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add route keys to route definitions
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/cljc/auto_ap/routes/pos/sales_summaries.cljc`
|
||||
|
||||
- [ ] **Step 1: Add three new route keys**
|
||||
|
||||
Add these routes inside the existing `routes` map, alongside `"/edit/sales-summary-item"`:
|
||||
|
||||
```clojure
|
||||
"/edit/item-account" ::edit-item-account
|
||||
"/edit/save-item-account" ::save-item-account
|
||||
"/edit/cancel-item-account" ::cancel-item-account
|
||||
```
|
||||
|
||||
The full routes map should become:
|
||||
|
||||
```clojure
|
||||
(def routes {"" {:get ::page
|
||||
:put ::edit-wizard-submit}
|
||||
"/table" ::table
|
||||
["/" [#"\d+" :db/id]] {:get ::edit-wizard}
|
||||
"/edit/navigate" ::edit-wizard-navigate
|
||||
"/edit/sales-summary-item" ::new-summary-item
|
||||
"/edit/item-account" ::edit-item-account
|
||||
"/edit/save-item-account" ::save-item-account
|
||||
"/edit/cancel-item-account" ::cancel-item-account})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the route file parses**
|
||||
|
||||
Run: `clj -M:check` or similar. If no checker is available, move on — the Clojure compiler will catch errors at load time.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add account display cell helper and account edit cell helper
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
|
||||
|
||||
These are pure rendering functions — no routes, no handlers, just hiccup.
|
||||
|
||||
- [ ] **Step 1: Make `account-typeahead*` public**
|
||||
|
||||
Change `defn-` to `defn` for `account-typeahead*` so it can be used from the new handlers:
|
||||
|
||||
```clojure
|
||||
(defn account-typeahead*
|
||||
[{:keys [name value client-id]}]
|
||||
[:div.flex.flex-col
|
||||
(com/typeahead {:name name
|
||||
:placeholder "Search..."
|
||||
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
|
||||
{:client-id client-id
|
||||
:purpose "invoice"})
|
||||
:value value
|
||||
:content-fn (fn [value]
|
||||
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
|
||||
client-id)))})])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `account-display-cell` function**
|
||||
|
||||
This renders the display-mode account cell: account name (or "Missing acct" pill), hidden input, and pencil icon. Insert after the `truncate` defn:
|
||||
|
||||
```clojure
|
||||
(defn account-display-cell [{:keys [item field-name-prefix client-id]}]
|
||||
(let [account-id (:ledger-mapped/account item)
|
||||
account-name (when account-id
|
||||
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read account-id)
|
||||
client-id)))]
|
||||
[:div.flex.items-center.gap-2
|
||||
(com/hidden {:name (str field-name-prefix "[ledger-mapped/account]")
|
||||
:value (or account-id "")})
|
||||
(if account-id
|
||||
[:span.text-sm account-name]
|
||||
(com/pill {:color :red} "Missing acct"))
|
||||
(com/a-icon-button {:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-item-account)
|
||||
:hx-target "closest td"
|
||||
:hx-swap "innerHTML"
|
||||
:hx-vals (hx/json {:item-index (or (:item-index item) 0)
|
||||
:client-id client-id
|
||||
:current-account-id (or account-id "")})}
|
||||
svg/pencil)]))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `account-edit-cell` function**
|
||||
|
||||
This renders the edit-mode account cell: typeahead + confirm/cancel buttons. This is what `::route/edit-item-account` returns:
|
||||
|
||||
```clojure
|
||||
(defn account-edit-cell [{:keys [field-name-prefix client-id current-account-id]}]
|
||||
(let [account-input-name (str field-name-prefix "[ledger-mapped/account]")]
|
||||
[:div.flex.flex-col.gap-2
|
||||
(account-typeahead* {:name account-input-name
|
||||
:value current-account-id
|
||||
:client-id client-id})
|
||||
[:div.flex.gap-1
|
||||
(com/a-icon-button {:hx-put (bidi/path-for ssr-routes/only-routes ::route/save-item-account)
|
||||
:hx-target "closest td"
|
||||
:hx-swap "innerHTML"
|
||||
:hx-include "closest td"
|
||||
:hx-vals (hx/json {:field-name-prefix field-name-prefix
|
||||
:client-id client-id})}
|
||||
svg/check)
|
||||
(com/a-icon-button {:hx-get (bidi/path-for ssr-routes/only-routes ::route/cancel-item-account)
|
||||
:hx-target "closest td"
|
||||
:hx-swap "innerHTML"
|
||||
:hx-vals (hx/json {:field-name-prefix field-name-prefix
|
||||
:client-id client-id
|
||||
:current-account-id (or current-account-id "")})}
|
||||
svg/x)]]))
|
||||
```
|
||||
|
||||
**Note:** We construct the input name directly from `field-name-prefix` + `[ledger-mapped/account]` instead of using form-cursor, because the HTMX handler doesn't have access to the wizard's form state. The typeahead component accepts a `:name` string directly.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Verify svg/check exists
|
||||
|
||||
**Files:**
|
||||
- Check: `src/clj/auto_ap/ssr/svg.clj`
|
||||
|
||||
- [ ] **Step 1: Search for check icon**
|
||||
|
||||
Run: `rg "def.*check" src/clj/auto_ap/ssr/svg.clj`
|
||||
|
||||
If `svg/check` does not exist, look for alternatives like `svg/tick`, `svg/confirm`, or `svg/save`. If none exist, use `svg/pencil` with a different label, or use a simple `[:span "✓"]` instead.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Rewrite MainStep render-step to use two-column layout
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
|
||||
|
||||
This is the core UI change. Replace the flat data-grid in `render-step` with a two-column layout matching the embedded grid.
|
||||
|
||||
- [ ] **Step 1: Replace the MainStep record's render-step body**
|
||||
|
||||
Replace the existing `render-step` implementation in the `defrecord MainStep` with:
|
||||
|
||||
```clojure
|
||||
(render-step
|
||||
[this {:keys [multi-form-state] :as request}]
|
||||
(let [client-id (:db/id (:sales-summary/client (:snapshot multi-form-state)))
|
||||
items (sort-items (:sales-summary/items (:step-params multi-form-state)))
|
||||
debit-items (filter #(= :ledger-side/debit (:ledger-mapped/ledger-side %)) items)
|
||||
credit-items (filter #(= :ledger-side/credit (:ledger-mapped/ledger-side %)) items)
|
||||
max-rows (max (count debit-items) (count credit-items))
|
||||
padded-debits (concat debit-items (repeat (- max-rows (count debit-items)) nil))
|
||||
padded-credits (concat credit-items (repeat (- max-rows (count credit-items)) nil))]
|
||||
(mm/default-render-step
|
||||
linear-wizard this
|
||||
:head [:div.p-2 "Edit Summary"]
|
||||
:body (mm/default-step-body
|
||||
{}
|
||||
[:div
|
||||
(fc/with-field :db/id
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
[:div.grid.grid-cols-2.gap-4
|
||||
[:div
|
||||
[:div.font-semibold.text-sm.mb-2 "Debits"]
|
||||
[:div.space-y-1
|
||||
(for [[idx item] (map-indexed vector padded-debits)]
|
||||
(if item
|
||||
(let [manual? (:sales-summary-item/manual? item)]
|
||||
[:div.flex.items-center.gap-2.text-sm
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" idx "][sales-summary-item/category]")
|
||||
:value (:sales-summary-item/category item)})
|
||||
(when manual?
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" idx "][sales-summary-item/manual?]")
|
||||
:value "true"}))
|
||||
[:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)]
|
||||
(account-display-cell {:item (assoc item :item-index idx)
|
||||
:field-name-prefix (str "step-params[sales-summary/items][" idx "]")
|
||||
:client-id client-id})
|
||||
[:span.font-mono (format "$%,.2f" (:ledger-mapped/amount item))]])
|
||||
[:div.h-6]))]
|
||||
(summary-total-row* request)
|
||||
(unbalanced-row* request)]
|
||||
[:div
|
||||
[:div.font-semibold.text-sm.mb-2 "Credits"]
|
||||
[:div.space-y-1
|
||||
(for [[idx item] (map-indexed vector padded-credits)]
|
||||
(if item
|
||||
(let [actual-idx (+ (count debit-items) idx)
|
||||
manual? (:sales-summary-item/manual? item)]
|
||||
[:div.flex.items-center.gap-2.text-sm
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/category]")
|
||||
:value (:sales-summary-item/category item)})
|
||||
(when manual?
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
|
||||
:value "true"}))
|
||||
[:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)]
|
||||
(account-display-cell {:item (assoc item :item-index actual-idx)
|
||||
:field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]")
|
||||
:client-id client-id})
|
||||
[:span.font-mono (format "$%,.2f" (:ledger-mapped/amount item))]])
|
||||
[:div.h-6]))]
|
||||
(summary-total-row* request)
|
||||
(unbalanced-row* request)]]
|
||||
[:div.mt-4
|
||||
(fc/with-field :sales-summary/items
|
||||
(com/data-grid-new-row {:colspan 2
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-summary-item)
|
||||
:row-offset 0
|
||||
:index (count (fc/field-value))
|
||||
:tr-params {:hx-vals (hx/json {:client-id client-id})}}
|
||||
"New Summary Item"))]])
|
||||
|
||||
:footer
|
||||
(mm/default-step-footer linear-wizard this :validation-route ::route/edit-wizard-navigate)
|
||||
:validation-route ::route/edit-wizard-navigate
|
||||
:width-height-class "lg:w-[900px] lg:h-[600px]")))
|
||||
```
|
||||
|
||||
**Important note on item indexing:** The padded lists are for display alignment only. The hidden inputs must use the *actual* index in the `:sales-summary/items` vector, not the display index. Debit items keep their original indices; credit items' indices start after all debit items. This is a simplification — if items are interspersed (debit, credit, debit), this approach breaks. We need to compute actual indices from the sorted list, not from the filtered sublists. See Step 2.
|
||||
|
||||
- [ ] **Step 2: Fix index calculation to use actual sorted position**
|
||||
|
||||
The approach in Step 1 has an indexing bug. Items in the form are stored as a vector and submitted by index. We must preserve the actual vector index for each item. Replace the layout logic with:
|
||||
|
||||
```clojure
|
||||
(render-step
|
||||
[this {:keys [multi-form-state] :as request}]
|
||||
(let [client-id (:db/id (:sales-summary/client (:snapshot multi-form-state)))
|
||||
items (sort-items (:sales-summary/items (:step-params multi-form-state)))
|
||||
indexed-items (map-indexed vector items)
|
||||
debit-items (filter #(= :ledger-side/debit (:ledger-mapped/ledger-side (second %))) indexed-items)
|
||||
credit-items (filter #(= :ledger-side/credit (:ledger-mapped/ledger-side (second %))) indexed-items)
|
||||
max-rows (max (count debit-items) (count credit-items))
|
||||
padded-debits (concat debit-items (repeat (- max-rows (count debit-items)) nil))
|
||||
padded-credits (concat credit-items (repeat (- max-rows (count credit-items)) nil))]
|
||||
(mm/default-render-step
|
||||
linear-wizard this
|
||||
:head [:div.p-2 "Edit Summary"]
|
||||
:body (mm/default-step-body
|
||||
{}
|
||||
[:div
|
||||
(fc/with-field :db/id
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
[:div.grid.grid-cols-2.gap-4
|
||||
[:div
|
||||
[:div.font-semibold.text-sm.mb-2 "Debits"]
|
||||
[:div.space-y-1
|
||||
(for [[actual-idx item] padded-debits]
|
||||
(if item
|
||||
(let [manual? (:sales-summary-item/manual? item)]
|
||||
[:div.flex.items-center.gap-2.text-sm
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/category]")
|
||||
:value (:sales-summary-item/category item)})
|
||||
(when manual?
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
|
||||
:value "true"}))
|
||||
[:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)]
|
||||
(account-display-cell {:item (assoc item :item-index actual-idx)
|
||||
:field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]")
|
||||
:client-id client-id})
|
||||
[:span.font-mono (format "$%,.2f" (:ledger-mapped/amount item))]])
|
||||
[:div.h-6]))]
|
||||
[:div
|
||||
[:div.font-semibold.text-sm.mb-2 "Credits"]
|
||||
[:div.space-y-1
|
||||
(for [[actual-idx item] padded-credits]
|
||||
(if item
|
||||
(let [manual? (:sales-summary-item/manual? item)]
|
||||
[:div.flex.items-center.gap-2.text-sm
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/category]")
|
||||
:value (:sales-summary-item/category item)})
|
||||
(when manual?
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
|
||||
:value "true"}))
|
||||
[:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)]
|
||||
(account-display-cell {:item (assoc item :item-index actual-idx)
|
||||
:field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]")
|
||||
:client-id client-id})
|
||||
[:span.font-mono (format "$%,.2f" (:ledger-mapped/amount item))]])
|
||||
[:div.h-6]))]]]
|
||||
[:div.mt-4
|
||||
(fc/with-field :sales-summary/items
|
||||
(com/data-grid-new-row {:colspan 2
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-summary-item)
|
||||
:row-offset 0
|
||||
:index (count (fc/field-value))
|
||||
:tr-params {:hx-vals (hx/json {:client-id client-id})}}
|
||||
"New Summary Item"))]])
|
||||
|
||||
:footer
|
||||
(mm/default-step-footer linear-wizard this :validation-route ::route/edit-wizard-navigate)
|
||||
:validation-route ::route/edit-wizard-navigate
|
||||
:width-height-class "lg:w-[900px] lg:h-[600px]")))
|
||||
```
|
||||
|
||||
**Key design decisions:**
|
||||
- `map-indexed vector items` preserves actual vector position for hidden input names
|
||||
- `padded-debits` / `padded-credits` are sequences of `[actual-idx item]` or `nil` for padding rows
|
||||
- Padding rows render as empty `[:div.h-6]` to maintain alignment
|
||||
- Total/unbalanced rows are not repeated per column — they go below the two-column grid, shared
|
||||
|
||||
- [ ] **Step 3: Move total/unbalanced rows outside the two-column grid**
|
||||
|
||||
The `summary-total-row*` and `unbalanced-row*` functions currently render as `<tr>` elements inside a data-grid. In the new layout, these should be simple flex rows below the grid, not table rows. For now, keep them as-is but render them in a single section below both columns (remove the duplicate from the credit column). Adjust the `:body` content:
|
||||
|
||||
After the `[:div.grid.grid-cols-2.gap-4 ...]` block, add:
|
||||
|
||||
```clojure
|
||||
[:div.mt-2.border-t.pt-2
|
||||
(summary-total-row* request)
|
||||
(unbalanced-row* request)]
|
||||
```
|
||||
|
||||
But since `summary-total-row*` and `unbalanced-row*` currently return `<tr>` elements, they won't render correctly outside a table. For the initial implementation, replace them with inline hiccup that renders the same info in a flex layout. See Task 5.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Rewrite total and unbalanced display as non-table hiccup
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
|
||||
|
||||
The existing `summary-total-row*` and `unbalanced-row*` return `<tr>` / `<td>` elements for the data-grid. The new layout is not a table, so these need to be simple div-based layouts.
|
||||
|
||||
- [ ] **Step 1: Add `summary-total-display` function**
|
||||
|
||||
Insert after `unbalanced-row*`:
|
||||
|
||||
```clojure
|
||||
(defn summary-total-display [request]
|
||||
(let [total-credits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-credits))
|
||||
total-debits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-debits))]
|
||||
[:div.flex.justify-between.text-sm.py-1
|
||||
[:span.font-semibold "Total"]
|
||||
[:div.flex.gap-8
|
||||
[:span.font-mono (format "$%,.2f" total-debits)]
|
||||
[:span.font-mono (format "$%,.2f" total-credits)]]]))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `unbalanced-display` function**
|
||||
|
||||
```clojure
|
||||
(defn unbalanced-display [request]
|
||||
(let [total-credits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-credits))
|
||||
total-debits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-debits))
|
||||
delta (- total-debits total-credits)]
|
||||
(when-not (dollars-0? delta)
|
||||
[:div.flex.justify-between.text-sm.py-1
|
||||
[:span.font-semibold {:class (if (pos? delta) "text-red-600" "text-green-600")} "Unbalanced"]
|
||||
[:div.flex.gap-8
|
||||
[:span.font-mono (when (pos? delta) (format "$%,.2f" delta))]
|
||||
[:span.font-mono (when (neg? delta) (format "$%,.2f" (Math/abs delta)))]]]])))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Use these in the render-step body**
|
||||
|
||||
In Task 4's render-step, replace the total/unbalanced section at the bottom with:
|
||||
|
||||
```clojure
|
||||
[:div.mt-2.border-t.pt-2
|
||||
(summary-total-display request)
|
||||
(unbalanced-display request)]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Add `edit-item-account` handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
|
||||
|
||||
- [ ] **Step 1: Write the handler**
|
||||
|
||||
Insert before `key->handler`:
|
||||
|
||||
```clojure
|
||||
(defn edit-item-account [request]
|
||||
(let [{:keys [item-index client-id current-account-id]} (:query-params request)
|
||||
item-index (if (string? item-index) (Integer/parseInt item-index) item-index)
|
||||
field-name-prefix (str "step-params[sales-summary/items][" item-index "]")]
|
||||
(html-response
|
||||
(account-edit-cell {:field-name-prefix field-name-prefix
|
||||
:client-id (if (string? client-id) (Long/parseLong client-id) client-id)
|
||||
:current-account-id (when (and current-account-id
|
||||
(not= current-account-id ""))
|
||||
(if (string? current-account-id)
|
||||
(Long/parseLong current-account-id)
|
||||
current-account-id))}))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add it to key->handler**
|
||||
|
||||
Add to the handler map inside `key->handler`:
|
||||
|
||||
```clojure
|
||||
::route/edit-item-account (-> edit-item-account
|
||||
(wrap-schema-enforce :query-schema [:map
|
||||
[:item-index nat-int?]
|
||||
[:client-id {:optional true} [:maybe entity-id]]
|
||||
[:current-account-id {:optional true} [:maybe :string]]]))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Add `save-item-account` handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
|
||||
|
||||
- [ ] **Step 1: Write the handler**
|
||||
|
||||
This handler receives the typeahead's selected value via `hx-include "closest td"`, which includes the hidden input from the typeahead. The typeahead's hidden input name will be something like `step-params[sales-summary/items][2][ledger-mapped/account]`. We need to extract the selected account ID from the form params and return a display cell with the updated value.
|
||||
|
||||
```clojure
|
||||
(defn save-item-account [request]
|
||||
(let [{:keys [field-name-prefix client-id]} (some-> request :query-params)
|
||||
account-input-name (str field-name-prefix "[ledger-mapped/account]")
|
||||
account-id-str (get-in request [:form-params account-input-name])
|
||||
account-id (when (and account-id-str (not= account-id-str ""))
|
||||
(Long/parseLong account-id-str))
|
||||
item {:ledger-mapped/account account-id
|
||||
:item-index (second (re-find #"\[(\d+)\]" field-name-prefix))}]
|
||||
(html-response
|
||||
(account-display-cell {:item item
|
||||
:field-name-prefix field-name-prefix
|
||||
:client-id (if (string? client-id) (Long/parseLong client-id) client-id)}))))
|
||||
```
|
||||
|
||||
**Note:** `field-name-prefix` comes from `hx-vals` in the confirm button. `account-input-name` is constructed by appending `[ledger-mapped/account]` to the prefix. The typeahead's hidden input will have this name.
|
||||
|
||||
- [ ] **Step 2: Add it to key->handler**
|
||||
|
||||
```clojure
|
||||
::route/save-item-account (-> save-item-account
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
```
|
||||
|
||||
Wait — we said no DB writes until wizard submit. So we should NOT wrap with `wrap-wizard` and `wrap-decode-multi-form-state`. The handler just returns HTML. It doesn't need the wizard state. The form params contain the typeahead value, and the query params contain the field name prefix and client-id. Simple.
|
||||
|
||||
```clojure
|
||||
::route/save-item-account save-item-account
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Add `cancel-item-account` handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
|
||||
|
||||
- [ ] **Step 1: Write the handler**
|
||||
|
||||
```clojure
|
||||
(defn cancel-item-account [request]
|
||||
(let [{:keys [field-name-prefix client-id current-account-id]} (:query-params request)
|
||||
account-id (when (and current-account-id (not= current-account-id ""))
|
||||
(if (string? current-account-id)
|
||||
(Long/parseLong current-account-id)
|
||||
current-account-id))
|
||||
item {:ledger-mapped/account account-id
|
||||
:item-index (second (re-find #"\[(\d+)\]" field-name-prefix))}]
|
||||
(html-response
|
||||
(account-display-cell {:item item
|
||||
:field-name-prefix field-name-prefix
|
||||
:client-id (if (string? client-id) (Long/parseLong client-id) client-id)}))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add it to key->handler**
|
||||
|
||||
```clojure
|
||||
::route/cancel-item-account cancel-item-account
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Wire routes in key->handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
|
||||
|
||||
- [ ] **Step 1: Add all three new handlers to key->handler**
|
||||
|
||||
The final additions to the handler map (before the closing `}`):
|
||||
|
||||
```clojure
|
||||
::route/edit-item-account (-> edit-item-account
|
||||
(wrap-schema-enforce :query-schema [:map
|
||||
[:item-index nat-int?]
|
||||
[:client-id {:optional true} [:maybe entity-id]]
|
||||
[:current-account-id {:optional true} [:maybe :string]]]))
|
||||
::route/save-item-account save-item-account
|
||||
::route/cancel-item-account cancel-item-account
|
||||
```
|
||||
|
||||
These get the same middleware applied via `apply-middleware-to-all-handlers` at the bottom of `key->handler`.
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Handle manual items in the two-column layout
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
|
||||
|
||||
Manual items have editable category text inputs and debit/credit money inputs. In the two-column layout, manual items need to stay in "edit mode" with their inputs visible.
|
||||
|
||||
- [ ] **Step 1: Add manual item rendering in the debit/credit columns**
|
||||
|
||||
In the render-step `for` loop, when `manual?` is true, render the editable fields instead of display-mode:
|
||||
|
||||
For a debit manual item:
|
||||
|
||||
```clojure
|
||||
[:div.flex.items-center.gap-2.text-sm
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
|
||||
:value "true"})
|
||||
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
|
||||
item []
|
||||
(fc/with-field :sales-summary-item/category
|
||||
(com/text-input {:placeholder "Category/Explanation"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)
|
||||
:class "w-32 text-sm"}))
|
||||
(account-display-cell {:item (assoc item :item-index actual-idx)
|
||||
:field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]")
|
||||
:client-id client-id}))
|
||||
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
|
||||
item []
|
||||
(fc/with-field :debit
|
||||
(com/money-input {:class "w-24 text-sm"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))]
|
||||
```
|
||||
|
||||
For a credit manual item, replace `:debit` with `:credit`.
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Test the complete flow end-to-end
|
||||
|
||||
**Files:**
|
||||
- Manual testing
|
||||
|
||||
- [ ] **Step 1: Open a sales summary row in the wizard**
|
||||
|
||||
Verify the two-column layout renders correctly with debits on the left, credits on the right.
|
||||
|
||||
- [ ] **Step 2: Verify display-mode account cells**
|
||||
|
||||
Each item should show the account name (or "Missing acct" pill) + hidden input + pencil icon.
|
||||
|
||||
- [ ] **Step 3: Click a pencil icon**
|
||||
|
||||
The cell should swap to show a typeahead search + confirm (check) and cancel (X) buttons.
|
||||
|
||||
- [ ] **Step 4: Search and select an account in the typeahead**
|
||||
|
||||
After selecting, click confirm. The cell should swap back to display mode with the updated account name and hidden input value.
|
||||
|
||||
- [ ] **Step 5: Click cancel**
|
||||
|
||||
The cell should swap back to the original display mode.
|
||||
|
||||
- [ ] **Step 6: Submit the wizard**
|
||||
|
||||
All hidden inputs (including the updated account) should be submitted. Verify the transaction updates the correct accounts.
|
||||
|
||||
- [ ] **Step 7: Test manual items**
|
||||
|
||||
Add a new summary item. Verify it renders with editable category + money inputs. Verify the account cell still uses the pencil-to-typeahead pattern.
|
||||
|
||||
- [ ] **Step 8: Test total/unbalanced display**
|
||||
|
||||
Verify totals and unbalanced indicators update correctly (if the `expense-account-total` route is fixed — out of scope for this plan but note if broken).
|
||||
@@ -0,0 +1,145 @@
|
||||
# Inline Account Editing in Sales Summary Wizard
|
||||
|
||||
## Problem
|
||||
|
||||
The current edit wizard for sales summaries renders every item in a flat data-grid with a full typeahead component per row for account assignment. This requires heavy scrolling and makes it hard to see the debit/credit structure at a glance.
|
||||
|
||||
## Solution
|
||||
|
||||
Redesign the wizard's MainStep to mirror the embedded grid's two-column layout (debits / credits), and replace the always-visible typeahead with a click-to-swap inline editing pattern powered by HTMX.
|
||||
|
||||
## Current Flow
|
||||
|
||||
1. Click pencil icon on a grid row → opens full modal wizard
|
||||
2. Wizard renders a data-grid where every row has: category (hidden or text input), account (typeahead), debit, credit
|
||||
3. Every row initializes a typeahead component, even if the user only needs to edit one account
|
||||
4. Heavy scrolling due to tall rows
|
||||
|
||||
## New Flow
|
||||
|
||||
1. Click pencil icon on a grid row → opens modal wizard showing two columns (debits / credits) matching the embedded grid layout
|
||||
2. Each item's account cell renders in **display mode**: account name text + hidden input holding the account ID + pencil icon. If no account is assigned, shows a red "Missing acct" pill + pencil icon.
|
||||
3. Click pencil on an account cell → `hx-get` to `::route/edit-item-account` → server returns **edit mode** (typeahead + confirm/cancel buttons), replacing just that cell via `hx-swap "innerHTML"`
|
||||
4. User selects an account in the typeahead → clicks confirm → `hx-put` to `::route/save-item-account` → server returns **display mode** (updated account name text + updated hidden input + pencil icon)
|
||||
5. Click cancel → `hx-get` to `::route/cancel-item-account` → server returns original display mode
|
||||
6. When the user submits the entire wizard form, all hidden inputs (including updated account IDs) are collected by the existing multi-form-state decode and saved in a single DB transaction
|
||||
|
||||
### Key Constraint
|
||||
|
||||
HTMX routes only manage interactivity (swapping cells). No DB writes happen until the wizard form is submitted via the existing submit handler.
|
||||
|
||||
## New Routes
|
||||
|
||||
| Route Key | Method | Purpose |
|
||||
|---|---|---|
|
||||
| `::route/edit-item-account` | GET | Returns typeahead + confirm/cancel for one account cell |
|
||||
| `::route/save-item-account` | PUT | Returns display mode with updated hidden input value |
|
||||
| `::route/cancel-item-account` | GET | Returns display mode with original hidden input value |
|
||||
|
||||
### Route Parameters
|
||||
|
||||
All three routes receive:
|
||||
- `item-index` — the index of the sales-summary/item in the vector (to construct the correct field name prefix)
|
||||
- `client-id` — for the typeahead search URL
|
||||
- The form-cursor field name prefix is derived from `item-index` so the returned hidden input has the correct `name` attribute (e.g. `step-params[sales-summary/items][2][ledger-mapped/account]`)
|
||||
|
||||
Additionally:
|
||||
- `edit-item-account` and `cancel-item-account` receive the `current-account-id` as a query param so cancel can restore the original value
|
||||
- `save-item-account` receives the selected account ID from the typeahead's form submission in the request body
|
||||
|
||||
## Wizard MainStep Changes
|
||||
|
||||
### Layout
|
||||
|
||||
Replace the current flat data-grid with a two-column layout mirroring the embedded grid:
|
||||
|
||||
```
|
||||
+-------------------------------------------+
|
||||
| Debits | Credits |
|
||||
|-------------------------------------------|
|
||||
| Category Acct Amt | Category Acct Amt |
|
||||
| ... | ... |
|
||||
|-------------------------------------------|
|
||||
| Total: $X,XXX.XX | Total: $X,XXX.XX |
|
||||
| Delta: $XX.XX | Delta: $XX.XX |
|
||||
+-------------------------------------------+
|
||||
| [+ New Summary Item] |
|
||||
+-------------------------------------------+
|
||||
```
|
||||
|
||||
### Item Rendering (Display Mode)
|
||||
|
||||
For each item (non-manual):
|
||||
- **Category**: text label + hidden input
|
||||
- **Account**: account name text (or "Missing acct" pill) + hidden input with account ID + pencil icon with `hx-get`
|
||||
- **Amount**: formatted dollar amount (debit or credit column)
|
||||
|
||||
### Item Rendering (Edit Mode — account cell only)
|
||||
|
||||
When the pencil is clicked, only the account cell swaps to:
|
||||
- Typeahead component (same `account-typeahead*` as current)
|
||||
- Confirm button (small check icon) with `hx-put`
|
||||
- Cancel button (small X icon) with `hx-get`
|
||||
|
||||
### Manual Items
|
||||
|
||||
Same as current: category text input, account typeahead, debit/credit money inputs, delete button. The "New Summary Item" button remains. Manual items are always in "edit mode" since they have editable fields beyond just account.
|
||||
|
||||
### Hidden Inputs
|
||||
|
||||
Every item row must include hidden inputs for:
|
||||
- `db/id`
|
||||
- `sales-summary-item/category` (for non-manual items)
|
||||
- `sales-summary-item/manual?` (for manual items)
|
||||
- `ledger-mapped/account` — this is the key one that gets updated by the inline edit flow
|
||||
|
||||
When the typeahead swaps in (edit mode), the old hidden input for `ledger-mapped/account` is replaced by the typeahead's own hidden input. On confirm, the server returns the updated hidden input. On cancel, the server returns the original hidden input.
|
||||
|
||||
### Total / Unbalanced Rows
|
||||
|
||||
Same as current: `summary-total-row*` and `unbalanced-row*` with live recalculation via `hx-put` to `::route/expense-account-total`.
|
||||
|
||||
## Handler Implementation
|
||||
|
||||
### `edit-item-account` handler
|
||||
|
||||
1. Parse query params: item index, client-id, current field name prefix
|
||||
2. Render the typeahead + confirm/cancel buttons
|
||||
3. The typeahead uses the same `account-typeahead*` pattern
|
||||
4. Confirm button: `hx-put` to `::route/save-item-account`, `hx-target "closest td"`, `hx-swap "innerHTML"`
|
||||
5. Cancel button: `hx-get` to `::route/cancel-item-account`, `hx-target "closest td"`, `hx-swap "innerHTML"`
|
||||
|
||||
### `save-item-account` handler
|
||||
|
||||
1. Parse form body: selected account ID, item index, client-id, field name prefix
|
||||
2. Resolve account name from DB using `d-accounts/clientize`
|
||||
3. Return display mode HTML: account name text + hidden input (with new account ID) + pencil icon
|
||||
|
||||
### `cancel-item-account` handler
|
||||
|
||||
1. Parse query params: item index, client-id, current field name prefix, original account ID
|
||||
2. Resolve account name from DB (if account ID exists)
|
||||
3. Return display mode HTML: account name text (or "Missing acct" pill) + hidden input (with original account ID) + pencil icon
|
||||
|
||||
## Route Definitions
|
||||
|
||||
Add to `routes.cljc`:
|
||||
|
||||
```clojure
|
||||
"/edit/item-account" ::edit-item-account
|
||||
"/edit/save-item-account" ::save-item-account
|
||||
"/edit/cancel-item-account" ::cancel-item-account
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/cljc/auto_ap/routes/pos/sales_summaries.cljc` | Add 3 new route keys |
|
||||
| `src/clj/auto_ap/ssr/pos/sales_summaries.clj` | Rewrite MainStep render-step, add 3 handlers, add helper fns for account display/edit cells |
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Changes to the embedded grid table (already redesigned)
|
||||
- Changes to how the wizard submit handler works
|
||||
- Adding the missing `::route/expense-account-total` route (pre-existing bug, separate fix)
|
||||
@@ -49,9 +49,9 @@
|
||||
(datomic-fn :upsert-entity #'iol-ion.tx.upsert-entity/upsert-entity)
|
||||
(datomic-fn :upsert-invoice #'iol-ion.tx.upsert-invoice/upsert-invoice)
|
||||
(datomic-fn :upsert-ledger #'iol-ion.tx.upsert-ledger/upsert-ledger)
|
||||
(datomic-fn :upsert-transaction #'iol-ion.tx.upsert-transaction/upsert-transaction)])))
|
||||
(datomic-fn :upsert-transaction #'iol-ion.tx.upsert-transaction/upsert-transaction)
|
||||
(datomic-fn :upsert-sales-summary #'iol-ion.tx.upsert-sales-summary-ledger/upsert-sales-summary)])))
|
||||
|
||||
(comment
|
||||
(regenerate-literals)
|
||||
|
||||
(auto-ap.datomic/install-functions))
|
||||
(auto-ap.datomic/install-functions))
|
||||
|
||||
70
iol_ion/src/iol_ion/tx/upsert_sales_summary_ledger.clj
Normal file
70
iol_ion/src/iol_ion/tx/upsert_sales_summary_ledger.clj
Normal file
@@ -0,0 +1,70 @@
|
||||
(ns iol-ion.tx.upsert-sales-summary-ledger
|
||||
(:require [datomic.api :as dc]))
|
||||
|
||||
(defn summary->journal-entry [db summary-id]
|
||||
(let [summary (dc/pull db '[:sales-summary/client
|
||||
:sales-summary/date
|
||||
{:sales-summary/items [:sales-summary-item/category
|
||||
:ledger-mapped/account
|
||||
:ledger-mapped/amount
|
||||
{:ledger-mapped/ledger-side [:db/ident]}]}]
|
||||
summary-id)
|
||||
items (:sales-summary/items summary)
|
||||
aggregated (->> items
|
||||
(filter :ledger-mapped/account)
|
||||
(group-by :ledger-mapped/account)
|
||||
(map (fn [[account acc-items]]
|
||||
(reduce
|
||||
(fn [m item]
|
||||
(update m (:db/ident (:ledger-mapped/ledger-side item)) (fnil + 0.0) (:ledger-mapped/amount item 0.0)))
|
||||
{:account account}
|
||||
acc-items))))
|
||||
_ (clojure.pprint/pprint aggregated)
|
||||
line-items (mapv (fn [{:keys [account] :as m}]
|
||||
(cond-> {:db/id (str (java.util.UUID/randomUUID))
|
||||
:journal-entry-line/account account
|
||||
:journal-entry-line/location "A"}
|
||||
(get m :ledger-side/debit) (assoc :journal-entry-line/debit (get m :ledger-side/debit))
|
||||
(get m :ledger-side/credit) (assoc :journal-entry-line/credit (get m :ledger-side/credit))))
|
||||
aggregated)
|
||||
|
||||
total-debits (reduce + 0.0 (map #(get % :ledger-side/debit 0.0) aggregated))
|
||||
total-credits (reduce + 0.0 (map #(get % :ledger-side/credit 0.0) aggregated))
|
||||
_ (clojure.pprint/pprint [total-debits total-credits])
|
||||
]
|
||||
(when (and (seq line-items)
|
||||
(= (Math/round (* 1000 total-debits))
|
||||
(Math/round (* 1000 total-credits))))
|
||||
{:journal-entry/source "sales-summary"
|
||||
:journal-entry/client (:db/id (:sales-summary/client summary))
|
||||
:journal-entry/date (:sales-summary/date summary)
|
||||
:journal-entry/original-entity summary-id
|
||||
:journal-entry/amount total-debits
|
||||
:journal-entry/line-items line-items})))
|
||||
|
||||
(defn current-date [db]
|
||||
(let [last-tx (dc/t->tx (dc/basis-t db))
|
||||
[[date]] (seq (dc/q '[:find ?ti :in $ ?tx
|
||||
:where [?tx :db/txInstant ?ti]]
|
||||
db
|
||||
last-tx))]
|
||||
date))
|
||||
|
||||
(defn upsert-sales-summary [db summary]
|
||||
(let [upserted-summary [[:upsert-entity summary]]
|
||||
db-after (-> (dc/with db upserted-summary) :db-after)
|
||||
summary-id (:db/id summary)
|
||||
client-id (-> (dc/pull db-after [{:sales-summary/client [:db/id]}] summary-id)
|
||||
:sales-summary/client
|
||||
:db/id)
|
||||
journal-entry (summary->journal-entry db-after summary-id)]
|
||||
upserted-summary
|
||||
#_(into upserted-summary
|
||||
(if journal-entry
|
||||
[[:upsert-ledger journal-entry]]
|
||||
(concat
|
||||
[[:db/retractEntity [:journal-entry/original-entity (:db/id summary)]]]
|
||||
|
||||
|
||||
(when client-id [{:db/id client-id
|
||||
:client/ledger-last-change (current-date db)}]))))))
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"agent": {
|
||||
"clojure-author": {
|
||||
"prompt": "You are an expert Clojure developer. Follow these rules:\n\nStructural Editing: Use the clojure-mcp tools for all code changes. When editing clojure, you may only use clojure_edit, clojure_edit_replace_sexp, file_edit, file_write, for modifications from the clojure mcp server. You should also prefer to use read_file from the clojure mcp server. Never use\n sed, Write, or raw text replacement for Clojure files. Use clj-repair-parens (via clojure_mcp_paren_repair) whenever a file has unbalanced delimiters\n before making other edits.\n Code Style: Write pure functions by default. Avoid side effects, mutable state, and overly clever code. Favor let bindings over nested calls. Keep\n functions small and composable.\nKnowledge: When you need to verify a library API, standard library behavior, or Clojure semantics, consult context7 first. Use web search as a\n fallback when context7 lacks coverage.\n Evaluation: Use clojure_mcp_clojure_eval to test expressions and verify behavior before suggesting code changes.",
|
||||
"permission": {"edit": "deny", "bash": "deny"}
|
||||
}
|
||||
},
|
||||
"command": {
|
||||
"resolve_pr_parallel": {
|
||||
"description": "Resolve all PR comments using parallel processing",
|
||||
@@ -108,7 +114,11 @@
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
"clojure-mcp": {
|
||||
"type": "local",
|
||||
"command": ["clojure", "-Tmcp", "start", ":config-profile", ":cli-assist"],
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"read": "allow",
|
||||
|
||||
1
resources/.~lock.sample-yodlee-manual.tsv#
Normal file
1
resources/.~lock.sample-yodlee-manual.tsv#
Normal file
@@ -0,0 +1 @@
|
||||
,noti,pop-os,01.06.2026 21:02,file:///home/noti/.config/libreoffice/4;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1949,12 +1949,12 @@
|
||||
:db/unique :db.unique/identity
|
||||
:db/index true}
|
||||
{:db/ident :sales-summary/client+dirty
|
||||
:db/valueType :db.type/tuple
|
||||
:db/tupleAttrs [:sales-summary/client :sales-summary/dirty]
|
||||
:db/cardinality :db.cardinality/one
|
||||
:db/index true}
|
||||
:db/valueType :db.type/tuple
|
||||
:db/tupleAttrs [:sales-summary/client :sales-summary/dirty]
|
||||
:db/cardinality :db.cardinality/one
|
||||
:db/index true}
|
||||
|
||||
{:db/ident :sales-summary-item/category
|
||||
{:db/ident :sales-summary-item/category
|
||||
:db/valueType :db.type/string
|
||||
:db/cardinality :db.cardinality/one}
|
||||
{:db/ident :sales-summary-item/sort-order
|
||||
|
||||
@@ -1759,4 +1759,38 @@ 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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
|
||||
|
43
resources/sysco_recode/bad.csv
Normal file
43
resources/sysco_recode/bad.csv
Normal file
@@ -0,0 +1,43 @@
|
||||
,,,,,,,,,,,,,,,,,,,,,,,,,d,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,20.56,CA,20.56,Y,0,0,0,1.2,07,48,02,01,CANNED AND DRY,24,20OZ,AQUAFIN,WATER PURIFIED BTL PET LSE DW,30,33,0.75,24,,000000,,0000,,,,29115,47,,8492330,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,14.84,CA,14.84,Y,0,0,0,0.6,07,16,05,01,CANNED AND DRY,12,11.2OZ,LOUX,SODA CHERRY VISSINADA GRK PLAS,9.5,10.5,0.26,12,,000000,,0000,,,3000P,808959,01,,7189422,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,14.93,CA,14.93,Y,0,0,0,0.6,07,16,05,01,CANNED AND DRY,12,8 OZ,LOUX,SODA LEMON LEMONADA GREEK,9,11.5,0.26,12,,000000,,0000,,,3200,808959,01,,9910355,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,79.83,CA,79.83,Y,0,0,0,0,07,35,03,99,CANNED AND DRY,4,5 LB,OTHRYS,SPICE OREGANO LEAF RUBBED,20,22,2.51,4,,000000,,0000,,,62760,808959,01,,9911236,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,31.07,CA,31.07,Y,0,0,0,0,07,35,99,99,CANNED AND DRY,22,4.68OZ,HI WEST,RICE MIX NICKS,6.43,7,0.16,22,,000000,,0000,,,30-5729,345717,03,,7301949,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,2,2,0,54.84,CA,109.68,Y,0,0,0,0,07,33,01,99,CANNED AND DRY,2,20 LB,ROYAL,RICE BASMATI PABROIL SELA CS,40,40.6,1.21,2,,000000,,0000,,,91000244,26992,43,,7053293,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,75.49,CA,75.49,Y,0,0,0,0,07,34,04,99,CANNED AND DRY,4,1 GAL,NICKGRK,DRESSING VINAIGRETTE LOGO,33,35,0.87,4,,000000,,0000,,,1654,853,01,,7108399,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,29.4,CA,29.4,Y,0,0,0,0,07,36,99,99,CANNED AND DRY,8,15 OZ,HAIG'S,DIP GARLIC TOUM,7.25,8.25,0.34,8,,000000,,0000,,,8PGD16,691816,01,,7360056,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,38.85,CA,38.85,Y,0,0,0,0,07,37,02,02,CANNED AND DRY,1,35 LB,BEOCO,OIL CORN,35,36.55,0.85,1,,000000,,0000,,,,9846,02,,4823761,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,2,2,0,56.72,CA,113.44,Y,0,0,0,0,07,36,99,99,CANNED AND DRY,4,4 LB,GRECDEL,SPREAD HUMMUS TRADITIONAL,16,17,0.62,4,,000000,,0000,,,HU000083,1533,19,,7278619,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,-7.22,CA,-7.32,Y,0,0,0,0,07,86,01,99,CANNED AND DRY,1,EA,NONPROD,ALLOWANCE FOR DROP SIZE,0.01,0.01,0.01,1,,000000,,0000,,,,,01,,9477498,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,4.17,CA,4.17,Y,0,0,0,0,07,86,01,99,CANNED AND DRY,1,EA,NONPROD,CHGS FOR FUEL SURCHARGE,1,1,0,1,,000000,,0000,,,,,01,,6592893,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,2,2,0,95.98,CA,191.96,Y,0,0,0,0,02,04,99,99,DAIRY PRODUCTS,1,5 GAL,NICKGRK,SAUCE TZATZIKI,42,43.5,1.2,1,,000000,,0000,,,SA000084,1533,19,,7213639,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,2,2,0,54.82,CA,109.64,Y,0,0,0,0,02,10,01,99,DAIRY PRODUCTS,4,1 GAL,NICKGRK,YOGURT FRZN NF NICK THE GREEK,39.9,39.9,0.97,4,,000000,,0000,,,13101,379887,05,,7302646,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,172.37,CA,172.37,Y,0,0,0,0,12,08,02,03,DISPENSER BEVRG,12,32 OZ,TRACTOR,JUICE CONC STRAWB DRAGON,24,25.5,0.58,12,,000000,,0000,,,6555,693956,01,,7206974,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,62.48,CA,62.48,Y,0,0,0,0,12,08,02,03,DISPENSER BEVRG,1,2.5GAL,DR PEPR,SYRUP DR PPR DIET BIB,20.93,21.82,0.47,1,,000000,,0000,,,12115,376510,09,,7459969,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,117.3,CA,117.3,Y,0,0,0,0,12,08,02,03,DISPENSER BEVRG,1,5GAL,DR PEPR,SYRUP DR PEPPER BIB,40,54.4,0.83,1,,000000,,0000,,,12109,9562,14,,4273553,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,8,8,0,26.36,CA,210.88,Y,0,0,0,0,06,02,45,99,FROZEN,12,10 CT,KONTOS,BREAD PITA GYRO PRE-OILED 7,21,24,1.65,12,,000000,,0000,,,10005,25370,01,,5223334,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,2,2,0,69.37,CA,138.74,Y,0,0,0,0,06,01,70,99,FROZEN,36,6 OZ,HELLAS,SPANAKOPITA SPINACH COOKED,12.4,13.4,0.62,36,,000000,,0000,,,216312,32248,01,,7455027,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,2,2,0,49.53,CA,99.06,Y,0,0,0,0,06,01,60,99,FROZEN,2,24 CT,HELLAS,BAKLAVA CLASSIC 2X24,9.6,10.6,0.46,2,,000000,,0000,,,100224,32248,01,,7187055,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,57.66,CA,57.66,Y,0,0,0,0,06,01,65,99,FROZEN,140,0.7 OZ,CHICPAT,DESSERT MINI PLAIN BEIGNET,6.17,7.5,0.88,140,,000000,,0000,,,540061,1188,53,,7212299,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,97.94,CA,97.94,Y,0,0,0,0,06,02,01,99,FROZEN,4,10 LB,NICKGRK,APTZR VEG FALAFEL PUCK HALAL,40,42,2.03,4,,000000,,0000,,,FA000090,1533,05,,7274591,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,1,53.5,1,53.5,7.556,LB,404.25,Y,0,0,0,0,03,02,01,13,MEATS,5,10.5#,TWORVRS,BEEF SHLDR TERES MAJOR SEL,53,55,1.99,5,,000000,,0000,,,B83003,527004,03,,0932867,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,87.76,CA,87.76,Y,0,0,0,0,03,04,99,99,MEATS,1,20 LB,GRECDEL,PORK SLI GYRO CONE,20,21,0.77,1,,000000,,0000,,,ME000215,1533,05,,7211838,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,7,7,0,92.53,CA,647.71,Y,0,0,0,0,03,02,04,99,MEATS,1,30 LB,NICKGRK,MEAT GYRO BEEF CONE NTG,30,31,0.97,1,,000000,,0000,,,ME000071,1533,05,,9906087,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,25.41,CA,25.41,Y,0,0,0,0,08,42,64,43,PAPER & DISP,20,50 CT,KARAT,LID PLAS FLAT F/12-22 OZ,5.75,7,1.94,20,,000000,,0000,,,C-KCL90,461672,05,,7661388,00000000000000,,,260402,04671945,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,22.32,CA,22.32,Y,0,0,0,0,08,60,99,99,PAPER & DISP,24,250 CT,ELEMEN,NAPKIN 2PLY INTR FOLD 6.3X8.26,16.1,16.8,1.55,24,,000000,,0000,,,11904,613310,01,,7452585,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,26.15,CA,26.15,Y,0,0,0,0,08,21,64,62,PAPER & DISP,50,50CT,KARAT,CUP PORTION PLAS CLR 1.50 OZ,10,10,1.36,50,,000000,,0000,,,FP-P150-PP,461672,05,,4613026,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,43.99,CA,43.99,Y,0,0,0,0,08,75,03,04,PAPER & DISP,6,50 EA,NATZWAY,BOWL PLASTIC COATING 42 OZ,14.55,17.19,3.56,6,,000000,,0000,,,10205,773772,01,,7408008,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,41.25,CA,41.25,Y,0,0,0,0,08,42,99,99,PAPER & DISP,6,50CT,NATZWAY,LID CLEAR PET 42 OZ,8.59,10.47,2.01,6,,000000,,0000,,,10206,773772,01,,7408215,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,47.6,CA,47.6,Y,0,0,0,0,08,21,56,99,PAPER & DISP,1000,22 OZ,NICKGRK,CUP PAPER COLD 22 OZ LOGO NTG,31.96,34.62,3.78,1000,,000000,,0000,,,810161542703,461672,05,,7354127,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,30.6,CA,30.6,Y,0,0,0,0,08,18,56,99,PAPER & DISP,1,450 CT,NICKGRK,CONTAINER PAPER 1/30 OZ NTG,23,25,3.25,1,,000000,,0000,,,810161542673,461672,05,,7354120,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,27.6,CA,27.6,Y,0,0,0,0,08,18,56,99,PAPER & DISP,1,160 CT,NICKGRK,CONTAINER PAPER 4/110OZ NTG,19.6,21.4,3.59,1,,000000,,0000,,,810161542680,461672,05,,7354119,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,3,3,0,37.6,CA,112.8,Y,0,0,0,0,08,18,02,12,PAPER & DISP,2,100CT,NATZWAY,CONTAINER PAPER MLD FBR 9X6,18,18,1.28,2,,000000,,0000,,,10042,773772,01,,7250678,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,2,2,0,46.07,CA,92.14,Y,0,0,0,0,08,09,56,99,PAPER & DISP,1,250BAG,NICKGRK,BAG PAPER 250 CT,14.5,15,2.18,1,,000000,,0000,,,,773772,01,,7417242,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,1,,1,1,0,24.46,EA,24.46,Y,0,0,0,0,08,36,56,79,PAPER & DISP,5,1000,BAGCRFT,WRAP DELI WHT 12X12 GRS RESIST,37,37,1.07,5,,000000,,0000,,,P057012,276,01,,5723808,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,39.41,CA,39.41,Y,3.85,0,0,0,08,06,25,10,PAPER & DISP,10,100CT,DHGPROF,GLOVE NITRILE BLK PEDRFREE LRG,12.21,12.21,0.66,10,,000000,,0000,,,DNGB-L,613310,01,,7296407,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,3,3,0,89.32,CA,267.96,Y,0,0,0,0,05,01,01,07,POULTRY,4,10 LB,SYS CLS,CHICKEN CVP THIGH BNLS SKLS,40,42,1.04,4,,000000,,0000,,,14301,3254,21,,7792187,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,9,9,0,86.97,CA,782.73,Y,0,0,0,0,05,02,01,99,POULTRY,1,20LB,GRECDEL,GYRO CHICKEN SHAWARMA CONE,20,21,0.77,1,,000000,,0000,,,ME000102,1533,05,,7124188,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,5,5,0,23.65,CA,118.25,Y,0,0,0,0,11,02,23,01,PRODUCE,1,50 LB,PACKER,POTATO KENNEBEC FRESH,50,52,2,1,,000000,,0000,,,,696760,01,,2039220,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,HDR,,,CKC CONCORD INC,,260402,,,,Rolling 8,,NICK THE GREEK CONCORD,2075 DIAMOND BLVD,STE H-103,CONCORD,CA,94520-582,408593,000000000,,,,,BBNKG,0,050,SYSCO SAN FRANCISCO,5900 STEWART AVENU,,FREMONT,CA,94538,,,,,,,1372486,4024,004,0000000,00000000,20260529,6.25,CRO8
|
||||
EEK,,050,00175469,850081745,SUM,,,40,0,0,74,0,4625.36,6.25,00000463161,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
|
271
scratch-sessions/sysco_recode_scratch.clj
Normal file
271
scratch-sessions/sysco_recode_scratch.clj
Normal file
@@ -0,0 +1,271 @@
|
||||
;; =====================================================================
|
||||
;; ONE-OFF SCRATCH — re-code already-imported Sysco invoices after fixing
|
||||
;; resources/sysco_line_item_mapping.csv.
|
||||
;;
|
||||
;; Context: the Sysco importer codes each line item by EXACT description
|
||||
;; match against sysco_line_item_mapping.csv, defaulting to GL 50000 when a
|
||||
;; description is missing (auto-ap.jobs.sysco/get-line-account). Missing
|
||||
;; PAPER & DISP (and other) descriptions landed in 50000 (Food Costs)
|
||||
;; instead of their real accounts (e.g. 55000 Paper Costs). The mapping is
|
||||
;; now fixed; this re-derives the correct split from each invoice's source
|
||||
;; CSV and rewrites :invoice/expense-accounts.
|
||||
;;
|
||||
;; Design:
|
||||
;; - Recode EVERY invoice found in the CSV resource (a Sysco file may batch
|
||||
;; several invoices; they're grouped by InvoiceNumber).
|
||||
;; - Build ONE transaction covering every invoice, emitting only the datoms
|
||||
;; that actually change:
|
||||
;; * reuse an existing invoice-expense-account when its account (and
|
||||
;; location) already match, updating just :amount when it differs;
|
||||
;; * add a child for an account that has no row yet;
|
||||
;; * retract a child whose account is no longer in the corrected split;
|
||||
;; * emit nothing for rows already correct.
|
||||
;; - Validate with (dc/with db changes): apply the tx to an in-memory db
|
||||
;; value and assert every affected invoice's expense-account amounts sum
|
||||
;; to its :invoice/total BEFORE committing for real.
|
||||
;; - After committing, touch the ledger for every affected invoice. This is
|
||||
;; a SEPARATE transaction on purpose: :upsert-invoice rebuilds the journal
|
||||
;; entry from the invoice's expense-accounts as seen in db-before, so it
|
||||
;; must run after the recode is committed.
|
||||
;;
|
||||
;; DO NOT load/evaluate this whole file. Step through the (comment ...) forms
|
||||
;; one at a time in a connected REPL; the commit + ledger steps are gated #_.
|
||||
;;
|
||||
;; PRECONDITIONS
|
||||
;; - The deployed artifact ships the fixed sysco_line_item_mapping.csv AND
|
||||
;; the invoice CSV at resources/sysco_recode/<file>.csv (io/resource).
|
||||
;; - You are connected to the DB you intend to mutate (prod conn!).
|
||||
;; =====================================================================
|
||||
|
||||
(comment
|
||||
|
||||
(require '[auto-ap.jobs.sysco :as sysco]
|
||||
'[auto-ap.datomic :refer [conn audit-transact random-tempid]]
|
||||
'[auto-ap.utils :refer [dollars=]]
|
||||
'[auto-ap.time :as t]
|
||||
'[clj-time.coerce :as coerce]
|
||||
'[clojure.data.csv :as csv]
|
||||
'[clojure.java.io :as io]
|
||||
'[datomic.api :as dc])
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 0 — reload the mapping cache so the corrected CSV is in effect.
|
||||
;; ------------------------------------------------------------------
|
||||
(reset! sysco/sysco-name->line nil)
|
||||
(count (sysco/get-sysco->line))
|
||||
|
||||
;; sanity: a previously-missing paper description now resolves to 55000.
|
||||
(dc/pull (dc/db conn) [:account/numeric-code :account/name]
|
||||
(sysco/get-line-account "BAG PAPER 250 CT")) ; => 55000
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; Helpers
|
||||
;; ------------------------------------------------------------------
|
||||
(defn read-csv-rows
|
||||
"Reads the invoice CSV from the classpath (so it ships with the deploy).
|
||||
`resource-path` is relative to a resources/ root, e.g. \"sysco_recode/bad.csv\"."
|
||||
[resource-path]
|
||||
(with-open [r (io/reader (or (io/resource resource-path)
|
||||
(throw (ex-info "CSV not found on classpath"
|
||||
{:resource-path resource-path}))))]
|
||||
(doall (csv/read-csv r))))
|
||||
|
||||
(defn parse-date
|
||||
"Sysco yyMMdd string -> java.util.Date, the same way the importer stores
|
||||
:invoice/date (auto-ap.jobs.sysco/extract-invoice-details)."
|
||||
[yymmdd]
|
||||
(coerce/to-date (t/parse yymmdd "yyMMdd")))
|
||||
|
||||
(defn group-invoices
|
||||
"Split a (possibly multi-invoice) Sysco CSV into one entry per invoice.
|
||||
Groups DET/HDR/SUM rows by InvoiceNumber (index 4); date comes from the
|
||||
group's HDR row InvoiceDate (index 10)."
|
||||
[rows]
|
||||
(->> rows
|
||||
(filter #(contains? #{"DET" "HDR" "SUM"} (nth % 5 nil)))
|
||||
(group-by #(nth % 4))
|
||||
(mapv (fn [[number grp]]
|
||||
(let [hdr (first (filter #(= "HDR" (nth % 5)) grp))]
|
||||
{:invoice-number number
|
||||
:date-str (some-> hdr (nth 10))
|
||||
:rows grp})))))
|
||||
|
||||
(defn desired-split
|
||||
"Rows of one Sysco invoice -> {account-eid -> amount-double}, using the
|
||||
CURRENT (fixed) mapping. DET rows only (record-type at index 5); tax
|
||||
(SUM row, TotalTaxAmount index 14) added to the same account the
|
||||
importer uses for \"TAX\". Mirrors auto-ap.jobs.sysco/code-individual-items."
|
||||
[rows]
|
||||
(let [det (filter #(= "DET" (nth % 5)) rows)
|
||||
sum-row (first (filter #(= "SUM" (nth % 5)) rows))
|
||||
tax (some-> sum-row (nth 14) Double/parseDouble)
|
||||
by-acct (reduce
|
||||
(fn [acc row]
|
||||
(update acc
|
||||
(sysco/get-line-account (nth row sysco/item-name-index))
|
||||
(fnil + 0.0)
|
||||
(Double/parseDouble (nth row sysco/item-price-index))))
|
||||
{}
|
||||
det)]
|
||||
(cond-> by-acct
|
||||
(and tax (not (zero? tax)))
|
||||
(update (sysco/get-line-account "TAX") (fnil + 0.0) tax))))
|
||||
|
||||
(defn resolve-eid
|
||||
"Match on invoice-number AND date (belt-and-suspenders). Asserts a unique
|
||||
hit so we never recode the wrong invoice."
|
||||
[invoice-number date]
|
||||
(let [ids (mapv first (dc/q '[:find ?i :in $ ?n ?d
|
||||
:where
|
||||
[?i :invoice/invoice-number ?n]
|
||||
[?i :invoice/date ?d]]
|
||||
(dc/db conn) invoice-number date))]
|
||||
(assert (>= 1 (count ids))
|
||||
(str "multiple invoices match " invoice-number " / " date ": " ids))
|
||||
(first ids)))
|
||||
|
||||
(defn invoice-change-datoms
|
||||
"Minimal tx-data to make invoice `eid`'s expense-account split equal
|
||||
`desired` ({account-eid -> amount}). Returns [] when already correct."
|
||||
[db eid desired]
|
||||
(let [existing (:invoice/expense-accounts
|
||||
(dc/pull db [{:invoice/expense-accounts
|
||||
[:db/id :invoice-expense-account/amount
|
||||
:invoice-expense-account/location
|
||||
{:invoice-expense-account/account [:db/id]}]}]
|
||||
eid))
|
||||
loc (or (some :invoice-expense-account/location existing) "HQ")
|
||||
;; one child per account expected; index by account, retract any dupes
|
||||
by-acct (group-by #(get-in % [:invoice-expense-account/account :db/id]) existing)
|
||||
one (into {} (map (fn [[a cs]] [a (first cs)])) by-acct)
|
||||
dupes (mapcat (fn [[_ cs]] (map :db/id (rest cs))) by-acct)
|
||||
wanted (set (keys desired))
|
||||
upserts (keep (fn [[acct amt]]
|
||||
(let [child (get one acct)]
|
||||
(cond
|
||||
;; new account -> accrete a child under the invoice
|
||||
(nil? child)
|
||||
{:db/id eid
|
||||
:invoice/expense-accounts
|
||||
[#:invoice-expense-account{:db/id (random-tempid)
|
||||
:account acct
|
||||
:location loc
|
||||
:amount amt}]}
|
||||
;; right account, wrong value -> reuse, set amount
|
||||
;; (and fix location if it drifted)
|
||||
(or (not (dollars= (:invoice-expense-account/amount child) amt))
|
||||
(not= (:invoice-expense-account/location child) loc))
|
||||
(cond-> {:db/id (:db/id child)
|
||||
:invoice-expense-account/amount amt}
|
||||
(not= (:invoice-expense-account/location child) loc)
|
||||
(assoc :invoice-expense-account/location loc))
|
||||
;; already correct -> nothing
|
||||
:else nil)))
|
||||
desired)
|
||||
retracts (for [[acct child] one :when (not (wanted acct))]
|
||||
[:db/retractEntity (:db/id child)])]
|
||||
(vec (concat upserts
|
||||
retracts
|
||||
(map (fn [id] [:db/retractEntity id]) dupes)))))
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 1 — point at the CSV. EVERY invoice in this file gets recoded.
|
||||
;; Place the file under resources/ (e.g. resources/sysco_recode/bad.csv)
|
||||
;; and commit it so it's on the classpath of the deployed artifact.
|
||||
;; ------------------------------------------------------------------
|
||||
(def csv-path "sysco_recode/bad.csv")
|
||||
(def rows (read-csv-rows csv-path))
|
||||
|
||||
(def invoices (group-invoices rows))
|
||||
(mapv (juxt :invoice-number :date-str) invoices) ;; what we found in the file
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 2 — resolve each invoice (number + date) and compute its split.
|
||||
;; ------------------------------------------------------------------
|
||||
(def plan
|
||||
(mapv (fn [{:keys [invoice-number date-str rows]}]
|
||||
(let [date (parse-date date-str)]
|
||||
{:invoice-number invoice-number
|
||||
:date date
|
||||
:eid (resolve-eid invoice-number date)
|
||||
:desired (desired-split rows)}))
|
||||
invoices))
|
||||
|
||||
;; bail if any invoice number didn't resolve
|
||||
(assert (every? :eid plan)
|
||||
(str "unresolved invoices: "
|
||||
(mapv (juxt :invoice-number :date) (remove :eid plan))))
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 3 — build the SINGLE changes-only transaction across all invoices.
|
||||
;; ------------------------------------------------------------------
|
||||
(def changes
|
||||
(let [db (dc/db conn)]
|
||||
(vec (mapcat (fn [{:keys [eid desired]}] (invoice-change-datoms db eid desired))
|
||||
plan))))
|
||||
|
||||
(count changes) ;; how many datoms we're actually changing
|
||||
changes ;; inspect the full minimal tx
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 4 — validate with dc/with: apply the tx to an in-memory db value
|
||||
;; and confirm every affected invoice still balances (sum of expense
|
||||
;; account amounts == :invoice/total).
|
||||
;; ------------------------------------------------------------------
|
||||
(def preview (dc/with (dc/db conn) changes))
|
||||
|
||||
(def balance-report
|
||||
(let [db-after (:db-after preview)]
|
||||
(mapv (fn [{:keys [eid invoice-number]}]
|
||||
(let [inv (dc/pull db-after
|
||||
[:invoice/total
|
||||
{:invoice/expense-accounts [:invoice-expense-account/amount]}]
|
||||
eid)
|
||||
s (reduce + 0.0 (map :invoice-expense-account/amount
|
||||
(:invoice/expense-accounts inv)))]
|
||||
{:invoice-number invoice-number
|
||||
:total (:invoice/total inv)
|
||||
:ea-sum s
|
||||
:ok? (dollars= s (:invoice/total inv))}))
|
||||
plan)))
|
||||
balance-report
|
||||
|
||||
;; HARD GATE — do not continue unless every invoice balances post-change.
|
||||
(assert (every? :ok? balance-report)
|
||||
(str "unbalanced after change: "
|
||||
(filterv (complement :ok?) balance-report)))
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 5 — COMMIT the recode (gated). One transaction, changes only.
|
||||
;; ------------------------------------------------------------------
|
||||
#_(audit-transact changes
|
||||
{:user/name "sysco recode (missing GL mappings fix)"
|
||||
:user/role "admin"})
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 6 — touch the ledger for every affected invoice (separate tx;
|
||||
;; :upsert-invoice rebuilds the journal entry from the now-committed
|
||||
;; expense-accounts). Gated.
|
||||
;; ------------------------------------------------------------------
|
||||
#_(audit-transact (mapv (fn [{:keys [eid]}] [:upsert-invoice {:db/id eid}]) plan)
|
||||
{:user/name "sysco recode ledger touch"
|
||||
:user/role "admin"})
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 7 — verify committed result.
|
||||
;; ------------------------------------------------------------------
|
||||
#_(let [db (dc/db conn)]
|
||||
(mapv (fn [{:keys [eid invoice-number]}]
|
||||
{:invoice-number invoice-number
|
||||
:accounts
|
||||
(->> (dc/pull db
|
||||
[{:invoice/expense-accounts
|
||||
[:invoice-expense-account/amount
|
||||
{:invoice-expense-account/account [:account/numeric-code]}]}]
|
||||
eid)
|
||||
:invoice/expense-accounts
|
||||
(map (juxt #(get-in % [:invoice-expense-account/account :account/numeric-code])
|
||||
:invoice-expense-account/amount))
|
||||
(sort-by first)
|
||||
vec)})
|
||||
plan)))
|
||||
@@ -5,10 +5,11 @@
|
||||
[iol-ion.tx.propose-invoice]
|
||||
[iol-ion.tx.reset-rels]
|
||||
[iol-ion.tx.reset-scalars]
|
||||
[iol-ion.tx.upsert-entity]
|
||||
[iol-ion.tx.upsert-invoice]
|
||||
[iol-ion.tx.upsert-ledger]
|
||||
[iol-ion.tx.upsert-transaction]
|
||||
[iol-ion.tx.upsert-entity]
|
||||
[iol-ion.tx.upsert-invoice]
|
||||
[iol-ion.tx.upsert-ledger]
|
||||
[iol-ion.tx.upsert-transaction]
|
||||
[iol-ion.tx.upsert-sales-summary-ledger]
|
||||
[com.github.ivarref.gen-fn :refer [gen-fn! datomic-fn]]
|
||||
[auto-ap.utils :refer [default-pagination-size by]]
|
||||
[clojure.edn :as edn]
|
||||
|
||||
@@ -278,46 +278,42 @@
|
||||
|
||||
(defn sales-summaries-v2 []
|
||||
(doseq [[c client-code] (dc/q '[:find ?c ?client-code
|
||||
:in $
|
||||
:where [?c :client/code ?client-code]]
|
||||
(dc/db conn))
|
||||
{:sales-summary/keys [date] :db/keys [id]} (dirty-sales-summaries c)]
|
||||
:in $
|
||||
:where [?c :client/code ?client-code]]
|
||||
(dc/db conn))
|
||||
{:sales-summary/keys [date] :db/keys [id] :as existing-summary} (dirty-sales-summaries c)]
|
||||
(mu/with-context {:client-code client-code
|
||||
:date date}
|
||||
(alog/info ::updating)
|
||||
(let [result {:db/id id
|
||||
:sales-summary/client c
|
||||
:sales-summary/date date
|
||||
:sales-summary/dirty false
|
||||
:sales-summary/client+date [c date]
|
||||
|
||||
:sales-summary/items
|
||||
(->>
|
||||
(get-sales c date)
|
||||
(concat (get-payment-items c date))
|
||||
(concat (get-refund-items c date))
|
||||
(cons (get-discounts c date))
|
||||
(cons (get-fees c date))
|
||||
(cons (get-tax c date))
|
||||
(cons (get-tip c date))
|
||||
(cons (get-returns c date))
|
||||
(filter identity)
|
||||
(map (fn [z]
|
||||
(assoc z :ledger-mapped/account (some-> z :sales-summary-item/category str/lower-case name->number lookup-account)
|
||||
:sales-summary-item/manual? false))
|
||||
)) }]
|
||||
(if (seq (:sales-summary/items result))
|
||||
(do
|
||||
(alog/info ::upserting-summaries
|
||||
:category-count (count (:sales-summary/items result)))
|
||||
@(dc/transact conn [[:upsert-entity result]]))
|
||||
@(dc/transact conn [{:db/id id :sales-summary/dirty false}]))))))
|
||||
|
||||
(let [c (auto-ap.datomic/pull-attr (dc/db conn) :db/id [:client/code "NGCL" ])
|
||||
date #inst "2024-04-14T00:00:00-07:00"]
|
||||
(get-payment-items c date)
|
||||
|
||||
)
|
||||
:date date}
|
||||
(alog/info ::updating)
|
||||
(let [manual-items (->> existing-summary
|
||||
:sales-summary/items
|
||||
(filter :sales-summary-item/manual?))
|
||||
calculated-items (->>
|
||||
(get-sales c date)
|
||||
(concat (get-payment-items c date))
|
||||
(concat (get-refund-items c date))
|
||||
(cons (get-discounts c date))
|
||||
(cons (get-fees c date))
|
||||
(cons (get-tax c date))
|
||||
(cons (get-tip c date))
|
||||
(cons (get-returns c date))
|
||||
(filter identity)
|
||||
(map (fn [z]
|
||||
(assoc z :ledger-mapped/account (some-> z :sales-summary-item/category str/lower-case name->number lookup-account)
|
||||
:sales-summary-item/manual? false))))
|
||||
all-items (concat calculated-items manual-items)
|
||||
result {:db/id id
|
||||
:sales-summary/client c
|
||||
:sales-summary/date date
|
||||
:sales-summary/dirty false
|
||||
:sales-summary/client+date [c date]
|
||||
:sales-summary/items all-items}]
|
||||
(if (seq (:sales-summary/items result))
|
||||
(do
|
||||
(alog/info ::upserting-summaries
|
||||
:category-count (count (:sales-summary/items result)))
|
||||
@(dc/transact conn [[:upsert-sales-summary result]]))
|
||||
@(dc/transact conn [{:db/id id :sales-summary/dirty false}]))))))
|
||||
|
||||
|
||||
(defn reset-summaries []
|
||||
@@ -334,29 +330,39 @@
|
||||
(comment
|
||||
(auto-ap.datomic/transact-schema conn)
|
||||
|
||||
@(dc/transact conn [{:db/ident :sales-summary/total-unknown-processor-payments
|
||||
:db/noHistory true,
|
||||
:db/valueType :db.type/double
|
||||
:db/cardinality :db.cardinality/one}])
|
||||
|
||||
(apply mark-dirty [:client/code "NGCL"] (last-n-days 30))
|
||||
|
||||
(apply mark-dirty [:client/code "NGDG"] (last-n-days 30))
|
||||
|
||||
(apply mark-dirty [:client/code "NGPG"] (last-n-days 30))
|
||||
(dirty-sales-summaries [:client/code "NGWH"])
|
||||
|
||||
(mark-all-dirty 50)
|
||||
|
||||
(apply mark-dirty [:client/code "NGWH"] (last-n-days 5))
|
||||
|
||||
(iol-ion.tx.upsert-sales-summary-ledger/summary->journal-entry (dc/db conn) 17592314245819)
|
||||
|
||||
|
||||
(iol-ion.tx.upsert-sales-summary-ledger/upsert-sales-summary (dc/db conn) {:db/id 17592314241429})
|
||||
|
||||
|
||||
|
||||
(mark-all-dirty 5)
|
||||
(delete-all)
|
||||
|
||||
|
||||
(sales-summaries-v2)
|
||||
|
||||
|
||||
1
|
||||
|
||||
|
||||
(dc/q '[:find (pull ?sos [* {:sales-summary/sales-items [*]}])
|
||||
:in $
|
||||
:where [?sos :sales-summary/client [:client/code "NGHW"]]
|
||||
[?sos :sales-summary/date ?d]
|
||||
[(= ?d #inst "2024-04-10T00:00:00-07:00")]]
|
||||
(dc/db conn))
|
||||
|
||||
|
||||
(dc/q '[:find ?n ?p2 (sum ?total)
|
||||
:with ?c
|
||||
:in $ [?clients ?start-date ?end-date]
|
||||
@@ -369,18 +375,21 @@
|
||||
(dc/db conn)
|
||||
[[(auto-ap.datomic/pull-attr (dc/db conn) :db/id [:client/code "NGHW"])] #inst "2024-04-11T00:00:00-07:00" #inst "2024-04-11T00:00:00-07:00"])
|
||||
|
||||
(dc/q '[:find ?n
|
||||
(dc/q '[:find ?n
|
||||
:in $ [?clients ?start-date ?end-date]
|
||||
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
||||
[?e :sales-order/line-items ?li]
|
||||
[?li :order-line-item/item-name ?n] ]
|
||||
[?li :order-line-item/item-name ?n]]
|
||||
(dc/db conn)
|
||||
[[(auto-ap.datomic/pull-attr (dc/db conn) :db/id [:client/code "NGCL"])] #inst "2024-04-11T00:00:00-07:00" #inst "2024-04-24T00:00:00-07:00"])
|
||||
|
||||
@(dc/transact conn [{:db/id :sales-summary/total-tax :db/ident :sales-summary/total-tax-legacy}
|
||||
{:db/id :sales-summary/total-tip :db/ident :sales-summary/total-tip-legacy}])
|
||||
|
||||
(auto-ap.datomic/transact-schema conn)
|
||||
@(dc/transact conn [{:db/id :sales-summary/total-tax :db/ident :sales-summary/total-tax-legacy}
|
||||
{:db/id :sales-summary/total-tip :db/ident :sales-summary/total-tip-legacy}])
|
||||
|
||||
(auto-ap.datomic/transact-schema conn)
|
||||
|
||||
|
||||
|
||||
|
||||
)
|
||||
|
||||
|
||||
@@ -35,27 +35,42 @@
|
||||
|
||||
|
||||
invoices-missing-ledger-entries (->> (dc/q {:find ['?t ]
|
||||
:in ['$ '?sd]
|
||||
:where ['[?t :invoice/date ?d]
|
||||
'[(>= ?d ?sd)]
|
||||
'(not [_ :journal-entry/original-entity ?t])
|
||||
'[?t :invoice/total ?amt]
|
||||
'[(not= 0.0 ?amt)]
|
||||
'(not [?t :invoice/status :invoice-status/voided])
|
||||
'(not [?t :invoice/import-status :import-status/pending])
|
||||
'(not [?t :invoice/exclude-from-ledger true])
|
||||
]}
|
||||
(dc/db conn) start-date)
|
||||
:in ['$ '?sd]
|
||||
:where ['[?t :invoice/date ?d]
|
||||
'[(>= ?d ?sd)]
|
||||
'(not [_ :journal-entry/original-entity ?t])
|
||||
'[?t :invoice/total ?amt]
|
||||
'[(not= 0.0 ?amt)]
|
||||
'(not [?t :invoice/status :invoice-status/voided])
|
||||
'(not [?t :invoice/import-status :import-status/pending])
|
||||
'(not [?t :invoice/exclude-from-ledger true])
|
||||
]}
|
||||
(dc/db conn) start-date)
|
||||
(map first)
|
||||
(mapv (fn [i]
|
||||
[:upsert-invoice {:db/id i}])))
|
||||
repairs (vec (concat txes-missing-ledger-entries invoices-missing-ledger-entries))]
|
||||
|
||||
sales-summaries-missing-ledger-entries (->> (dc/q {:find ['?ss ]
|
||||
:in ['$ '?sd]
|
||||
:where ['[?ss :sales-summary/date ?d]
|
||||
'[(>= ?d ?sd)]
|
||||
'(not [_ :journal-entry/original-entity ?ss])
|
||||
'[?ss :sales-summary/items ?item]
|
||||
'[?item :ledger-mapped/account]
|
||||
]}
|
||||
(dc/db conn) start-date)
|
||||
(map first)
|
||||
(mapv (fn [ss]
|
||||
[:upsert-sales-summary {:db/id ss}])))
|
||||
|
||||
repairs (vec (concat txes-missing-ledger-entries invoices-missing-ledger-entries sales-summaries-missing-ledger-entries))]
|
||||
(when (seq repairs)
|
||||
(mu/log ::ledger-repairs-needed
|
||||
:sample (take 3 repairs)
|
||||
:transaction-count (count txes-missing-ledger-entries)
|
||||
:invoice-count (count invoices-missing-ledger-entries))
|
||||
@(dc/transact conn repairs)))))
|
||||
(mu/log ::ledger-repairs-needed
|
||||
:sample (take 3 repairs)
|
||||
:transaction-count (count txes-missing-ledger-entries)
|
||||
:invoice-count (count invoices-missing-ledger-entries)
|
||||
:sales-summary-count (count sales-summaries-missing-ledger-entries))
|
||||
@(dc/transact conn repairs)))))
|
||||
|
||||
|
||||
(defn touch-transaction [e]
|
||||
|
||||
@@ -177,8 +177,9 @@
|
||||
(conj [:paragraph {:color [128 0 0] :size 9} (:warning report)])
|
||||
(conj
|
||||
(table->pdf report
|
||||
(cond-> (into [30 ] (repeat client-count 13))
|
||||
(:include-comparison args) (into (repeat (* 2 client-count) 13))))))
|
||||
(cond-> (into [30 ] (repeat client-count 13))
|
||||
(:include-comparison args) (into (repeat (* 2 client-count) 13))
|
||||
(and (> client-count 1) (not (:include-comparison args))) (conj 13)))))
|
||||
output-stream)
|
||||
(.toByteArray output-stream)))
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
(.setHandler server stats-handler))
|
||||
(.setStopAtShutdown server true))
|
||||
|
||||
(mount/defstate port :start (Integer/parseInt (or (env :port) "3000")))
|
||||
(mount/defstate port :start (Integer/parseInt (str (or (env :port) "3000"))))
|
||||
|
||||
(mount/defstate jetty
|
||||
:start (run-jetty app {:port port
|
||||
|
||||
@@ -1,560 +0,0 @@
|
||||
(ns auto-ap.ssr.admin.sales-summaries
|
||||
(:require
|
||||
[auto-ap.datomic
|
||||
:refer [apply-pagination apply-sort-3 conn merge-query pull-many
|
||||
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]]
|
||||
[auto-ap.routes.admin.sales-summaries :as route]
|
||||
[auto-ap.routes.utils
|
||||
:refer [wrap-admin wrap-client-redirect-unauthenticated]]
|
||||
[auto-ap.ssr-routes :as ssr-routes]
|
||||
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.components.multi-modal :as mm]
|
||||
[auto-ap.ssr.form-cursor :as fc]
|
||||
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [apply-middleware-to-all-handlers clj-date-schema
|
||||
default-grid-fields-schema entity-id html-response money
|
||||
strip temp-id wrap-merge-prior-hx wrap-schema-enforce]]
|
||||
[auto-ap.time :as atime]
|
||||
[bidi.bidi :as bidi]
|
||||
[clj-time.coerce :as c]
|
||||
[clojure.string :as str]
|
||||
[datomic.api :as dc]
|
||||
[hiccup.util :as hu]
|
||||
[iol-ion.query :refer [dollars=]]
|
||||
[malli.core :as mc]
|
||||
[malli.util :as mut]))
|
||||
|
||||
(def query-schema (mc/schema
|
||||
[:maybe
|
||||
(into [:map {:date-range [:date-range :start-date :end-date]}
|
||||
|
||||
[:start-date {:optional true}
|
||||
[:maybe clj-date-schema]]
|
||||
[:end-date {:optional true}
|
||||
[:maybe clj-date-schema]] ]
|
||||
default-grid-fields-schema)]))
|
||||
|
||||
(defn filters [request]
|
||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/table)
|
||||
"hx-target" "#entity-table"
|
||||
"hx-indicator" "#entity-table"}
|
||||
|
||||
#_[:fieldset.space-y-6
|
||||
(date-range-field {:value {:start (:start-date (:query-params request))
|
||||
:end (:end-date (:query-params request))}
|
||||
:id "date-range"})
|
||||
(com/field {:label "Source"}
|
||||
(com/select {:name "source"
|
||||
:class "hot-filter w-full"
|
||||
:value (:source (:query-params request))
|
||||
:placeholder ""
|
||||
:options (ref->select-options "import-source" :allow-nil? true)}))
|
||||
|
||||
#_(com/field {:label "Code"}
|
||||
(com/text-input {:name "code"
|
||||
:id "code"
|
||||
:class "hot-filter"
|
||||
:value (:code (:query-params request))
|
||||
:placeholder "11101"
|
||||
:size :small}))]])
|
||||
|
||||
(def default-read '[:db/id
|
||||
*
|
||||
[:sales-summary/date :xform clj-time.coerce/from-date]
|
||||
{:sales-summary/client [:client/code :client/name :db/id]}
|
||||
{:sales-summary/items [{[:ledger-mapped/ledger-side :xform iol-ion.query/ident] [:db/ident]
|
||||
} ;; TODO clientize
|
||||
:ledger-mapped/account
|
||||
:ledger-mapped/amount
|
||||
:sales-summary-item/category
|
||||
:sales-summary-item/sort-order
|
||||
:db/id
|
||||
:sales-summary-item/manual?]
|
||||
} ]) ;; TODO
|
||||
|
||||
(defn fetch-ids [db request]
|
||||
(let [query-params (:query-params request)
|
||||
valid-clients (extract-client-ids (:clients request)
|
||||
(:client request)
|
||||
(:client-id query-params)
|
||||
(when (:client-code query-params)
|
||||
[:client/code (:client-code query-params)]))
|
||||
query (cond-> {:query {:find []
|
||||
:in '[$ [?client ...]]
|
||||
:where '[[?e :sales-summary/client ?client]]}
|
||||
:args [db valid-clients]}
|
||||
(or (:start-date query-params)
|
||||
(:end-date query-params))
|
||||
(merge-query {:query '{:where [[?e :sales-summary/date ?d]]}})
|
||||
|
||||
(:start-date query-params)
|
||||
(merge-query {:query '{:in [?start-date]
|
||||
:where [[(>= ?d ?start-date)]]}
|
||||
:args [(-> query-params :start-date c/to-date)]})
|
||||
|
||||
(:end-date query-params)
|
||||
(merge-query {:query '{:in [?end-date]
|
||||
:where [[(< ?d ?end-date)]]}
|
||||
:args [(-> query-params :end-date c/to-date)]})
|
||||
|
||||
|
||||
true
|
||||
(merge-query {:query {:find ['?sort-default '?e]
|
||||
:where ['[?e :sales-summary/date ?sort-default]]}}))]
|
||||
(cond->> (query2 query)
|
||||
true (apply-sort-3 query-params)
|
||||
true (apply-pagination query-params))))
|
||||
|
||||
(defn hydrate-results [ids db _]
|
||||
(let [results (->> (pull-many db default-read ids)
|
||||
(group-by :db/id))
|
||||
refunds (->> ids
|
||||
(map results)
|
||||
(map first))]
|
||||
refunds))
|
||||
|
||||
(defn fetch-page [request]
|
||||
(let [db (dc/db conn)
|
||||
{ids-to-retrieve :ids matching-count :count} (fetch-ids db request)]
|
||||
|
||||
[(->> (hydrate-results ids-to-retrieve db request))
|
||||
matching-count]))
|
||||
|
||||
#_(defn get-debits [ss]
|
||||
{:card-payments (+ (:sales-summary/total-card-payments ss 0.0)
|
||||
(:sales-summary/total-card-fees ss 0.0)
|
||||
(- (:sales-summary/total-card-refunds ss 0.0)))
|
||||
:food-app-payments (+ (:sales-summary/total-food-app-payments ss 0.0)
|
||||
(:sales-summary/total-food-app-fees ss 0.0)
|
||||
(- (:sales-summary/total-food-app-refunds ss 0.0)))
|
||||
:gift-card-payments (+ (:sales-summary/total-gift-card-payments ss 0.0)
|
||||
(:sales-summary/total-gift-card-fees ss 0.0)
|
||||
(- (:sales-summary/total-gift-card-refunds ss 0.0)))
|
||||
#_#_:refunds (+ (:sales-summary/total-food-app-refunds ss 0.0)
|
||||
(:sales-summary/total-card-refunds ss 0.0)
|
||||
(:sales-summary/total-cash-refunds ss 0.0))
|
||||
|
||||
:fees (- (:sales-summary/total-card-fees ss 0.0))
|
||||
:cash-payments (+ (:sales-summary/total-cash-payments ss 0.0)
|
||||
(- (:sales-summary/total-cash-refunds ss 0.0)))
|
||||
:total-unknown-processor-payments (:sales-summary/total-unknown-processor-payments ss 0.0)
|
||||
:discounts (+ (:sales-summary/discount ss 0.0))
|
||||
:returns (+ (:sales-summary/total-returns ss 0.0))})
|
||||
(defn sort-items [ss]
|
||||
(sort-by (juxt :ledger-mapped/ledger-side :sales-summary-item/sort-order :sales-summary-item/category) ss))
|
||||
|
||||
|
||||
|
||||
(defn total-debits [items]
|
||||
(->> items
|
||||
(filter #(= :ledger-side/debit (:ledger-mapped/ledger-side %)))
|
||||
(map #(:ledger-mapped/amount % 0.0))
|
||||
(reduce + 0.0)))
|
||||
|
||||
(defn total-credits [items]
|
||||
(->> items
|
||||
(filter #(= :ledger-side/credit (:ledger-mapped/ledger-side %)))
|
||||
(map #(:ledger-mapped/amount % 0.0))
|
||||
(reduce + 0.0)))
|
||||
|
||||
(def grid-page
|
||||
(helper/build {:id "entity-table"
|
||||
:id-fn :db/id
|
||||
:nav com/admin-aside-nav
|
||||
:fetch-page fetch-page
|
||||
:page-specific-nav filters
|
||||
:query-schema query-schema
|
||||
:row-buttons (fn [_ entity]
|
||||
[(com/icon-button {:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/edit-wizard
|
||||
:db/id (:db/id entity))}
|
||||
svg/pencil)])
|
||||
:oob-render
|
||||
(fn [request]
|
||||
[#_(assoc-in (date-range-field {:value {:start (:start-date (:query-params request))
|
||||
:end (:end-date (:query-params request))}
|
||||
:id "date-range"}) [1 :hx-swap-oob] true)]) ;; TODO
|
||||
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes
|
||||
:admin)}
|
||||
"Admin"]
|
||||
|
||||
[:a {:href (bidi/path-for ssr-routes/only-routes
|
||||
::route/page)}
|
||||
"Sales Summaries"]]
|
||||
:title "Sales Summaries"
|
||||
:entity-name "Daily Summary"
|
||||
:route ::route/table
|
||||
:headers [{:key "client"
|
||||
:name "Client"
|
||||
:sort-key "client"
|
||||
:hide? (fn [args]
|
||||
(= (count (:clients args)) 1))
|
||||
:render #(-> % :sales-summary/client :client/code)}
|
||||
|
||||
|
||||
{:key "date"
|
||||
:name "Date"
|
||||
:sort-key "date"
|
||||
:render #(some-> % :sales-summary/date (atime/unparse-local atime/normal-date))}
|
||||
|
||||
|
||||
{:key "debits"
|
||||
:name "debits"
|
||||
:sort-key "debits"
|
||||
:render (fn [ss]
|
||||
(let [total-debits (total-debits (:sales-summary/items ss))
|
||||
total-credits (total-credits (:sales-summary/items ss))]
|
||||
[:ul
|
||||
(for [si (sort-items (:sales-summary/items ss))
|
||||
:when (= :ledger-side/debit (:ledger-mapped/ledger-side si))]
|
||||
[:li (:sales-summary-item/category si) ": " (format "$%,.2f" (:ledger-mapped/amount si))
|
||||
(when-not (:ledger-mapped/account si)
|
||||
[:span.pl-4 (com/pill {:color :red}
|
||||
"missing account")])]
|
||||
)
|
||||
[:li (com/pill {:color (if (dollars= total-debits total-credits)
|
||||
:primary
|
||||
:red)} "Total: " (format "$%,.2f" total-debits))]]))}
|
||||
{:key "credits"
|
||||
:name "credits"
|
||||
:sort-key "credits"
|
||||
:render (fn [ss]
|
||||
(let [total-debits (total-debits (:sales-summary/items ss))
|
||||
total-credits (total-credits (:sales-summary/items ss))]
|
||||
[:ul
|
||||
(for [si (sort-items (:sales-summary/items ss))
|
||||
:when (= :ledger-side/credit (:ledger-mapped/ledger-side si))]
|
||||
[:li (:sales-summary-item/category si) ": " (format "$%,.2f" (:ledger-mapped/amount si))
|
||||
(when-not (:ledger-mapped/account si)
|
||||
[:span.pl-4 (com/pill {:color :red}
|
||||
"missing account")])])
|
||||
[:li (com/pill {:color (if (dollars= total-debits total-credits)
|
||||
:primary
|
||||
:red)} "Total: " (format "$%,.2f" total-credits))]]))}]}))
|
||||
|
||||
;; TODO schema cleanup
|
||||
;; Decide on what should be calculated as generating ledger entries, and what should be calculated
|
||||
;; as part of the summary
|
||||
;; default thought here is that the summary has more detail (e.g., line items), fees broken out by type
|
||||
;; and aggregated into the final ledger entry
|
||||
;; that allows customization at any level.
|
||||
;; TODO rename refunds/returns
|
||||
|
||||
(def row* (partial helper/row* grid-page))
|
||||
(def table* (partial helper/table* grid-page))
|
||||
|
||||
(def edit-schema
|
||||
[:map
|
||||
[:db/id entity-id]
|
||||
[:sales-summary/client [:map [:db/id entity-id]]]
|
||||
[:sales-summary/items
|
||||
[:vector {:coerce? true}
|
||||
[:and
|
||||
[:map
|
||||
[:db/id [:or entity-id temp-id]]
|
||||
[:sales-summary-item/category [:string {:decode/string strip}]]
|
||||
[:sales-summary-item/manual? {:default false :decode/arbitrary (fn [x] (cond
|
||||
(boolean? x)
|
||||
x
|
||||
(nil? x)
|
||||
false
|
||||
(str/blank? x)
|
||||
false
|
||||
:else
|
||||
true))} :boolean]
|
||||
[:ledger-mapped/account entity-id]
|
||||
[:credit {:optional true} [:maybe money]]
|
||||
[:debit {:optional true} [:maybe money]]]
|
||||
[:fn {:error/message "Must choose one of credit/debit"
|
||||
:error/path [:credit]}
|
||||
(fn [x]
|
||||
(not (and (:credit x)
|
||||
(:debit x))))]]]] ])
|
||||
|
||||
|
||||
(defn summary-total-row* [request]
|
||||
(let [total-credits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-credits))
|
||||
total-debits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-debits))]
|
||||
|
||||
(com/data-grid-row {:id "total-row"
|
||||
:hx-trigger "change from:closest form target:.amount-field"
|
||||
:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/expense-account-total)
|
||||
:hx-target "this"
|
||||
:hx-swap "innerHTML"}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"])
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(format "$%,.2f" total-debits))
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(format "$%,.2f" total-credits)))))
|
||||
|
||||
(defn unbalanced-row* [request]
|
||||
(let [total-credits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-credits))
|
||||
total-debits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-debits))]
|
||||
|
||||
(com/data-grid-row {:id "total-row"
|
||||
:hx-trigger "change from:closest form target:.amount-field"
|
||||
:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/expense-account-total)
|
||||
:hx-target "this"
|
||||
:hx-swap "innerHTML"}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "UNBALANCED"])
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(when (and
|
||||
(not (dollars= total-credits total-debits))
|
||||
(> total-debits total-credits))
|
||||
(format "$%,.2f" (- total-debits total-credits))))
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(when
|
||||
(and (not (dollars= total-credits total-debits))
|
||||
(> total-credits total-debits))
|
||||
(format "$%,.2f" (- total-credits total-debits)))))))
|
||||
|
||||
(defn- account-typeahead*
|
||||
[{:keys [name value client-id]}]
|
||||
[:div.flex.flex-col
|
||||
(com/typeahead {:name name
|
||||
:placeholder "Search..."
|
||||
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
|
||||
{:client-id client-id
|
||||
:purpose "invoice"})
|
||||
:value value
|
||||
:content-fn (fn [value]
|
||||
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
|
||||
client-id)))})])
|
||||
|
||||
(defn sales-summary-item-row* [{:keys [value client-id]}]
|
||||
(let [manual? (fc/field-value (:sales-summary-item/manual? value))]
|
||||
(com/data-grid-row (cond-> {:x-ref "p"
|
||||
:x-data (hx/json {})}
|
||||
(fc/field-value (:new? value)) (hx/htmx-transition-appear ))
|
||||
(fc/with-field :db/id
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
(when manual?
|
||||
(fc/with-field :sales-summary-item/manual?
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value true})))
|
||||
(com/data-grid-cell {}
|
||||
(fc/with-field :sales-summary-item/category
|
||||
(if manual?
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/text-input {:placeholder "Category/Explanation"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
|
||||
(list
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)})
|
||||
(fc/field-value (:sales-summary-item/category value))))))
|
||||
(com/data-grid-cell {}
|
||||
(fc/with-field :ledger-mapped/account
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(account-typeahead* {:value (fc/field-value)
|
||||
:client-id client-id
|
||||
:name (fc/field-name)}))))
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
|
||||
(if manual?
|
||||
(fc/with-field :debit
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/money-input {:class "w-24"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))
|
||||
(when (= (fc/field-value (:ledger-mapped/ledger-side value))
|
||||
:ledger-side/debit)
|
||||
(format "$%,.2f" (fc/field-value (:ledger-mapped/amount value))))))
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
|
||||
(if manual?
|
||||
(fc/with-field :credit
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/money-input {:class "w-24"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))
|
||||
(when (= (fc/field-value (:ledger-mapped/ledger-side value))
|
||||
:ledger-side/credit)
|
||||
(format "$%,.2f" (fc/field-value (:ledger-mapped/amount value))))))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(when manual?
|
||||
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x))))))
|
||||
|
||||
(defrecord MainStep [linear-wizard]
|
||||
mm/ModalWizardStep
|
||||
(step-name [_]
|
||||
"Main")
|
||||
(step-key [_]
|
||||
:main)
|
||||
|
||||
(edit-path [_ _]
|
||||
[])
|
||||
|
||||
(step-schema [_]
|
||||
(mut/select-keys (mm/form-schema linear-wizard) #{:db/id :sales-summary/items}))
|
||||
|
||||
(render-step
|
||||
[this {:keys [multi-form-state] :as request}]
|
||||
(mm/default-render-step
|
||||
linear-wizard this
|
||||
:head [:div.p-2 "New invoice"]
|
||||
:body (mm/default-step-body
|
||||
{}
|
||||
[:div
|
||||
(fc/with-field :db/id
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
(com/data-grid {:headers
|
||||
[(com/data-grid-header {} "Category")
|
||||
(com/data-grid-header {} "Account")
|
||||
(com/data-grid-header {} "Debits")
|
||||
(com/data-grid-header {} "Credits")
|
||||
(com/data-grid-header {} "")]}
|
||||
(fc/with-field :sales-summary/items
|
||||
(list
|
||||
(fc/cursor-map #(sales-summary-item-row* {:value %
|
||||
:client-id (:db/id (:sales-summary/client (:snapshot multi-form-state))) }))
|
||||
;; TODO
|
||||
(com/data-grid-new-row {:colspan 5
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-summary-item)
|
||||
:row-offset 0
|
||||
:index (count (fc/field-value))
|
||||
:tr-params {:hx-vals (hx/json {:client-id (:db/id (:sales-summary/client (:snapshot multi-form-state)))})}} ;; TODO
|
||||
"New Summary Item")))
|
||||
(summary-total-row* request)
|
||||
(unbalanced-row* request)) ])
|
||||
|
||||
:footer
|
||||
(mm/default-step-footer linear-wizard this :validation-route ::route/edit-wizard-navigate)
|
||||
:validation-route ::route/edit-wizard-navigate
|
||||
:width-height-class "lg:w-[850px] lg:h-[900px]")))
|
||||
|
||||
(defn attach-ledger [i]
|
||||
(cond-> i
|
||||
(:credit i) (assoc :ledger-mapped/ledger-side :ledger-side/credit
|
||||
:ledger-mapped/amount (:credit i))
|
||||
(:debit i) (assoc :ledger-mapped/ledger-side :ledger-side/debit
|
||||
:ledger-mapped/amount (:debit i))
|
||||
true (dissoc :credit :debit)
|
||||
true (assoc :sales-summary-item/manual? true)))
|
||||
|
||||
(defrecord EditWizard [_ current-step]
|
||||
mm/LinearModalWizard
|
||||
(hydrate-from-request
|
||||
[this request]
|
||||
this)
|
||||
(navigate [this step-key]
|
||||
(assoc this :current-step step-key))
|
||||
(get-current-step
|
||||
[this]
|
||||
(mm/get-step this :main))
|
||||
(render-wizard [this {:keys [multi-form-state] :as request}]
|
||||
(mm/default-render-wizard
|
||||
this request
|
||||
:form-params
|
||||
(-> mm/default-form-props
|
||||
(assoc :hx-put
|
||||
(str (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-submit))))
|
||||
:render-timeline? false))
|
||||
(steps [_]
|
||||
[:main])
|
||||
(get-step [this step-key]
|
||||
(let [step-key-result (mc/parse mm/step-key-schema step-key)
|
||||
[step-key-type step-key] step-key-result]
|
||||
(->MainStep this)))
|
||||
(form-schema [_]
|
||||
edit-schema)
|
||||
(submit [this {:keys [multi-form-state request-method identity] :as request}]
|
||||
(let [result (:snapshot multi-form-state )
|
||||
transaction [:upsert-entity {:db/id (:db/id result)
|
||||
:sales-summary/items (map
|
||||
(fn [i]
|
||||
(if (:sales-summary-item/manual? i)
|
||||
(attach-ledger i)
|
||||
{:db/id (:db/id i)
|
||||
:ledger-mapped/account (:ledger-mapped/account i)
|
||||
}))
|
||||
(:sales-summary/items result))}]]
|
||||
(clojure.pprint/pprint (:sales-summary/items result))
|
||||
@(dc/transact conn [ transaction])
|
||||
(html-response
|
||||
(row* identity (dc/pull (dc/db conn) default-read (:db/id result))
|
||||
{:flash? true
|
||||
:request request})
|
||||
:headers (cond-> {"hx-trigger" "modalclose"
|
||||
"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id result))
|
||||
"hx-reswap" "outerHTML"})))))
|
||||
|
||||
(def edit-wizard (->EditWizard nil nil))
|
||||
|
||||
(defn initial-edit-wizard-state [request]
|
||||
(let [entity (dc/pull (dc/db conn) default-read (:db/id (:route-params request)))
|
||||
entity (select-keys entity (mut/keys edit-schema))
|
||||
entity (update entity :sales-summary/items (comp #(map (fn [x]
|
||||
(if (= :ledger-side/debit (:ledger-mapped/ledger-side x))
|
||||
(assoc x :debit (:ledger-mapped/amount x))
|
||||
(assoc x :credit (:ledger-mapped/amount x))))
|
||||
%) sort-items))]
|
||||
|
||||
(mm/->MultiStepFormState entity [] entity)))
|
||||
|
||||
(def key->handler
|
||||
(apply-middleware-to-all-handlers
|
||||
(->>
|
||||
{::route/page (helper/page-route grid-page)
|
||||
::route/table (helper/table-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)
|
||||
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
|
||||
::route/edit-wizard-navigate (-> mm/next-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/new-summary-item (-> (add-new-entity-handler [:step-params :sales-summary/items]
|
||||
(fn render [cursor request]
|
||||
(sales-summary-item-row*
|
||||
{:value cursor
|
||||
:client-id (:client-id (:query-params request))}))
|
||||
(fn build-new-row [base _]
|
||||
(assoc base :sales-summary-item/manual? true)))
|
||||
(wrap-schema-enforce :query-schema [:map
|
||||
[:client-id {:optional true}
|
||||
[:maybe entity-id]]]))
|
||||
::route/edit-wizard-submit (-> mm/submit-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-decode-multi-form-state))})
|
||||
(fn [h]
|
||||
(-> h
|
||||
(wrap-copy-qp-pqp)
|
||||
(wrap-apply-sort grid-page)
|
||||
(wrap-merge-prior-hx)
|
||||
(wrap-schema-enforce :query-schema query-schema)
|
||||
(wrap-schema-enforce :hx-schema query-schema)
|
||||
(wrap-admin)
|
||||
(wrap-client-redirect-unauthenticated)))))
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
[auto-ap.routes.admin.excel-invoices :as ei-routes]
|
||||
[auto-ap.routes.admin.import-batch :as ib-routes]
|
||||
[auto-ap.routes.admin.transaction-rules :as transaction-rules]
|
||||
[auto-ap.routes.admin.vendors :as v-routes]
|
||||
[auto-ap.routes.admin.vendors :as v-routes]
|
||||
[auto-ap.routes.pos.sales-summaries :as ss-routes]
|
||||
[auto-ap.routes.invoice :as invoice-route]
|
||||
[auto-ap.routes.ledger :as ledger-routes]
|
||||
[auto-ap.routes.outgoing-invoice :as oi-routes]
|
||||
@@ -90,8 +91,8 @@
|
||||
(#{::invoice-route/all-page ::invoice-route/unpaid-page ::invoice-route/voided-page ::invoice-route/paid-page ::oi-routes/new ::invoice-route/import-page :invoice-glimpse :invoice-glimpse-textract-invoice} (:matched-route request))
|
||||
"invoices"
|
||||
|
||||
(#{:pos-sales :pos-expected-deposits :pos-tenders :pos-refunds :pos-cash-drawer-shifts} (:matched-route request))
|
||||
"sales"
|
||||
(#{:pos-sales :pos-expected-deposits :pos-tenders :pos-refunds :pos-cash-drawer-shifts ::ss-routes/page} (:matched-route request))
|
||||
"sales"
|
||||
(#{::payment-routes/all-page ::payment-routes/pending-page ::payment-routes/cleared-page ::payment-routes/voided-page} (:matched-route request))
|
||||
"payments"
|
||||
(#{::ledger-routes/all-page ::ledger-routes/external-page ::ledger-routes/external-import-page ::ledger-routes/balance-sheet ::ledger-routes/cash-flows ::ledger-routes/profit-and-loss} (:matched-route request))
|
||||
@@ -207,12 +208,18 @@
|
||||
:hx-boost "true"}
|
||||
|
||||
"Refunds")
|
||||
(menu-button- {:href (str (bidi/path-for ssr-routes/only-routes
|
||||
:pos-cash-drawer-shifts)
|
||||
"?date-range=week")
|
||||
:active? (= :pos-cash-drawer-shifts (:matched-route request))
|
||||
:hx-boost "true"}
|
||||
"Cash drawer shifts")
|
||||
(menu-button- {:href (str (bidi/path-for ssr-routes/only-routes
|
||||
:pos-cash-drawer-shifts)
|
||||
::ss-routes/page)
|
||||
"?date-range=week")
|
||||
:active? (= :pos-cash-drawer-shifts (:matched-route request))
|
||||
:active? (= ::ss-routes/page (:matched-route request))
|
||||
:hx-boost "true"}
|
||||
"Cash drawer shifts"))))
|
||||
"Summaries"))))
|
||||
|
||||
(menu-button- {"@click.prevent" "if (selected == 'payments') {selected = null } else { selected = 'payments'} "
|
||||
:icon svg/payments}
|
||||
|
||||
@@ -144,10 +144,12 @@
|
||||
[:div.htmx-indicator-hidden.inline-flex.gap-2.items-center.justify-center (into [:div.h-4.w-4] children)]]))
|
||||
|
||||
(defn a-icon-button- [params & children]
|
||||
(into
|
||||
[:a (-> params (update :class str " inline-flex items-center justify-center bg-white dark:bg-gray-600 items-center p-3 text-sm font-medium border border-gray-300 dark:border-gray-700 text-center text-gray-500 hover:text-gray-800 rounded-lg dark:text-gray-400 dark:hover:text-gray-100")
|
||||
(update :href #(or % "")))
|
||||
[:div.h-4.w-4 children]]))
|
||||
(let [class-str (:class params "")
|
||||
has-padding? (re-find #"\bp[x y]?-\d+(\.\d+)?\b" class-str)]
|
||||
(into
|
||||
[:a (-> params (update :class str (if has-padding? "" " p-3") " inline-flex items-center justify-center bg-white dark:bg-gray-600 items-center text-sm font-medium border border-gray-300 dark:border-gray-700 text-center text-gray-500 hover:text-gray-800 rounded-lg dark:text-gray-400 dark:hover:text-gray-100")
|
||||
(update :href #(or % "")))
|
||||
[:div.h-4.w-4 children]])))
|
||||
|
||||
(defn save-button- [params & children]
|
||||
[:button {:class "text-white bg-green-500 hover:bg-green-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center mr-2 dark:bg-green-600 dark:hover:bg-green-700 dark:focus:ring-green-800 inline-flex items-center hover:scale-105 transition duration-300"}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
[auto-ap.ssr.admin.excel-invoice :as admin-excel-invoices]
|
||||
[auto-ap.ssr.admin.history :as history]
|
||||
[auto-ap.ssr.admin.import-batch :as import-batch]
|
||||
[auto-ap.ssr.admin.sales-summaries :as admin-sales-summaries]
|
||||
[auto-ap.ssr.pos.sales-summaries :as pos-sales-summaries]
|
||||
[auto-ap.ssr.admin.transaction-rules :as admin-rules]
|
||||
[auto-ap.ssr.admin.vendors :as admin-vendors]
|
||||
[auto-ap.ssr.auth :as auth]
|
||||
@@ -85,17 +85,17 @@
|
||||
(into company-1099/key->handler)
|
||||
(into invoice/key->handler)
|
||||
(into import-batch/key->handler)
|
||||
(into pos-sales/key->handler)
|
||||
(into pos-expected-deposits/key->handler)
|
||||
(into pos-tenders/key->handler)
|
||||
(into pos-cash-drawer-shifts/key->handler)
|
||||
(into pos-refunds/key->handler)
|
||||
(into users/key->handler)
|
||||
(into admin-accounts/key->handler)
|
||||
(into admin-excel-invoices/key->handler)
|
||||
(into admin/key->handler)
|
||||
(into admin-jobs/key->handler)
|
||||
(into admin-sales-summaries/key->handler)
|
||||
(into pos-sales/key->handler)
|
||||
(into pos-expected-deposits/key->handler)
|
||||
(into pos-tenders/key->handler)
|
||||
(into pos-cash-drawer-shifts/key->handler)
|
||||
(into pos-refunds/key->handler)
|
||||
(into pos-sales-summaries/key->handler)
|
||||
(into users/key->handler)
|
||||
(into admin-accounts/key->handler)
|
||||
(into admin-excel-invoices/key->handler)
|
||||
(into admin/key->handler)
|
||||
(into admin-jobs/key->handler)
|
||||
(into admin-vendors/key->handler)
|
||||
(into admin-clients/key->handler)
|
||||
(into admin-rules/key->handler)
|
||||
|
||||
@@ -120,7 +120,8 @@
|
||||
(list
|
||||
[:div.text-2xl.font-bold.text-gray-600 (str "Balance Sheet - " (str/join ", " (map :client/name client))) ]
|
||||
(rtable/table {:widths (cond-> (into [30 ] (repeat 13 client-count))
|
||||
(> (count date) 1) (into (repeat 13 (* 2 client-count (dec (count date))))))
|
||||
(> (count date) 1) (into (repeat 13 (* 2 client-count (dec (count date)))))
|
||||
(and (> client-count 1) (= (count date) 1)) (conj 13))
|
||||
:investigate-url (bidi.bidi/path-for ssr-routes/only-routes ::route/investigate)
|
||||
:table report
|
||||
:warning (not-empty (str/join "\n " (filter not-empty [warning (:warning report)])))} ))))])
|
||||
@@ -201,8 +202,9 @@
|
||||
(conj [:paragraph {:color [128 0 0] :size 9} (:warning report)])
|
||||
(conj
|
||||
(table->pdf report
|
||||
(cond-> (into [30 ] (repeat client-count 13))
|
||||
(> (count date) 1) (into (repeat (* 2 client-count (dec (count date))) 13 ))))))
|
||||
(cond-> (into [30 ] (repeat client-count 13))
|
||||
(> (count date) 1) (into (repeat (* 2 client-count (dec (count date))) 13 ))
|
||||
(and (> client-count 1) (= (count date) 1)) (conj 13)))))
|
||||
output-stream)
|
||||
(.toByteArray output-stream)))
|
||||
|
||||
|
||||
790
src/clj/auto_ap/ssr/pos/sales_summaries.clj
Normal file
790
src/clj/auto_ap/ssr/pos/sales_summaries.clj
Normal file
@@ -0,0 +1,790 @@
|
||||
(ns auto-ap.ssr.pos.sales-summaries
|
||||
(:require
|
||||
[auto-ap.datomic
|
||||
:refer [apply-pagination apply-sort-3 conn merge-query pull-many
|
||||
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]]
|
||||
[auto-ap.client-routes :as client-routes]
|
||||
[auto-ap.routes.pos.sales-summaries :as route]
|
||||
[auto-ap.ssr-routes :as ssr-routes]
|
||||
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
|
||||
[auto-ap.ssr.components.multi-modal :as mm]
|
||||
[auto-ap.ssr.form-cursor :as fc]
|
||||
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.pos.common
|
||||
:refer [date-range-field*]]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [apply-middleware-to-all-handlers clj-date-schema
|
||||
default-grid-fields-schema entity-id html-response money
|
||||
strip temp-id wrap-merge-prior-hx wrap-schema-enforce]]
|
||||
[auto-ap.time :as atime]
|
||||
[bidi.bidi :as bidi]
|
||||
[clj-time.coerce :as c]
|
||||
[clojure.string :as str]
|
||||
[datomic.api :as dc]
|
||||
[hiccup.util :as hu]
|
||||
[iol-ion.query :refer [dollars= dollars-0?]]
|
||||
[malli.core :as mc]
|
||||
[malli.util :as mut]))
|
||||
|
||||
(def query-schema (mc/schema
|
||||
[:maybe
|
||||
(into [:map {:date-range [:date-range :start-date :end-date]}
|
||||
|
||||
[:start-date {:optional true}
|
||||
[:maybe clj-date-schema]]
|
||||
[:end-date {:optional true}
|
||||
[:maybe clj-date-schema]]]
|
||||
default-grid-fields-schema)]))
|
||||
|
||||
(defn filters [request]
|
||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/table)
|
||||
"hx-target" "#entity-table"
|
||||
"hx-indicator" "#entity-table"}
|
||||
|
||||
[:fieldset.space-y-6
|
||||
(date-range-field* request)]])
|
||||
|
||||
(def default-read '[:db/id
|
||||
*
|
||||
[:sales-summary/date :xform clj-time.coerce/from-date]
|
||||
{:sales-summary/client [:client/code :client/name :db/id]}
|
||||
{:sales-summary/items [{[:ledger-mapped/ledger-side :xform iol-ion.query/ident] [:db/ident]}
|
||||
:ledger-mapped/account
|
||||
:ledger-mapped/amount
|
||||
:sales-summary-item/category
|
||||
:sales-summary-item/sort-order
|
||||
:db/id
|
||||
:sales-summary-item/manual?]}
|
||||
{:journal-entry/original-entity [:db/id]}])
|
||||
|
||||
(defn fetch-ids [db request]
|
||||
(let [query-params (:query-params request)
|
||||
valid-clients (extract-client-ids (:clients request)
|
||||
(:client request)
|
||||
(:client-id query-params)
|
||||
(when (:client-code query-params)
|
||||
[:client/code (:client-code query-params)]))
|
||||
query (cond-> {:query {:find []
|
||||
:in '[$ [?client ...]]
|
||||
:where '[[?e :sales-summary/client ?client]]}
|
||||
:args [db valid-clients]}
|
||||
(or (:start-date query-params)
|
||||
(:end-date query-params))
|
||||
(merge-query {:query '{:where [[?e :sales-summary/date ?d]]}})
|
||||
|
||||
(:start-date query-params)
|
||||
(merge-query {:query '{:in [?start-date]
|
||||
:where [[(>= ?d ?start-date)]]}
|
||||
:args [(-> query-params :start-date c/to-date)]})
|
||||
|
||||
(:end-date query-params)
|
||||
(merge-query {:query '{:in [?end-date]
|
||||
:where [[(< ?d ?end-date)]]}
|
||||
:args [(-> query-params :end-date c/to-date)]})
|
||||
|
||||
true
|
||||
(merge-query {:query {:find ['?sort-default '?e]
|
||||
:where ['[?e :sales-summary/date ?sort-default]]}}))]
|
||||
(cond->> (query2 query)
|
||||
true (apply-sort-3 query-params)
|
||||
true (apply-pagination query-params))))
|
||||
|
||||
(defn hydrate-results [ids db _]
|
||||
(let [results (->> (pull-many db default-read ids)
|
||||
(group-by :db/id))
|
||||
refunds (->> ids
|
||||
(map results)
|
||||
(map first))]
|
||||
refunds))
|
||||
|
||||
(defn fetch-page [request]
|
||||
(let [db (dc/db conn)
|
||||
{ids-to-retrieve :ids matching-count :count} (fetch-ids db request)]
|
||||
|
||||
[(->> (hydrate-results ids-to-retrieve db request))
|
||||
matching-count]))
|
||||
|
||||
(defn sort-items [ss]
|
||||
(sort-by (juxt :ledger-mapped/ledger-side :sales-summary-item/sort-order :sales-summary-item/category) ss))
|
||||
|
||||
(defn total-debits [items]
|
||||
(->> items
|
||||
(filter #(= :ledger-side/debit (:ledger-mapped/ledger-side %)))
|
||||
(map #(:ledger-mapped/amount % 0.0))
|
||||
(reduce + 0.0)))
|
||||
|
||||
(defn total-credits [items]
|
||||
(->> items
|
||||
(filter #(= :ledger-side/credit (:ledger-mapped/ledger-side %)))
|
||||
(map #(:ledger-mapped/amount % 0.0))
|
||||
(reduce + 0.0)))
|
||||
|
||||
(defn truncate [s max-len]
|
||||
(if (> (count s) max-len)
|
||||
(str (subs s 0 (- max-len 3)) "...")
|
||||
s))
|
||||
|
||||
(defn account-typeahead*
|
||||
[{:keys [name value client-id]}]
|
||||
[:div.flex.flex-col
|
||||
(com/typeahead {:name name
|
||||
:placeholder "Search..."
|
||||
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
|
||||
{:client-id client-id
|
||||
:purpose "invoice"})
|
||||
:value value
|
||||
:content-fn (fn [value]
|
||||
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
|
||||
client-id)))})])
|
||||
|
||||
(defn account-display-cell [{:keys [item field-name-prefix client-id]}]
|
||||
(let [account-id (:ledger-mapped/account item)
|
||||
account-name (when account-id
|
||||
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read account-id)
|
||||
client-id)))]
|
||||
[:div.account-cell.flex.items-center.gap-2
|
||||
(com/hidden {:name (str field-name-prefix "[ledger-mapped/account]")
|
||||
:value (or account-id "")})
|
||||
(if account-id
|
||||
[:span.text-sm account-name]
|
||||
(com/pill {:color :red} "Missing acct"))
|
||||
(com/a-icon-button {:class "p-1"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-item-account)
|
||||
:hx-target "closest .account-cell"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-vals (hx/json {:item-index (or (:item-index item) 0)
|
||||
:client-id client-id
|
||||
:current-account-id (or account-id "")})}
|
||||
svg/pencil)]))
|
||||
|
||||
(defn account-edit-cell [{:keys [field-name-prefix client-id current-account-id]}]
|
||||
(let [account-input-name (str field-name-prefix "[ledger-mapped/account]")]
|
||||
[:div.account-cell.flex.flex-col.gap-2
|
||||
(account-typeahead* {:name account-input-name
|
||||
:value current-account-id
|
||||
:client-id client-id})
|
||||
[:div.flex.gap-1
|
||||
(com/a-icon-button {:class "p-1"
|
||||
:hx-put (bidi/path-for ssr-routes/only-routes ::route/save-item-account)
|
||||
:hx-target "closest .account-cell"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest .account-cell"
|
||||
:hx-vals (hx/json {:field-name-prefix field-name-prefix
|
||||
:client-id client-id})}
|
||||
svg/check)
|
||||
(com/a-icon-button {:class "p-1"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/cancel-item-account)
|
||||
:hx-target "closest .account-cell"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-vals (hx/json {:field-name-prefix field-name-prefix
|
||||
:client-id client-id
|
||||
:current-account-id (or current-account-id "")})}
|
||||
svg/x)]]))
|
||||
|
||||
(def grid-page
|
||||
(helper/build {:id "entity-table"
|
||||
:id-fn :db/id
|
||||
:nav com/main-aside-nav
|
||||
:fetch-page fetch-page
|
||||
:page-specific-nav filters
|
||||
:query-schema query-schema
|
||||
:row-buttons (fn [_ entity]
|
||||
[(com/icon-button {:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/edit-wizard
|
||||
:db/id (:db/id entity))}
|
||||
svg/pencil)])
|
||||
:oob-render
|
||||
(fn [request]
|
||||
[(assoc-in (date-range-field* request) [1 :hx-swap-oob] true)])
|
||||
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes
|
||||
:company)}
|
||||
"POS"]
|
||||
|
||||
[:a {:href (bidi/path-for ssr-routes/only-routes
|
||||
::route/page)}
|
||||
"Sales Summaries"]]
|
||||
:title "Sales Summaries"
|
||||
:entity-name "Daily Summary"
|
||||
:route ::route/table
|
||||
:headers [{:key "client"
|
||||
:name "Client"
|
||||
:sort-key "client"
|
||||
:hide? (fn [args]
|
||||
(= (count (:clients args)) 1))
|
||||
:render #(-> % :sales-summary/client :client/code)}
|
||||
|
||||
{:key "date"
|
||||
:name "Date"
|
||||
:sort-key "date"
|
||||
:render #(some-> % :sales-summary/date (atime/unparse-local atime/normal-date))}
|
||||
|
||||
{:key "debits"
|
||||
:name "Debits"
|
||||
:sort-key "debits"
|
||||
:class "w-72 align-top"
|
||||
:render (fn [ss]
|
||||
(let [items (:sales-summary/items ss)
|
||||
debit-items (filter #(= :ledger-side/debit (:ledger-mapped/ledger-side %)) (sort-items items))
|
||||
credit-count (count (filter #(= :ledger-side/credit (:ledger-mapped/ledger-side %)) items))
|
||||
total-debits (total-debits items)]
|
||||
[:div.flex.flex-col.h-full
|
||||
[:ul.flex-grow
|
||||
(for [si debit-items]
|
||||
[:li.flex.items-baseline.gap-2.py-0.5.text-sm.text-gray-700
|
||||
[:span.flex-1.min-w-0.truncate.text-gray-600
|
||||
(:sales-summary-item/category si)]
|
||||
(when-not (:ledger-mapped/account si)
|
||||
[:span.shrink-0 (com/pill {:color :red} "?")])
|
||||
[:span.shrink-0.font-mono.tabular-nums.text-right.text-gray-900.whitespace-nowrap
|
||||
(format "$%,.2f" (:ledger-mapped/amount si))]])
|
||||
(for [_ (range (max 0 (- credit-count (count debit-items))))]
|
||||
[:li.py-0.5.text-sm " "])]
|
||||
[:div.border-t-2.border-gray-300.mt-1.pt-1.flex.justify-between.items-baseline
|
||||
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-gray-500 "Total"]
|
||||
[:span.font-mono.tabular-nums.font-bold.text-gray-900
|
||||
(format "$%,.2f" total-debits)]]]))}
|
||||
|
||||
{:key "credits"
|
||||
:name "Credits"
|
||||
:sort-key "credits"
|
||||
:class "w-72 align-top"
|
||||
:render (fn [ss]
|
||||
(let [items (:sales-summary/items ss)
|
||||
credit-items (filter #(= :ledger-side/credit (:ledger-mapped/ledger-side %)) (sort-items items))
|
||||
debit-count (count (filter #(= :ledger-side/debit (:ledger-mapped/ledger-side %)) items))
|
||||
total-credits (total-credits items)]
|
||||
[:div.flex.flex-col.h-full
|
||||
[:ul.flex-grow
|
||||
(for [si credit-items]
|
||||
[:li.flex.items-baseline.gap-2.py-0.5.text-sm.text-gray-700
|
||||
[:span.flex-1.min-w-0.truncate.text-gray-600
|
||||
(:sales-summary-item/category si)]
|
||||
(when-not (:ledger-mapped/account si)
|
||||
[:span.shrink-0 (com/pill {:color :red} "?")])
|
||||
[:span.shrink-0.font-mono.tabular-nums.text-right.text-gray-900.whitespace-nowrap
|
||||
(format "$%,.2f" (:ledger-mapped/amount si))]])
|
||||
(for [_ (range (max 0 (- debit-count (count credit-items))))]
|
||||
[:li.py-0.5.text-sm " "])]
|
||||
[:div.border-t-2.border-gray-300.mt-1.pt-1.flex.justify-between.items-baseline
|
||||
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-gray-500 "Total"]
|
||||
[:span.font-mono.tabular-nums.font-bold.text-gray-900
|
||||
(format "$%,.2f" total-credits)]]]))}
|
||||
|
||||
{:key "balance"
|
||||
:name "Status"
|
||||
:sort-key "balance"
|
||||
:class "w-28 align-top"
|
||||
:render (fn [ss]
|
||||
(let [items (:sales-summary/items ss)
|
||||
total-debits (total-debits items)
|
||||
total-credits (total-credits items)
|
||||
delta (- total-debits total-credits)
|
||||
balanced? (dollars= total-debits total-credits)
|
||||
missing-account? (some #(not (:ledger-mapped/account %)) items)]
|
||||
[:div.flex.flex-col.items-center.gap-1.pt-2
|
||||
(when missing-account?
|
||||
[:span.inline-block.text-xs.font-semibold.uppercase.tracking-wider.text-amber-800.bg-amber-100.border.border-amber-300.rounded-sm.px-1.5.py-0.5
|
||||
"Missing acct"])
|
||||
(if balanced?
|
||||
(when-not missing-account?
|
||||
[:span.inline-block.text-xs.font-semibold.uppercase.tracking-wider.text-emerald-800.bg-emerald-100.border.border-emerald-300.rounded-sm.px-1.5.py-0.5
|
||||
"Balanced"])
|
||||
[:div.flex.flex-col.items-center
|
||||
[:span.font-mono.tabular-nums.text-red-700.font-bold.text-sm
|
||||
(format "$%,.2f" (Math/abs delta))]
|
||||
[:span.text-xs.uppercase.tracking-wider.text-red-600.font-medium.mt-0.5
|
||||
(if (> total-debits total-credits) "Debit over" "Credit over")]])]))}
|
||||
|
||||
{:key "links"
|
||||
:name "Links"
|
||||
:show-starting "lg"
|
||||
:class "w-8"
|
||||
:render (fn [ss]
|
||||
(let [ledger-entry (:journal-entry/original-entity ss)]
|
||||
(when (seq ledger-entry)
|
||||
(link-dropdown
|
||||
[{:link (hu/url (bidi/path-for client-routes/routes :ledger)
|
||||
{:exact-match-id (:db/id (first ledger-entry))})
|
||||
:color :yellow
|
||||
:content "Ledger entry"}]))))}]}))
|
||||
|
||||
(def row* (partial helper/row* grid-page))
|
||||
(def table* (partial helper/table* grid-page))
|
||||
|
||||
(def edit-schema
|
||||
[:map
|
||||
[:db/id entity-id]
|
||||
[:sales-summary/client [:map [:db/id entity-id]]]
|
||||
[:sales-summary/items
|
||||
[:vector {:coerce? true}
|
||||
[:and
|
||||
[:map
|
||||
[:db/id [:or entity-id temp-id]]
|
||||
[:sales-summary-item/category [:string {:decode/string strip}]]
|
||||
[:sales-summary-item/manual? {:default false :decode/arbitrary (fn [x] (cond
|
||||
(boolean? x)
|
||||
x
|
||||
(nil? x)
|
||||
false
|
||||
(str/blank? x)
|
||||
false
|
||||
:else
|
||||
true))} :boolean]
|
||||
[:ledger-mapped/account entity-id]
|
||||
[:credit {:optional true} [:maybe money]]
|
||||
[:debit {:optional true} [:maybe money]]]
|
||||
[:fn {:error/message "Must choose one of credit/debit"
|
||||
:error/path [:credit]}
|
||||
(fn [x]
|
||||
(not (and (:credit x)
|
||||
(:debit x))))]]]]])
|
||||
|
||||
(defn summary-total-row* [request]
|
||||
(let [total-credits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-credits))
|
||||
total-debits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-debits))]
|
||||
|
||||
(com/data-grid-row {:id "total-row"
|
||||
:class "bg-slate-50 border-t-2 border-slate-300"}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-slate-600
|
||||
"Total"])
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
[:span.font-mono.tabular-nums.font-bold.text-slate-900
|
||||
(format "$%,.2f" total-debits)])
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
[:span.font-mono.tabular-nums.font-bold.text-slate-900
|
||||
(format "$%,.2f" total-credits)])
|
||||
(com/data-grid-cell {}))))
|
||||
|
||||
(defn unbalanced-row* [request]
|
||||
(let [total-credits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-credits))
|
||||
total-debits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-debits))
|
||||
unbalanced? (not (dollars= total-credits total-debits))
|
||||
debit-over? (and unbalanced? (> total-debits total-credits))
|
||||
credit-over? (and unbalanced? (> total-credits total-debits))]
|
||||
|
||||
(com/data-grid-row {:id "unbalanced-row"
|
||||
:class (when unbalanced? "bg-red-50 border-t border-red-200")}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(when unbalanced?
|
||||
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-red-700
|
||||
"Out of balance"]))
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(when debit-over?
|
||||
[:span.font-mono.tabular-nums.font-bold.text-red-700
|
||||
(format "$%,.2f" (- total-debits total-credits))]))
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(when credit-over?
|
||||
[:span.font-mono.tabular-nums.font-bold.text-red-700
|
||||
(format "$%,.2f" (- total-credits total-debits))]))
|
||||
(com/data-grid-cell {}))))
|
||||
|
||||
(defn summary-total-display [request]
|
||||
(let [total-credits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-credits))
|
||||
total-debits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-debits))]
|
||||
[:div.flex.justify-between.text-sm.py-1.border-t.mt-1
|
||||
{:id "total-display"}]
|
||||
[:span.font-semibold "Total"]
|
||||
[:div.flex.gap-8
|
||||
[:span.font-mono (format "$%,.2f" total-debits)]
|
||||
[:span.font-mono (format "$%,.2f" total-credits)]]))
|
||||
|
||||
(defn unbalanced-display [request]
|
||||
(let [total-credits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-credits))
|
||||
total-debits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-debits))
|
||||
delta (- total-debits total-credits)]
|
||||
(when-not (dollars-0? delta)
|
||||
[:div.flex.justify-between.text-sm.py-1
|
||||
{:id "unbalanced-display"}
|
||||
[:span.font-semibold.text-red-600 "Unbalanced"]
|
||||
[:div.flex.gap-8
|
||||
[:span.font-mono (when (pos? delta) (format "$%,.2f" delta))
|
||||
[:span.font-mono (when (neg? delta) (format "$%,.2f" (Math/abs delta)))]]]])))
|
||||
|
||||
(defn sales-summary-item-row* [{:keys [value client-id]}]
|
||||
(let [manual? (fc/field-value (:sales-summary-item/manual? value))]
|
||||
(com/data-grid-row (cond-> {:x-ref "p"
|
||||
:x-data (hx/json {})
|
||||
:class (when manual?
|
||||
"bg-indigo-50/40 border-l-2 border-indigo-300")}
|
||||
(fc/field-value (:new? value)) (hx/htmx-transition-appear))
|
||||
(fc/with-field :db/id
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
(when manual?
|
||||
(fc/with-field :sales-summary-item/manual?
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value true})))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(fc/with-field :sales-summary-item/category
|
||||
(if manual?
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/text-input {:placeholder "Category/Explanation"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
|
||||
(list
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)})
|
||||
[:span.text-sm.text-gray-700
|
||||
(fc/field-value (:sales-summary-item/category value))]))))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(fc/with-field :ledger-mapped/account
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(account-typeahead* {:value (fc/field-value)
|
||||
:client-id client-id
|
||||
:name (fc/field-name)}))))
|
||||
(com/data-grid-cell {:class "text-right align-top"}
|
||||
|
||||
(if manual?
|
||||
(fc/with-field :debit
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))
|
||||
(when (= (fc/field-value (:ledger-mapped/ledger-side value))
|
||||
:ledger-side/debit)
|
||||
[:span.font-mono.tabular-nums.text-gray-900.text-sm.whitespace-nowrap
|
||||
(format "$%,.2f" (fc/field-value (:ledger-mapped/amount value)))])))
|
||||
(com/data-grid-cell {:class "text-right align-top"}
|
||||
|
||||
(if manual?
|
||||
(fc/with-field :credit
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))
|
||||
(when (= (fc/field-value (:ledger-mapped/ledger-side value))
|
||||
:ledger-side/credit)
|
||||
[:span.font-mono.tabular-nums.text-gray-900.text-sm.whitespace-nowrap
|
||||
(format "$%,.2f" (fc/field-value (:ledger-mapped/amount value)))])))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(when manual?
|
||||
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x))))))
|
||||
|
||||
(defrecord MainStep [linear-wizard]
|
||||
mm/ModalWizardStep
|
||||
(step-name [_]
|
||||
"Main")
|
||||
(step-key [_]
|
||||
:main)
|
||||
|
||||
(edit-path [_ _]
|
||||
[])
|
||||
|
||||
(step-schema [_]
|
||||
(mut/select-keys (mm/form-schema linear-wizard) #{:db/id :sales-summary/items}))
|
||||
|
||||
(render-step
|
||||
[this {:keys [multi-form-state] :as request}]
|
||||
(let [client-id (:db/id (:sales-summary/client (:snapshot multi-form-state)))
|
||||
items (:sales-summary/items (:step-params multi-form-state))
|
||||
sorted-items (sort-items items)
|
||||
indexed-items (map-indexed vector sorted-items)
|
||||
debit-items (filter #(= :ledger-side/debit (:ledger-mapped/ledger-side (second %))) indexed-items)
|
||||
credit-items (filter #(= :ledger-side/credit (:ledger-mapped/ledger-side (second %))) indexed-items)
|
||||
max-rows (max (count debit-items) (count credit-items))
|
||||
padded-debits (concat debit-items (repeat (- max-rows (count debit-items)) nil))
|
||||
padded-credits (concat credit-items (repeat (- max-rows (count credit-items)) nil))]
|
||||
(mm/default-render-step
|
||||
linear-wizard this
|
||||
:head [:div.p-2 "Edit Summary"]
|
||||
:body (mm/default-step-body
|
||||
{}
|
||||
[:div
|
||||
(fc/with-field :db/id
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
[:div.grid.grid-cols-2.gap-6
|
||||
[:div
|
||||
[:div.font-semibold.text-sm.mb-2 "Debits"]
|
||||
[:div.space-y-1
|
||||
(for [[actual-idx item] padded-debits]
|
||||
(if item
|
||||
(let [manual? (:sales-summary-item/manual? item)]
|
||||
(if manual?
|
||||
[:div.flex.items-center.gap-2.text-sm {:x-ref "p" :x-data (hx/json {})}
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
|
||||
:value "true"})
|
||||
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
|
||||
item []
|
||||
(fc/with-field :sales-summary-item/category
|
||||
(com/text-input {:placeholder "Category"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)
|
||||
:class "w-32 text-sm"})))
|
||||
(account-typeahead* {:name (str "step-params[sales-summary/items][" actual-idx "][ledger-mapped/account]")
|
||||
:value (:ledger-mapped/account item)
|
||||
:client-id client-id})
|
||||
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
|
||||
item []
|
||||
(fc/with-field :debit
|
||||
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))
|
||||
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x)]
|
||||
[:div.flex.items-center.gap-2.text-sm
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/category]")
|
||||
:value (:sales-summary-item/category item)})
|
||||
[:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)]
|
||||
(account-display-cell {:item (assoc item :item-index actual-idx)
|
||||
:field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]")
|
||||
:client-id client-id})
|
||||
[:span.ml-auto.font-mono.tabular-nums.text-gray-900 (format "$%,.2f" (:ledger-mapped/amount item))]]))
|
||||
[:div.h-6]))]
|
||||
[:div.mt-2.border-t.pt-1
|
||||
(summary-total-display request)
|
||||
(unbalanced-display request)]]
|
||||
[:div
|
||||
[:div.font-semibold.text-sm.mb-2 "Credits"]
|
||||
[:div.space-y-1
|
||||
(for [[actual-idx item] padded-credits]
|
||||
(if item
|
||||
(let [manual? (:sales-summary-item/manual? item)]
|
||||
(if manual?
|
||||
[:div.flex.items-center.gap-2.text-sm {:x-ref "p" :x-data (hx/json {})}
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
|
||||
:value "true"})
|
||||
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
|
||||
item []
|
||||
(fc/with-field :sales-summary-item/category
|
||||
(com/text-input {:placeholder "Category"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)
|
||||
:class "w-32 text-sm"})))
|
||||
(account-typeahead* {:name (str "step-params[sales-summary/items][" actual-idx "][ledger-mapped/account]")
|
||||
:value (:ledger-mapped/account item)
|
||||
:client-id client-id})
|
||||
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
|
||||
item []
|
||||
(fc/with-field :credit
|
||||
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))
|
||||
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x)]
|
||||
[:div.flex.items-center.gap-2.text-sm
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/category]")
|
||||
:value (:sales-summary-item/category item)})
|
||||
[:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)]
|
||||
(account-display-cell {:item (assoc item :item-index actual-idx)
|
||||
:field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]")
|
||||
:client-id client-id})
|
||||
[:span.ml-auto.font-mono.tabular-nums.text-gray-900 (format "$%,.2f" (:ledger-mapped/amount item))]]))
|
||||
[:div.h-6]))]
|
||||
[:div.mt-2.border-t.pt-1
|
||||
(summary-total-display request)
|
||||
(unbalanced-display request)]]]
|
||||
[:div.mt-4.border-t.pt-2
|
||||
(fc/with-field :sales-summary/items
|
||||
(com/data-grid-new-row {:colspan 2
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-summary-item)
|
||||
:row-offset 0
|
||||
:index (count (fc/field-value))
|
||||
:tr-params {:hx-vals (hx/json {:client-id client-id})}}
|
||||
"New Summary Item"))]])
|
||||
|
||||
:footer
|
||||
(mm/default-step-footer linear-wizard this :validation-route ::route/edit-wizard-navigate)
|
||||
:validation-route ::route/edit-wizard-navigate
|
||||
:width-height-class "lg:w-[900px] lg:h-[600px]"))))
|
||||
|
||||
(defn attach-ledger [i]
|
||||
(cond-> i
|
||||
(:credit i) (assoc :ledger-mapped/ledger-side :ledger-side/credit
|
||||
:ledger-mapped/amount (:credit i))
|
||||
(:debit i) (assoc :ledger-mapped/ledger-side :ledger-side/debit
|
||||
:ledger-mapped/amount (:debit i))
|
||||
true (dissoc :credit :debit)
|
||||
true (assoc :sales-summary-item/manual? true)))
|
||||
|
||||
(defrecord EditWizard [_ current-step]
|
||||
mm/LinearModalWizard
|
||||
(hydrate-from-request
|
||||
[this request]
|
||||
this)
|
||||
(navigate [this step-key]
|
||||
(assoc this :current-step step-key))
|
||||
(get-current-step
|
||||
[this]
|
||||
(mm/get-step this :main))
|
||||
(render-wizard [this {:keys [multi-form-state] :as request}]
|
||||
(mm/default-render-wizard
|
||||
this request
|
||||
:form-params
|
||||
(-> mm/default-form-props
|
||||
(assoc :hx-put
|
||||
(str (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-submit))))
|
||||
:render-timeline? false))
|
||||
(steps [_]
|
||||
[:main])
|
||||
(get-step [this step-key]
|
||||
(let [step-key-result (mc/parse mm/step-key-schema step-key)
|
||||
[step-key-type step-key] step-key-result]
|
||||
(->MainStep this)))
|
||||
(form-schema [_]
|
||||
edit-schema)
|
||||
(submit [this {:keys [multi-form-state request-method identity] :as request}]
|
||||
(let [result (:snapshot multi-form-state)
|
||||
transaction [:upsert-sales-summary {:db/id (:db/id result)
|
||||
:sales-summary/items (map
|
||||
(fn [i]
|
||||
(if (:sales-summary-item/manual? i)
|
||||
(attach-ledger i)
|
||||
{:db/id (:db/id i)
|
||||
:ledger-mapped/account (:ledger-mapped/account i)}))
|
||||
(:sales-summary/items result))}]]
|
||||
@(dc/transact conn [transaction])
|
||||
(html-response
|
||||
(row* identity (dc/pull (dc/db conn) default-read (:db/id result))
|
||||
{:flash? true
|
||||
:request request})
|
||||
:headers (cond-> {"hx-trigger" "modalclose"
|
||||
"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id result))
|
||||
"hx-reswap" "outerHTML"})))))
|
||||
|
||||
(def edit-wizard (->EditWizard nil nil))
|
||||
|
||||
(defn initial-edit-wizard-state [request]
|
||||
(let [entity (dc/pull (dc/db conn) default-read (:db/id (:route-params request)))
|
||||
entity (select-keys entity (mut/keys edit-schema))
|
||||
entity (update entity :sales-summary/items (comp #(map (fn [x]
|
||||
(if (= :ledger-side/debit (:ledger-mapped/ledger-side x))
|
||||
(assoc x :debit (:ledger-mapped/amount x))
|
||||
(assoc x :credit (:ledger-mapped/amount x))))
|
||||
%) sort-items))]
|
||||
|
||||
(mm/->MultiStepFormState entity [] entity)))
|
||||
|
||||
(defn edit-item-account [request]
|
||||
(let [{:keys [item-index client-id current-account-id]} (:query-params request)
|
||||
item-index (if (string? item-index) (Integer/parseInt item-index) item-index)
|
||||
field-name-prefix (str "step-params[sales-summary/items][" item-index "]")
|
||||
current-account-id (when (and current-account-id (not= current-account-id ""))
|
||||
(if (string? current-account-id)
|
||||
(Long/parseLong current-account-id)
|
||||
current-account-id))
|
||||
client-id (if (string? client-id) (Long/parseLong client-id) client-id)]
|
||||
(html-response
|
||||
(account-edit-cell {:field-name-prefix field-name-prefix
|
||||
:client-id client-id
|
||||
:current-account-id current-account-id}))))
|
||||
|
||||
(defn save-item-account [request]
|
||||
(let [field-name-prefix (get-in request [:params "field-name-prefix"])
|
||||
client-id (get-in request [:params "client-id"])
|
||||
account-input-name (str field-name-prefix "[ledger-mapped/account]")
|
||||
account-id-str (get-in request [:form-params account-input-name])
|
||||
account-id (when (and account-id-str (not= account-id-str ""))
|
||||
(Long/parseLong account-id-str))
|
||||
item {:ledger-mapped/account account-id
|
||||
:item-index (second (re-find #"\[(\d+)\]" (or field-name-prefix "")))}
|
||||
client-id (if (string? client-id) (Long/parseLong client-id) client-id)]
|
||||
(html-response
|
||||
(account-display-cell {:item item
|
||||
:field-name-prefix field-name-prefix
|
||||
:client-id client-id}))))
|
||||
|
||||
(defn cancel-item-account [request]
|
||||
(let [{:keys [field-name-prefix client-id current-account-id]} (:query-params request)
|
||||
account-id (when (and current-account-id (not= current-account-id ""))
|
||||
(if (string? current-account-id)
|
||||
(Long/parseLong current-account-id)
|
||||
current-account-id))
|
||||
item {:ledger-mapped/account account-id
|
||||
:item-index (second (re-find #"\[(\d+)\]" (or field-name-prefix "")))}
|
||||
client-id (if (string? client-id) (Long/parseLong client-id) client-id)]
|
||||
(html-response
|
||||
(account-display-cell {:item item
|
||||
:field-name-prefix field-name-prefix
|
||||
:client-id client-id}))))
|
||||
|
||||
(def key->handler
|
||||
(apply-middleware-to-all-handlers
|
||||
(->>
|
||||
{::route/page (helper/page-route grid-page)
|
||||
::route/table (helper/table-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)
|
||||
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
|
||||
::route/edit-wizard-navigate (-> mm/next-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/new-summary-item (-> (add-new-entity-handler [:step-params :sales-summary/items]
|
||||
(fn render [cursor request]
|
||||
(sales-summary-item-row*
|
||||
{:value cursor
|
||||
:client-id (:client-id (:query-params request))}))
|
||||
(fn build-new-row [base _]
|
||||
(assoc base :sales-summary-item/manual? true)))
|
||||
(wrap-schema-enforce :query-schema [:map
|
||||
[:client-id {:optional true}
|
||||
[:maybe entity-id]]]))
|
||||
::route/edit-item-account (-> edit-item-account
|
||||
(wrap-schema-enforce :query-schema [:map
|
||||
[:item-index nat-int?]
|
||||
[:client-id {:optional true} [:maybe entity-id]]
|
||||
[:current-account-id {:optional true} [:maybe :string]]]))
|
||||
::route/save-item-account save-item-account
|
||||
::route/cancel-item-account cancel-item-account
|
||||
::route/edit-wizard-submit (-> mm/submit-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-decode-multi-form-state))})
|
||||
(fn [h]
|
||||
(-> h
|
||||
(wrap-copy-qp-pqp)
|
||||
(wrap-apply-sort grid-page)
|
||||
(wrap-merge-prior-hx)
|
||||
(wrap-schema-enforce :query-schema query-schema)
|
||||
(wrap-schema-enforce :hx-schema query-schema)))))
|
||||
@@ -798,30 +798,34 @@
|
||||
|
||||
|
||||
(defn balance-sheet-headers [pnl-data]
|
||||
(let [period-count (count (:periods (:args pnl-data)))]
|
||||
(let [period-count (count (:periods (:args pnl-data)))
|
||||
client-ids (set (map :client-id (:data pnl-data)))
|
||||
client-count (count client-ids)
|
||||
show-total? (and (> client-count 1) (= 1 period-count))]
|
||||
(cond-> []
|
||||
(> (count (set (map :client-id (:data pnl-data)))) 1)
|
||||
(conj (into [{:value "Client"}]
|
||||
(> client-count 1)
|
||||
(conj (cond-> (into [{:value "Client"}]
|
||||
(mapcat identity
|
||||
(for [client client-ids]
|
||||
(cond-> [{:value (str (-> pnl-data :client-codes (get client)))}]
|
||||
(> period-count 1)
|
||||
(into (apply concat (repeat (dec period-count) ["" ""])))))))
|
||||
show-total? (conj {:value "Total" :bold true :border [:left]})))
|
||||
|
||||
(mapcat identity
|
||||
(for [client (set (map :client-id (:data pnl-data))) ]
|
||||
(cond-> [{:value (str (-> pnl-data :client-codes (get client)))}]
|
||||
|
||||
(> period-count 1)
|
||||
(into (apply concat (repeat (dec period-count) ["" ""]))))))))
|
||||
true
|
||||
(conj (into [{:value "Period Ending"}]
|
||||
(for [client (set (map :client-id (:data pnl-data)))
|
||||
(conj (cond-> (into [{:value "Period Ending"}]
|
||||
(for [client client-ids
|
||||
[index p] (map vector (range) (:periods (:args pnl-data)))
|
||||
:let [is-first? (= 0 index)
|
||||
period-date (date->str p)
|
||||
period-headers (if (or is-first?
|
||||
(not (:include-deltas (:args pnl-data))))
|
||||
[{:value period-date}]
|
||||
[{:value period-date}
|
||||
{:value "+/-"}])]
|
||||
[{:value period-date}]
|
||||
[{:value period-date}
|
||||
{:value "+/-"}])]
|
||||
header period-headers]
|
||||
header))))))
|
||||
header))
|
||||
show-total? (conj {:value (date->str (first (:periods (:args pnl-data)))) :border [:left]}))))))
|
||||
|
||||
(defn append-deltas [table]
|
||||
(->> table
|
||||
@@ -890,12 +894,33 @@
|
||||
:rows table})))
|
||||
)
|
||||
|
||||
(defn add-total-border [rows]
|
||||
(map (fn [row]
|
||||
(let [last-idx (dec (count row))]
|
||||
(map-indexed
|
||||
(fn [i cell]
|
||||
(if (= i last-idx)
|
||||
(let [borders (or (:border cell) [])]
|
||||
(assoc cell :border (conj borders :left)))
|
||||
cell))
|
||||
row)))
|
||||
rows))
|
||||
|
||||
(defn summarize-balance-sheet [pnl-data]
|
||||
(let [pnl-datas (for [client-id (set (map :client-id (:data pnl-data)))
|
||||
p (:periods (:args pnl-data))]
|
||||
(-> pnl-data
|
||||
(filter-client client-id)
|
||||
(filter-period p)))]
|
||||
(let [client-ids (set (map :client-id (:data pnl-data)))
|
||||
client-count (count client-ids)
|
||||
period-count (count (:periods (:args pnl-data)))
|
||||
show-total? (and (> client-count 1) (= 1 period-count))
|
||||
pnl-datas (for [client-id client-ids
|
||||
p (:periods (:args pnl-data))]
|
||||
(-> pnl-data
|
||||
(filter-client client-id)
|
||||
(filter-period p)))
|
||||
total-data (when show-total?
|
||||
(-> pnl-data
|
||||
(filter-period (first (:periods (:args pnl-data))))
|
||||
(assoc :cell-args {:bold true})))
|
||||
pnl-datas (concat pnl-datas (when total-data [total-data]))]
|
||||
(let [table (-> []
|
||||
(into (detail-rows pnl-datas
|
||||
:assets
|
||||
@@ -912,10 +937,11 @@
|
||||
(negate #{:cogs :payroll :controllable :fixed-overhead :ownership-controllable}))
|
||||
pnl-datas)
|
||||
"Retained Earnings")))
|
||||
table (if (and (> (count (:periods (:args pnl-data))) 1)
|
||||
table (if (and (> period-count 1)
|
||||
(:include-deltas (:args pnl-data)))
|
||||
(append-deltas table)
|
||||
table)]
|
||||
(append-deltas table)
|
||||
table)
|
||||
table (if show-total? (add-total-border table) table)]
|
||||
{:warning (warning-message pnl-data)
|
||||
:header (balance-sheet-headers pnl-data)
|
||||
:rows table}))
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
(ns auto-ap.routes.admin.sales-summaries)
|
||||
(def routes {"" {:get ::page
|
||||
:put ::edit-wizard-submit}
|
||||
"/table" ::table
|
||||
|
||||
["/" [#"\d+" :db/id]] {:get ::edit-wizard }
|
||||
|
||||
"/edit/navigate" ::edit-wizard-navigate
|
||||
"/edit/sales-summary-item" ::new-summary-item})
|
||||
10
src/cljc/auto_ap/routes/pos/sales_summaries.cljc
Normal file
10
src/cljc/auto_ap/routes/pos/sales_summaries.cljc
Normal file
@@ -0,0 +1,10 @@
|
||||
(ns auto-ap.routes.pos.sales-summaries)
|
||||
(def routes {"" {:get ::page
|
||||
:put ::edit-wizard-submit}
|
||||
"/table" ::table
|
||||
["/" [#"\d+" :db/id]] {:get ::edit-wizard }
|
||||
"/edit/navigate" ::edit-wizard-navigate
|
||||
"/edit/sales-summary-item" ::new-summary-item
|
||||
"/edit/item-account" ::edit-item-account
|
||||
"/edit/save-item-account" ::save-item-account
|
||||
"/edit/cancel-item-account" ::cancel-item-account})
|
||||
@@ -12,7 +12,7 @@
|
||||
[auto-ap.routes.transactions :as t-routes]
|
||||
|
||||
[auto-ap.routes.admin.clients :as ac-routes]
|
||||
[auto-ap.routes.admin.sales-summaries :as ss-routes]
|
||||
[auto-ap.routes.pos.sales-summaries :as ss-routes]
|
||||
[auto-ap.routes.admin.transaction-rules :as tr-routes]))
|
||||
|
||||
(def routes {"impersonate" :impersonate
|
||||
|
||||
@@ -265,7 +265,8 @@ NOTE: Please review the transactions we may have question for you here: https://
|
||||
[:div.notification.is-warning.is-light
|
||||
(:warning report)])
|
||||
[rtable/table {:widths (cond-> (into [30 ] (repeat 13 client-count))
|
||||
(:include-comparison args) (into (repeat 13 (* 2 client-count))))
|
||||
(:include-comparison args) (into (repeat 13 (* 2 client-count)))
|
||||
(and (> client-count 1) (not (:include-comparison args))) (conj 13))
|
||||
:click-event ::investigate-clicked
|
||||
:table report}]]))
|
||||
|
||||
|
||||
@@ -1,5 +1,2 @@
|
||||
#!/bin/bash
|
||||
sudo docker run --rm -ti -v ~/dev/integreat/data/solr:/var/solr --network=bridge -p 8983:8983 bryce-solr
|
||||
#sudo podman container run --user 1000 --privileged --volume /home/notid/dev/integreat/data/solr:/var/solr -p 8983:8983 bryce-solr
|
||||
|
||||
|
||||
sudo docker run --rm -ti -v ~/dev/integreat/data/solr:/var/solr --network=bridge -p 8983:8983 679918342773.dkr.ecr.us-east-1.amazonaws.com/integreat-solr
|
||||
|
||||
Reference in New Issue
Block a user