Compare commits
22 Commits
sales-clea
...
feat/sales
| Author | SHA1 | Date | |
|---|---|---|---|
| f575f425a2 | |||
| 218d0684c0 | |||
| 9153494ed7 | |||
| ea7f46ea8a | |||
| 4597611655 | |||
| 26c9563a03 | |||
| db9018722d | |||
| 0e57550b3c | |||
| 297464c188 | |||
| 6e3a024f66 | |||
|
|
28a755e9a9 | ||
|
|
01347ff3f5 | ||
| 53625e4583 | |||
| 8899c643ed | |||
| c196723913 | |||
| 395e445c99 | |||
| 8a0395dc4a | |||
| 26dbde5bd3 | |||
| 98a3e0dda6 | |||
| f4366fe98e | |||
| d95e24a1d7 | |||
| 37351e5f92 |
174
.claude/skills/clojure-eval/SKILL.md
Normal file
174
.claude/skills/clojure-eval/SKILL.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
---
|
||||||
|
name: clojure-eval
|
||||||
|
description: Evaluate Clojure code via nREPL using clj-nrepl-eval. Use this when you need to test code, check if edited files compile, verify function behavior, or interact with a running REPL session.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Clojure REPL Evaluation
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Use this skill when you need to:
|
||||||
|
- **Verify that edited Clojure files compile and load correctly**
|
||||||
|
- Test function behavior interactively
|
||||||
|
- Check the current state of the REPL
|
||||||
|
- Debug code by evaluating expressions
|
||||||
|
- Require or load namespaces for testing
|
||||||
|
- Validate that code changes work before committing
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
The `clj-nrepl-eval` command evaluates Clojure code against an nREPL server. **Session state persists between evaluations**, so you can require a namespace in one evaluation and use it in subsequent calls. Each host:port combination maintains its own session file.
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### 0. Discover and select nREPL server
|
||||||
|
|
||||||
|
First, discover what nREPL servers are running in the current directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval --discover-ports
|
||||||
|
```
|
||||||
|
|
||||||
|
This will show all nREPL servers (Clojure, Babashka, shadow-cljs, etc.) running in the current project directory.
|
||||||
|
|
||||||
|
**Then use the AskUserQuestion tool:**
|
||||||
|
|
||||||
|
- **If ports are discovered:** Prompt user to select which nREPL port to use:
|
||||||
|
- **question:** "Which nREPL port would you like to use?"
|
||||||
|
- **header:** "nREPL Port"
|
||||||
|
- **options:** Present each discovered port as an option with:
|
||||||
|
- **label:** The port number
|
||||||
|
- **description:** The server type and status (e.g., "Clojure nREPL server in current directory")
|
||||||
|
- Include up to 4 discovered ports as options
|
||||||
|
- The user can select "Other" to enter a custom port number
|
||||||
|
|
||||||
|
- **If no ports are discovered:** Prompt user how to start an nREPL server:
|
||||||
|
- **question:** "No nREPL servers found. How would you like to start one?"
|
||||||
|
- **header:** "Start nREPL"
|
||||||
|
- **options:**
|
||||||
|
- **label:** "deps.edn alias", **description:** "Find and use an nREPL alias in deps.edn"
|
||||||
|
- **label:** "Leiningen", **description:** "Start nREPL using 'lein repl'"
|
||||||
|
- The user can select "Other" for alternative methods or if they already have a server running on a specific port
|
||||||
|
|
||||||
|
IMPORTANT: IF you start a REPL do not supply a port let the nREPL start and return the port that it was started on.
|
||||||
|
|
||||||
|
### 1. Evaluate Clojure Code
|
||||||
|
|
||||||
|
> Evaluation automatically connects to the given port
|
||||||
|
|
||||||
|
Use the `-p` flag to specify the port and pass your Clojure code.
|
||||||
|
|
||||||
|
**Recommended: Pass code as a command-line argument:**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> "(+ 1 2 3)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**For multiple expressions (single line):**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> "(def x 10) (+ x 20)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative: Using heredoc (may require permission approval for multiline commands):**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> <<'EOF'
|
||||||
|
(def x 10)
|
||||||
|
(+ x 20)
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative: Via stdin pipe:**
|
||||||
|
```bash
|
||||||
|
echo "(+ 1 2 3)" | clj-nrepl-eval -p <PORT>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Display nREPL Sessions
|
||||||
|
|
||||||
|
**Discover all nREPL servers in current directory:**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval --discover-ports
|
||||||
|
```
|
||||||
|
Shows all running nREPL servers in the current project directory, including their type (clj/bb/basilisp) and whether they match the current working directory.
|
||||||
|
|
||||||
|
**Check previously connected sessions:**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval --connected-ports
|
||||||
|
```
|
||||||
|
Shows only connections you have made before (appears after first evaluation on a port).
|
||||||
|
|
||||||
|
### 3. Common Patterns
|
||||||
|
|
||||||
|
**Require a namespace (always use :reload to pick up changes):**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> "(require '[my.namespace :as ns] :reload)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test a function after requiring:**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> "(ns/my-function arg1 arg2)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check if a file compiles:**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> "(require 'my.namespace :reload)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multiple expressions:**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> "(def x 10) (* x 2) (+ x 5)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Complex multiline code (using heredoc):**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> <<'EOF'
|
||||||
|
(def x 10)
|
||||||
|
(* x 2)
|
||||||
|
(+ x 5)
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
*Note: Heredoc syntax may require permission approval.*
|
||||||
|
|
||||||
|
**With custom timeout (in milliseconds):**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> --timeout 5000 "(long-running-fn)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reset the session (clears all state):**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> --reset-session
|
||||||
|
clj-nrepl-eval -p <PORT> --reset-session "(def x 1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Options
|
||||||
|
|
||||||
|
- `-p, --port PORT` - nREPL port (required)
|
||||||
|
- `-H, --host HOST` - nREPL host (default: 127.0.0.1)
|
||||||
|
- `-t, --timeout MILLISECONDS` - Timeout (default: 120000 = 2 minutes)
|
||||||
|
- `-r, --reset-session` - Reset the persistent nREPL session
|
||||||
|
- `-c, --connected-ports` - List previously connected nREPL sessions
|
||||||
|
- `-d, --discover-ports` - Discover nREPL servers in current directory
|
||||||
|
- `-h, --help` - Show help message
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- **Prefer command-line arguments:** Pass code as quoted strings: `clj-nrepl-eval -p <PORT> "(+ 1 2 3)"` - works with existing permissions
|
||||||
|
- **Heredoc for complex code:** Use heredoc (`<<'EOF' ... EOF`) for truly multiline code, but note it may require permission approval
|
||||||
|
- **Sessions persist:** State (vars, namespaces, loaded libraries) persists across invocations until the nREPL server restarts or `--reset-session` is used
|
||||||
|
- **Automatic delimiter repair:** The tool automatically repairs missing or mismatched parentheses
|
||||||
|
- **Always use :reload:** When requiring namespaces, use `:reload` to pick up recent changes
|
||||||
|
- **Default timeout:** 2 minutes (120000ms) - increase for long-running operations
|
||||||
|
- **Input precedence:** Command-line arguments take precedence over stdin
|
||||||
|
|
||||||
|
## Typical Workflow
|
||||||
|
|
||||||
|
1. Discover nREPL servers: `clj-nrepl-eval --discover-ports`
|
||||||
|
2. Use **AskUserQuestion** tool to prompt user to select a port
|
||||||
|
3. Require namespace:
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> "(require '[my.ns :as ns] :reload)"
|
||||||
|
```
|
||||||
|
4. Test function:
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> "(ns/my-fn ...)"
|
||||||
|
```
|
||||||
|
5. Iterate: Make changes, re-require with `:reload`, test again
|
||||||
|
|
||||||
82
.claude/skills/clojure-eval/examples.md
Normal file
82
.claude/skills/clojure-eval/examples.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# clj-nrepl-eval Examples
|
||||||
|
|
||||||
|
## Discovery
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval --connected-ports
|
||||||
|
```
|
||||||
|
|
||||||
|
## Heredoc for Multiline Code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p 7888 <<'EOF'
|
||||||
|
(defn greet [name]
|
||||||
|
(str "Hello, " name "!"))
|
||||||
|
|
||||||
|
(greet "Claude")
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### Heredoc Simplifies String Escaping
|
||||||
|
|
||||||
|
Heredoc avoids shell escaping issues with quotes, backslashes, and special characters:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# With heredoc - no escaping needed
|
||||||
|
clj-nrepl-eval -p 7888 <<'EOF'
|
||||||
|
(def regex #"\\d{3}-\\d{4}")
|
||||||
|
(def message "She said \"Hello!\" and waved")
|
||||||
|
(def path "C:\\Users\\name\\file.txt")
|
||||||
|
(println message)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Without heredoc - requires complex escaping
|
||||||
|
clj-nrepl-eval -p 7888 "(def message \"She said \\\"Hello!\\\" and waved\")"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Working with Project Namespaces
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test a function after requiring
|
||||||
|
clj-nrepl-eval -p 7888 <<'EOF'
|
||||||
|
(require '[clojure-mcp-light.delimiter-repair :as dr] :reload)
|
||||||
|
(dr/delimiter-error? "(defn foo [x]")
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify Compilation After Edit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# If this returns nil, the file compiled successfully
|
||||||
|
clj-nrepl-eval -p 7888 "(require 'clojure-mcp-light.hook :reload)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Reset session if state becomes corrupted
|
||||||
|
clj-nrepl-eval -p 7888 --reset-session
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Workflow Patterns
|
||||||
|
|
||||||
|
### Load, Test, Iterate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# After editing a file, reload and test in one command
|
||||||
|
clj-nrepl-eval -p 7888 <<'EOF'
|
||||||
|
(require '[my.namespace :as ns] :reload)
|
||||||
|
(ns/my-function test-data)
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Tests After Changes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p 7888 <<'EOF'
|
||||||
|
(require '[my.project.core :as core] :reload)
|
||||||
|
(require '[my.project.core-test :as test] :reload)
|
||||||
|
(clojure.test/run-tests 'my.project.core-test)
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
201
.claude/skills/invoice-template-creator/SKILL.md
Normal file
201
.claude/skills/invoice-template-creator/SKILL.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
---
|
||||||
|
name: invoice-template-creator
|
||||||
|
description: This skill creates PDF invoice parsing templates for the Integreat system. It should be used when adding support for a new vendor invoice format that needs to be automatically parsed.
|
||||||
|
license: Complete terms in LICENSE.txt
|
||||||
|
---
|
||||||
|
|
||||||
|
# Invoice Template Creator
|
||||||
|
|
||||||
|
This skill automates the creation of invoice parsing templates for the Integreat system. It generates both the template definition and a corresponding test file based on a sample PDF invoice.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Use this skill when you need to add support for a new vendor invoice format that cannot be parsed by existing templates. This typically happens when:
|
||||||
|
|
||||||
|
- A new vendor sends invoices in a unique format
|
||||||
|
- An existing vendor changes their invoice layout
|
||||||
|
- You encounter an invoice that fails to parse with current templates
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before using this skill, ensure you have:
|
||||||
|
|
||||||
|
1. A sample PDF invoice file placed in `dev-resources/` directory
|
||||||
|
2. Identified the vendor name
|
||||||
|
3. Identified unique text patterns in the invoice (phone numbers, addresses, etc.) that can distinguish this vendor
|
||||||
|
4. Know the expected values for key fields (invoice number, date, customer name, total)
|
||||||
|
|
||||||
|
## Usage Workflow
|
||||||
|
|
||||||
|
### Step 1: Analyze the PDF
|
||||||
|
|
||||||
|
First, extract and analyze the PDF text to understand its structure:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdftotext -layout "dev-resources/FILENAME.pdf" -
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for:
|
||||||
|
- **Vendor identifiers**: Phone numbers, addresses, or unique text that identifies this vendor
|
||||||
|
- **Field patterns**: How invoice number, date, customer name, and total appear in the text
|
||||||
|
- **Layout quirks**: Multi-line fields, special formatting, or unusual spacing
|
||||||
|
|
||||||
|
### Step 2: Define Expected Values
|
||||||
|
|
||||||
|
Document the expected values for each field:
|
||||||
|
|
||||||
|
| Field | Expected Value | Notes |
|
||||||
|
|-------|---------------|-------|
|
||||||
|
| Vendor Name | "Vendor Name" | Company name as it should appear |
|
||||||
|
| Invoice Number | "12345" | The invoice identifier |
|
||||||
|
| Date | "01/15/26" | Format found in PDF |
|
||||||
|
| Customer Name | "Customer Name" | As it appears on invoice |
|
||||||
|
| Customer Address | "123 Main St" | Street address if available |
|
||||||
|
| Total | "100.00" | Amount |
|
||||||
|
|
||||||
|
### Step 3: Create the Template and Test
|
||||||
|
|
||||||
|
The skill will:
|
||||||
|
|
||||||
|
1. **Create a test file** at `test/clj/auto_ap/parse/templates_test.clj` (or add to existing)
|
||||||
|
- Test parses the PDF file
|
||||||
|
- Verifies all expected values are extracted correctly
|
||||||
|
- Follows existing test patterns
|
||||||
|
|
||||||
|
2. **Add template to** `src/clj/auto_ap/parse/templates.clj`
|
||||||
|
- Adds entry to `pdf-templates` vector
|
||||||
|
- Includes:
|
||||||
|
- `:vendor` - Vendor name
|
||||||
|
- `:keywords` - Regex patterns to identify this vendor (must match all)
|
||||||
|
- `:extract` - Regex patterns for each field
|
||||||
|
- `:parser` - Optional date/number parsers
|
||||||
|
|
||||||
|
### Step 4: Iterative Refinement
|
||||||
|
|
||||||
|
Run the test to see if it passes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lein test auto-ap.parse.templates-test
|
||||||
|
```
|
||||||
|
|
||||||
|
If it fails, examine the debug output and refine the regex patterns. Common issues:
|
||||||
|
|
||||||
|
- **Template doesn't match**: Keywords don't actually appear in the PDF text
|
||||||
|
- **Field is nil**: Regex capture group doesn't match the actual text format
|
||||||
|
- **Wrong value captured**: Regex is too greedy or matches wrong text
|
||||||
|
|
||||||
|
## Template Structure Reference
|
||||||
|
|
||||||
|
### Basic Template Format
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
{:vendor "Vendor Name"
|
||||||
|
:keywords [#"unique-pattern-1" #"unique-pattern-2"]
|
||||||
|
:extract {:invoice-number #"Invoice\s+#\s+(\d+)"
|
||||||
|
:date #"Date:\s+(\d{2}/\d{2}/\d{2})"
|
||||||
|
:customer-identifier #"Bill To:\s+([A-Za-z\s]+)"
|
||||||
|
:total #"Total:\s+\$([\d,]+\.\d{2})"}
|
||||||
|
:parser {:date [:clj-time "MM/dd/yy"]
|
||||||
|
:total [:trim-commas nil]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Extraction Patterns
|
||||||
|
|
||||||
|
**Invoice Number:**
|
||||||
|
- Look for: `"Invoice #12345"` or `"INV: 12345"`
|
||||||
|
- Pattern: `#"Invoice\s*#?\s*(\d+)"` or `#"INV:\s*(\d+)"`
|
||||||
|
|
||||||
|
**Date:**
|
||||||
|
- Common formats: `"01/15/26"`, `"Jan 15, 2026"`, `"2026-01-15"`
|
||||||
|
- Pattern: `#"(\d{2}/\d{2}/\d{2})"` for MM/dd/yy
|
||||||
|
- Parser: `:date [:clj-time "MM/dd/yy"]`
|
||||||
|
|
||||||
|
**Customer Identifier:**
|
||||||
|
- Look for: `"Bill To: Customer Name"` or `"Sold To: Customer Name"`
|
||||||
|
- Pattern: `#"Bill To:\s+([A-Za-z\s]+?)(?=\s{2,}|\n)"`
|
||||||
|
- Use non-greedy `+?` and lookahead `(?=...)` to stop at boundaries
|
||||||
|
|
||||||
|
**Total:**
|
||||||
|
- Look for: `"Total: $100.00"` or `"Amount Due: 100.00"`
|
||||||
|
- Pattern: `#"Total:\s+\$?([\d,]+\.\d{2})"`
|
||||||
|
- Parser: `:total [:trim-commas nil]` removes commas
|
||||||
|
|
||||||
|
### Advanced Patterns
|
||||||
|
|
||||||
|
**Multi-line customer address:**
|
||||||
|
When customer info spans multiple lines (name + address):
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
:customer-identifier #"(?s)I\s+([A-Z][A-Z\s]+?)\s{2,}.*?L\s+([0-9][A-Z0-9\s]+?)(?=\s{2,}|\n)"
|
||||||
|
:account-number #"(?s)L\s+([0-9][A-Z0-9\s]+?)(?=\s{2,}|\n)"
|
||||||
|
```
|
||||||
|
|
||||||
|
The `(?s)` flag makes `.` match newlines. Use non-greedy `+?` and lookaheads `(?=...)` to capture clean values.
|
||||||
|
|
||||||
|
**Multiple date formats:**
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
:parser {:date [:clj-time ["MM/dd/yy" "yyyy-MM-dd"]]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Credit memos (negative amounts):**
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
:parser {:total [:trim-commas-and-negate nil]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Best Practices
|
||||||
|
|
||||||
|
1. IMPORTANT, CRITICAL!! **Start with a failing test** - Define expected values before implementing
|
||||||
|
2. **Test actual PDF parsing** - Use `parse-file` or `parse` with real PDF text
|
||||||
|
3. **Verify each field individually** - Separate assertions for clarity
|
||||||
|
4. **Handle date comparisons carefully** - Compare year/month/day separately if needed
|
||||||
|
5. **Use `str/trim`** - Account for extra whitespace in extracted values
|
||||||
|
|
||||||
|
## Example Test Structure
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(deftest parse-vendor-invoice-12345
|
||||||
|
(testing "Should parse Vendor invoice with expected values"
|
||||||
|
(let [results (sut/parse-file (io/file "dev-resources/INVOICE.pdf")
|
||||||
|
"INVOICE.pdf")
|
||||||
|
result (first results)]
|
||||||
|
(is (some? results) "Should return results")
|
||||||
|
(is (some? result) "Template should match")
|
||||||
|
(when result
|
||||||
|
(is (= "Vendor Name" (:vendor-code result)))
|
||||||
|
(is (= "12345" (:invoice-number result)))
|
||||||
|
(is (= "Customer Name" (:customer-identifier result)))
|
||||||
|
(is (= "100.00" (:total result)))))))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **Keywords must all match** - Every pattern in `:keywords` must be found in the PDF
|
||||||
|
2. **Capture groups required** - Regexes need `()` to extract values
|
||||||
|
3. **PDF text != visual text** - Layout may differ from what you see visually
|
||||||
|
4. **Greedy quantifiers** - Use `+?` instead of `+` to avoid over-matching
|
||||||
|
5. **Case sensitivity** - Regex is case-sensitive unless you use `(?i)` flag
|
||||||
|
|
||||||
|
## Post-Creation Checklist
|
||||||
|
|
||||||
|
After creating the template:
|
||||||
|
|
||||||
|
- [ ] Test passes: `lein test auto-ap.parse.templates-test`
|
||||||
|
- [ ] Format is correct: `lein cljfmt check`
|
||||||
|
- [ ] Code compiles: `lein check`
|
||||||
|
- [ ] Template is in correct position in `pdf-templates` vector
|
||||||
|
- [ ] Keywords uniquely identify this vendor (won't match other templates)
|
||||||
|
- [ ] Test file follows naming conventions
|
||||||
|
|
||||||
|
## Integration with Workflow
|
||||||
|
|
||||||
|
This skill is typically used as part of a larger workflow:
|
||||||
|
|
||||||
|
1. User provides PDF and requirements
|
||||||
|
2. This skill creates template and test
|
||||||
|
3. User reviews and refines if needed
|
||||||
|
4. Test is run to verify extraction
|
||||||
|
5. Code is committed
|
||||||
|
|
||||||
|
The skill ensures consistency with existing patterns and reduces manual boilerplate when adding new vendor support.
|
||||||
188
.claude/skills/invoice-template-creator/references/examples.md
Normal file
188
.claude/skills/invoice-template-creator/references/examples.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# Invoice Template Examples
|
||||||
|
|
||||||
|
## Simple Single Invoice
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
{:vendor "Gstar Seafood"
|
||||||
|
:keywords [#"G Star Seafood"]
|
||||||
|
:extract {:total #"Total\s{2,}([\d\-,]+\.\d{2,2}+)"
|
||||||
|
:customer-identifier #"(.*?)(?:\s+)Invoice #"
|
||||||
|
:date #"Invoice Date\s{2,}([0-9]+/[0-9]+/[0-9]+)"
|
||||||
|
:invoice-number #"Invoice #\s+(\d+)"}
|
||||||
|
:parser {:date [:clj-time "MM/dd/yyyy"]
|
||||||
|
:total [:trim-commas nil]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-Invoice Statement
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
{:vendor "Southbay Fresh Produce"
|
||||||
|
:keywords [#"(SOUTH BAY FRESH PRODUCE|SOUTH BAY PRODUCE)"]
|
||||||
|
:extract {:date #"^([0-9]+/[0-9]+/[0-9]+)"
|
||||||
|
:customer-identifier #"To:[^\n]*\n\s+([A-Za-z' ]+)\s{2}"
|
||||||
|
:invoice-number #"INV #\/(\d+)"
|
||||||
|
:total #"\$([0-9.]+)\."}
|
||||||
|
:parser {:date [:clj-time "MM/dd/yyyy"]}
|
||||||
|
:multi #"\n"
|
||||||
|
:multi-match? #"^[0-9]+/[0-9]+/[0-9]+\s+INV "}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Customer with Address (Multi-line)
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
{:vendor "Bonanza Produce"
|
||||||
|
:keywords [#"530-544-4136"]
|
||||||
|
:extract {:invoice-number #"NO\s+(\d{8,})\s+\d{2}/\d{2}/\d{2}"
|
||||||
|
:date #"NO\s+\d{8,}\s+(\d{2}/\d{2}/\d{2})"
|
||||||
|
:customer-identifier #"(?s)I\s+([A-Z][A-Z\s]+?)\s{2,}.*?L\s+([0-9][A-Z0-9\s]+?)(?=\s{2,}|\n)"
|
||||||
|
:account-number #"(?s)L\s+([0-9][A-Z0-9\s]+?)(?=\s{2,}|\n)"
|
||||||
|
:total #"SHIPPED\s+[\d\.]+\s+TOTAL\s+([\d\.]+)"}
|
||||||
|
:parser {:date [:clj-time "MM/dd/yy"]
|
||||||
|
:total [:trim-commas nil]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Credit Memo (Negative Amounts)
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
{:vendor "General Produce Company"
|
||||||
|
:keywords [#"916-552-6495"]
|
||||||
|
:extract {:date #"DATE.*\n.*\n.*?([0-9]+/[0-9]+/[0-9]+)"
|
||||||
|
:invoice-number #"CREDIT NO.*\n.*\n.*?(\d{5,}?)\s+"
|
||||||
|
:account-number #"CUST NO.*\n.*\n\s+(\d+)"
|
||||||
|
:total #"TOTAL:\s+\|\s*(.*)"}
|
||||||
|
:parser {:date [:clj-time "MM/dd/yy"]
|
||||||
|
:total [:trim-commas-and-negate nil]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complex Date Parsing
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
{:vendor "Ben E. Keith"
|
||||||
|
:keywords [#"BEN E. KEITH"]
|
||||||
|
:extract {:date #"Customer No Mo Day Yr.*?\n.*?\d{5,}\s{2,}(\d+\s+\d+\s+\d+)"
|
||||||
|
:customer-identifier #"Customer No Mo Day Yr.*?\n.*?(\d{5,})"
|
||||||
|
:invoice-number #"Invoice No.*?\n.*?(\d{8,})"
|
||||||
|
:total #"Total Invoice.*?\n.*?([\-]?[0-9]+\.[0-9]{2,})"}
|
||||||
|
:parser {:date [:month-day-year nil]
|
||||||
|
:total [:trim-commas-and-negate nil]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multiple Date Formats
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
{:vendor "RNDC"
|
||||||
|
:keywords [#"P.O.Box 743564"]
|
||||||
|
:extract {:date #"(?:INVOICE|CREDIT) DATE\n(?:.*?)(\S+)\n"
|
||||||
|
:account-number #"Store Number:\s+(\d+)"
|
||||||
|
:invoice-number #"(?:INVOICE|CREDIT) DATE\n(?:.*?)\s{2,}(\d+?)\s+\S+\n"
|
||||||
|
:total #"Net Amount(?:.*\n){4}(?:.*?)([\-]?[0-9\.]+)\n"}
|
||||||
|
:parser {:date [:clj-time ["MM/dd/yy" "dd-MMM-yy"]]
|
||||||
|
:total [:trim-commas-and-negate nil]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Regex Patterns
|
||||||
|
|
||||||
|
### Phone Numbers
|
||||||
|
```clojure
|
||||||
|
#"\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dollar Amounts
|
||||||
|
```clojure
|
||||||
|
#"\$?([0-9,]+\.[0-9]{2})"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dates (MM/dd/yy)
|
||||||
|
```clojure
|
||||||
|
#"([0-9]{2}/[0-9]{2}/[0-9]{2})"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dates (MM/dd/yyyy)
|
||||||
|
```clojure
|
||||||
|
#"([0-9]{2}/[0-9]{2}/[0-9]{4})"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-line Text (dotall mode)
|
||||||
|
```clojure
|
||||||
|
#"(?s)start.*?end"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Non-greedy Match
|
||||||
|
```clojure
|
||||||
|
#"(pattern.+?)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lookahead Boundary
|
||||||
|
```clojure
|
||||||
|
#"value(?=\s{2,}|\n)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Field Extraction Strategies
|
||||||
|
|
||||||
|
### 1. Simple Line-based
|
||||||
|
Use `[^\n]*` to match until end of line:
|
||||||
|
```clojure
|
||||||
|
#"Invoice:\s+([^\n]+)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Whitespace Boundary
|
||||||
|
Use `(?=\s{2,}|\n)` to stop at multiple spaces or newline:
|
||||||
|
```clojure
|
||||||
|
#"Customer:\s+(.+?)(?=\s{2,}|\n)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Specific Marker
|
||||||
|
Match until a specific pattern is found:
|
||||||
|
```clojure
|
||||||
|
#"(?s)Start(.*?)End"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Multi-part Extraction
|
||||||
|
Use multiple capture groups for related fields:
|
||||||
|
```clojure
|
||||||
|
#"Date:\s+(\d{2})/(\d{2})/(\d{2})"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parser Options
|
||||||
|
|
||||||
|
### Date Parsers
|
||||||
|
- `[:clj-time "MM/dd/yyyy"]` - Standard US date
|
||||||
|
- `[:clj-time "MM/dd/yy"]` - 2-digit year
|
||||||
|
- `[:clj-time "MMM dd, yyyy"]` - Named month
|
||||||
|
- `[:clj-time ["MM/dd/yy" "yyyy-MM-dd"]]` - Multiple formats
|
||||||
|
- `[:month-day-year nil]` - Space-separated (1 15 26)
|
||||||
|
|
||||||
|
### Number Parsers
|
||||||
|
- `[:trim-commas nil]` - Remove commas from numbers
|
||||||
|
- `[:trim-commas-and-negate nil]` - Handle negative/credit amounts
|
||||||
|
- `[:trim-commas-and-remove-dollars nil]` - Remove $ and commas
|
||||||
|
- `nil` - No parsing, return raw string
|
||||||
|
|
||||||
|
## Testing Patterns
|
||||||
|
|
||||||
|
### Basic Test Structure
|
||||||
|
```clojure
|
||||||
|
(deftest parse-vendor-invoice
|
||||||
|
(testing "Should parse vendor invoice"
|
||||||
|
(let [results (sut/parse-file (io/file "dev-resources/INVOICE.pdf")
|
||||||
|
"INVOICE.pdf")
|
||||||
|
result (first results)]
|
||||||
|
(is (some? result))
|
||||||
|
(is (= "Vendor" (:vendor-code result)))
|
||||||
|
(is (= "12345" (:invoice-number result))))))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Date Testing
|
||||||
|
```clojure
|
||||||
|
(let [d (:date result)]
|
||||||
|
(is (= 2026 (time/year d)))
|
||||||
|
(is (= 1 (time/month d)))
|
||||||
|
(is (= 15 (time/day d))))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-field Verification
|
||||||
|
```clojure
|
||||||
|
(is (= "Expected Name" (:customer-identifier result)))
|
||||||
|
(is (= "Expected Street" (str/trim (:account-number result))))
|
||||||
|
(is (= "Expected City, ST 12345" (str/trim (:location result))))
|
||||||
|
```
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
# Use bd merge for beads JSONL files
|
||||||
|
.beads/issues.jsonl merge=beads
|
||||||
174
.opencode/skills/clojure-eval/SKILL.md
Normal file
174
.opencode/skills/clojure-eval/SKILL.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
---
|
||||||
|
name: clojure-eval
|
||||||
|
description: Evaluate Clojure code via nREPL using clj-nrepl-eval. Use this when you need to test code, check if edited files compile, verify function behavior, or interact with a running REPL session.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Clojure REPL Evaluation
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Use this skill when you need to:
|
||||||
|
- **Verify that edited Clojure files compile and load correctly**
|
||||||
|
- Test function behavior interactively
|
||||||
|
- Check the current state of the REPL
|
||||||
|
- Debug code by evaluating expressions
|
||||||
|
- Require or load namespaces for testing
|
||||||
|
- Validate that code changes work before committing
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
The `clj-nrepl-eval` command evaluates Clojure code against an nREPL server. **Session state persists between evaluations**, so you can require a namespace in one evaluation and use it in subsequent calls. Each host:port combination maintains its own session file.
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### 0. Discover and select nREPL server
|
||||||
|
|
||||||
|
First, discover what nREPL servers are running in the current directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval --discover-ports
|
||||||
|
```
|
||||||
|
|
||||||
|
This will show all nREPL servers (Clojure, Babashka, shadow-cljs, etc.) running in the current project directory.
|
||||||
|
|
||||||
|
**Then use the AskUserQuestion tool:**
|
||||||
|
|
||||||
|
- **If ports are discovered:** Prompt user to select which nREPL port to use:
|
||||||
|
- **question:** "Which nREPL port would you like to use?"
|
||||||
|
- **header:** "nREPL Port"
|
||||||
|
- **options:** Present each discovered port as an option with:
|
||||||
|
- **label:** The port number
|
||||||
|
- **description:** The server type and status (e.g., "Clojure nREPL server in current directory")
|
||||||
|
- Include up to 4 discovered ports as options
|
||||||
|
- The user can select "Other" to enter a custom port number
|
||||||
|
|
||||||
|
- **If no ports are discovered:** Prompt user how to start an nREPL server:
|
||||||
|
- **question:** "No nREPL servers found. How would you like to start one?"
|
||||||
|
- **header:** "Start nREPL"
|
||||||
|
- **options:**
|
||||||
|
- **label:** "deps.edn alias", **description:** "Find and use an nREPL alias in deps.edn"
|
||||||
|
- **label:** "Leiningen", **description:** "Start nREPL using 'lein repl'"
|
||||||
|
- The user can select "Other" for alternative methods or if they already have a server running on a specific port
|
||||||
|
|
||||||
|
IMPORTANT: IF you start a REPL do not supply a port let the nREPL start and return the port that it was started on.
|
||||||
|
|
||||||
|
### 1. Evaluate Clojure Code
|
||||||
|
|
||||||
|
> Evaluation automatically connects to the given port
|
||||||
|
|
||||||
|
Use the `-p` flag to specify the port and pass your Clojure code.
|
||||||
|
|
||||||
|
**Recommended: Pass code as a command-line argument:**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> "(+ 1 2 3)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**For multiple expressions (single line):**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> "(def x 10) (+ x 20)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative: Using heredoc (may require permission approval for multiline commands):**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> <<'EOF'
|
||||||
|
(def x 10)
|
||||||
|
(+ x 20)
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative: Via stdin pipe:**
|
||||||
|
```bash
|
||||||
|
echo "(+ 1 2 3)" | clj-nrepl-eval -p <PORT>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Display nREPL Sessions
|
||||||
|
|
||||||
|
**Discover all nREPL servers in current directory:**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval --discover-ports
|
||||||
|
```
|
||||||
|
Shows all running nREPL servers in the current project directory, including their type (clj/bb/basilisp) and whether they match the current working directory.
|
||||||
|
|
||||||
|
**Check previously connected sessions:**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval --connected-ports
|
||||||
|
```
|
||||||
|
Shows only connections you have made before (appears after first evaluation on a port).
|
||||||
|
|
||||||
|
### 3. Common Patterns
|
||||||
|
|
||||||
|
**Require a namespace (always use :reload to pick up changes):**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> "(require '[my.namespace :as ns] :reload)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test a function after requiring:**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> "(ns/my-function arg1 arg2)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check if a file compiles:**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> "(require 'my.namespace :reload)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multiple expressions:**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> "(def x 10) (* x 2) (+ x 5)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Complex multiline code (using heredoc):**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> <<'EOF'
|
||||||
|
(def x 10)
|
||||||
|
(* x 2)
|
||||||
|
(+ x 5)
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
*Note: Heredoc syntax may require permission approval.*
|
||||||
|
|
||||||
|
**With custom timeout (in milliseconds):**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> --timeout 5000 "(long-running-fn)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reset the session (clears all state):**
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> --reset-session
|
||||||
|
clj-nrepl-eval -p <PORT> --reset-session "(def x 1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Options
|
||||||
|
|
||||||
|
- `-p, --port PORT` - nREPL port (required)
|
||||||
|
- `-H, --host HOST` - nREPL host (default: 127.0.0.1)
|
||||||
|
- `-t, --timeout MILLISECONDS` - Timeout (default: 120000 = 2 minutes)
|
||||||
|
- `-r, --reset-session` - Reset the persistent nREPL session
|
||||||
|
- `-c, --connected-ports` - List previously connected nREPL sessions
|
||||||
|
- `-d, --discover-ports` - Discover nREPL servers in current directory
|
||||||
|
- `-h, --help` - Show help message
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- **Prefer command-line arguments:** Pass code as quoted strings: `clj-nrepl-eval -p <PORT> "(+ 1 2 3)"` - works with existing permissions
|
||||||
|
- **Heredoc for complex code:** Use heredoc (`<<'EOF' ... EOF`) for truly multiline code, but note it may require permission approval
|
||||||
|
- **Sessions persist:** State (vars, namespaces, loaded libraries) persists across invocations until the nREPL server restarts or `--reset-session` is used
|
||||||
|
- **Automatic delimiter repair:** The tool automatically repairs missing or mismatched parentheses
|
||||||
|
- **Always use :reload:** When requiring namespaces, use `:reload` to pick up recent changes
|
||||||
|
- **Default timeout:** 2 minutes (120000ms) - increase for long-running operations
|
||||||
|
- **Input precedence:** Command-line arguments take precedence over stdin
|
||||||
|
|
||||||
|
## Typical Workflow
|
||||||
|
|
||||||
|
1. Discover nREPL servers: `clj-nrepl-eval --discover-ports`
|
||||||
|
2. Use **AskUserQuestion** tool to prompt user to select a port
|
||||||
|
3. Require namespace:
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> "(require '[my.ns :as ns] :reload)"
|
||||||
|
```
|
||||||
|
4. Test function:
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p <PORT> "(ns/my-fn ...)"
|
||||||
|
```
|
||||||
|
5. Iterate: Make changes, re-require with `:reload`, test again
|
||||||
|
|
||||||
82
.opencode/skills/clojure-eval/examples.md
Normal file
82
.opencode/skills/clojure-eval/examples.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# clj-nrepl-eval Examples
|
||||||
|
|
||||||
|
## Discovery
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval --connected-ports
|
||||||
|
```
|
||||||
|
|
||||||
|
## Heredoc for Multiline Code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p 7888 <<'EOF'
|
||||||
|
(defn greet [name]
|
||||||
|
(str "Hello, " name "!"))
|
||||||
|
|
||||||
|
(greet "Claude")
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### Heredoc Simplifies String Escaping
|
||||||
|
|
||||||
|
Heredoc avoids shell escaping issues with quotes, backslashes, and special characters:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# With heredoc - no escaping needed
|
||||||
|
clj-nrepl-eval -p 7888 <<'EOF'
|
||||||
|
(def regex #"\\d{3}-\\d{4}")
|
||||||
|
(def message "She said \"Hello!\" and waved")
|
||||||
|
(def path "C:\\Users\\name\\file.txt")
|
||||||
|
(println message)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Without heredoc - requires complex escaping
|
||||||
|
clj-nrepl-eval -p 7888 "(def message \"She said \\\"Hello!\\\" and waved\")"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Working with Project Namespaces
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test a function after requiring
|
||||||
|
clj-nrepl-eval -p 7888 <<'EOF'
|
||||||
|
(require '[clojure-mcp-light.delimiter-repair :as dr] :reload)
|
||||||
|
(dr/delimiter-error? "(defn foo [x]")
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify Compilation After Edit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# If this returns nil, the file compiled successfully
|
||||||
|
clj-nrepl-eval -p 7888 "(require 'clojure-mcp-light.hook :reload)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Reset session if state becomes corrupted
|
||||||
|
clj-nrepl-eval -p 7888 --reset-session
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Workflow Patterns
|
||||||
|
|
||||||
|
### Load, Test, Iterate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# After editing a file, reload and test in one command
|
||||||
|
clj-nrepl-eval -p 7888 <<'EOF'
|
||||||
|
(require '[my.namespace :as ns] :reload)
|
||||||
|
(ns/my-function test-data)
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Tests After Changes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clj-nrepl-eval -p 7888 <<'EOF'
|
||||||
|
(require '[my.project.core :as core] :reload)
|
||||||
|
(require '[my.project.core-test :as test] :reload)
|
||||||
|
(clojure.test/run-tests 'my.project.core-test)
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
127
.opencode/skills/gitea-tea/SKILL.md
Normal file
127
.opencode/skills/gitea-tea/SKILL.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
---
|
||||||
|
name: gitea-tea
|
||||||
|
description: Use tea CLI to create, manage, and checkout Gitea pull requests. Use this when opening a PR, managing PRs, or checking out PRs on the gitea remote (gitea.story-basking.ts.net).
|
||||||
|
---
|
||||||
|
|
||||||
|
# Gitea Tea CLI Skill
|
||||||
|
|
||||||
|
This skill covers using `tea` (Gitea's official CLI) for pull request workflows in this project.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Use this skill when you need to:
|
||||||
|
- Create a PR from a working branch to master on the gitea remote
|
||||||
|
- Open, list, or view PRs
|
||||||
|
- Checkout a PR locally for review or iteration
|
||||||
|
- Manage PR state (close, reopen, merge)
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
The gitea remote is `gitea.story-basking.ts.net` with repo slug `notid/integreat`. The default push remote is **gitea**, NOT origin and NOT deploy.
|
||||||
|
|
||||||
|
In this project's environment:
|
||||||
|
- Gitea login is pre-configured for `gitea.story-basking.ts.net`
|
||||||
|
- Repo slug: `notid/integreat`
|
||||||
|
- Target branch for PRs: `master`
|
||||||
|
- The git remote named `gitea` points to this instance
|
||||||
|
|
||||||
|
## Creating a PR
|
||||||
|
|
||||||
|
Use `tea pulls create` to open a PR from the current branch to master. Always specify `-r notid/integreat -b master`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea pulls create -r notid/integreat -b master --title "Title" --description "Body"
|
||||||
|
```
|
||||||
|
|
||||||
|
Common flags:
|
||||||
|
- `-t, --title` - PR title
|
||||||
|
- `-d, --description` - PR body/description (use heredoc or file for long descriptions)
|
||||||
|
- `-a, --assignees` - Comma-separated usernames to assign
|
||||||
|
- `-L, --labels` - Comma-separated labels to apply
|
||||||
|
- `-m, --milestone` - Milestone to assign
|
||||||
|
|
||||||
|
**Writing a multiline description:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea pulls create -r notid/integreat -b master \
|
||||||
|
-t "feat: add feature" \
|
||||||
|
-d "$(cat <<'EOF'
|
||||||
|
## Summary
|
||||||
|
- Bullet point one
|
||||||
|
- Bullet point two
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or write the body to a temp file first and reference it.
|
||||||
|
|
||||||
|
## Listing PRs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea pulls list -r notid/integreat # List open PRs
|
||||||
|
tea pulls list -r notid/integreat --state all # All PRs
|
||||||
|
tea pulls list -r notid/integreat --limit 10 -o simple # Limit output, simple format
|
||||||
|
```
|
||||||
|
|
||||||
|
## Opening a PR in Browser
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea open pr <number> -r notid/integreat
|
||||||
|
tea open pr create -r notid/integreat # Open web UI to create a PR
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checking Out a PR Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea pulls checkout <number> -r notid/integreat
|
||||||
|
```
|
||||||
|
|
||||||
|
This fetches and checks out the PR branch locally.
|
||||||
|
|
||||||
|
## Managing PR State
|
||||||
|
|
||||||
|
**Close a PR:**
|
||||||
|
```bash
|
||||||
|
tea pulls close <number> -r notid/integreat --confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reopen a closed PR:**
|
||||||
|
```bash
|
||||||
|
tea pulls reopen <number> -r notid/integreat --confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
**Merge a PR:**
|
||||||
|
```bash
|
||||||
|
tea pulls merge <number> -r notid/integreat --confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
**Edit a PR (title, description, etc.):**
|
||||||
|
```bash
|
||||||
|
tea pulls edit <number> -r notid/integreat --title "New title" --description "New body"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full PR Creation Workflow
|
||||||
|
|
||||||
|
1. Ensure the branch is pushed to gitea:
|
||||||
|
```bash
|
||||||
|
git push gitea <branch-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create the PR with tea:
|
||||||
|
```bash
|
||||||
|
tea pulls create -r notid/integreat -b master \
|
||||||
|
--title "feat: description of change" \
|
||||||
|
--description "Detailed PR body here"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Open the PR in browser to verify:
|
||||||
|
```bash
|
||||||
|
tea open pr <number> -r notid/integreat
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Always use `-r notid/integreat` to specify the repo explicitly
|
||||||
|
- Use `-b master` to set the target branch (default may differ)
|
||||||
|
- The `--confirm` flag is required for destructive actions (close, merge)
|
||||||
|
- Use `-o simple`, `-o json`, `-o table`, etc. to control output format
|
||||||
201
.opencode/skills/invoice-template-creator/SKILL.md
Normal file
201
.opencode/skills/invoice-template-creator/SKILL.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
---
|
||||||
|
name: invoice-template-creator
|
||||||
|
description: This skill creates PDF invoice parsing templates for the Integreat system. It should be used when adding support for a new vendor invoice format that needs to be automatically parsed.
|
||||||
|
license: Complete terms in LICENSE.txt
|
||||||
|
---
|
||||||
|
|
||||||
|
# Invoice Template Creator
|
||||||
|
|
||||||
|
This skill automates the creation of invoice parsing templates for the Integreat system. It generates both the template definition and a corresponding test file based on a sample PDF invoice.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Use this skill when you need to add support for a new vendor invoice format that cannot be parsed by existing templates. This typically happens when:
|
||||||
|
|
||||||
|
- A new vendor sends invoices in a unique format
|
||||||
|
- An existing vendor changes their invoice layout
|
||||||
|
- You encounter an invoice that fails to parse with current templates
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before using this skill, ensure you have:
|
||||||
|
|
||||||
|
1. A sample PDF invoice file placed in `dev-resources/` directory
|
||||||
|
2. Identified the vendor name
|
||||||
|
3. Identified unique text patterns in the invoice (phone numbers, addresses, etc.) that can distinguish this vendor
|
||||||
|
4. Know the expected values for key fields (invoice number, date, customer name, total)
|
||||||
|
|
||||||
|
## Usage Workflow
|
||||||
|
|
||||||
|
### Step 1: Analyze the PDF
|
||||||
|
|
||||||
|
First, extract and analyze the PDF text to understand its structure:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdftotext -layout "dev-resources/FILENAME.pdf" -
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for:
|
||||||
|
- **Vendor identifiers**: Phone numbers, addresses, or unique text that identifies this vendor
|
||||||
|
- **Field patterns**: How invoice number, date, customer name, and total appear in the text
|
||||||
|
- **Layout quirks**: Multi-line fields, special formatting, or unusual spacing
|
||||||
|
|
||||||
|
### Step 2: Define Expected Values
|
||||||
|
|
||||||
|
Document the expected values for each field:
|
||||||
|
|
||||||
|
| Field | Expected Value | Notes |
|
||||||
|
|-------|---------------|-------|
|
||||||
|
| Vendor Name | "Vendor Name" | Company name as it should appear |
|
||||||
|
| Invoice Number | "12345" | The invoice identifier |
|
||||||
|
| Date | "01/15/26" | Format found in PDF |
|
||||||
|
| Customer Name | "Customer Name" | As it appears on invoice |
|
||||||
|
| Customer Address | "123 Main St" | Street address if available |
|
||||||
|
| Total | "100.00" | Amount |
|
||||||
|
|
||||||
|
### Step 3: Create the Template and Test
|
||||||
|
|
||||||
|
The skill will:
|
||||||
|
|
||||||
|
1. **Create a test file** at `test/clj/auto_ap/parse/templates_test.clj` (or add to existing)
|
||||||
|
- Test parses the PDF file
|
||||||
|
- Verifies all expected values are extracted correctly
|
||||||
|
- Follows existing test patterns
|
||||||
|
|
||||||
|
2. **Add template to** `src/clj/auto_ap/parse/templates.clj`
|
||||||
|
- Adds entry to `pdf-templates` vector
|
||||||
|
- Includes:
|
||||||
|
- `:vendor` - Vendor name
|
||||||
|
- `:keywords` - Regex patterns to identify this vendor (must match all)
|
||||||
|
- `:extract` - Regex patterns for each field
|
||||||
|
- `:parser` - Optional date/number parsers
|
||||||
|
|
||||||
|
### Step 4: Iterative Refinement
|
||||||
|
|
||||||
|
Run the test to see if it passes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lein test auto-ap.parse.templates-test
|
||||||
|
```
|
||||||
|
|
||||||
|
If it fails, examine the debug output and refine the regex patterns. Common issues:
|
||||||
|
|
||||||
|
- **Template doesn't match**: Keywords don't actually appear in the PDF text
|
||||||
|
- **Field is nil**: Regex capture group doesn't match the actual text format
|
||||||
|
- **Wrong value captured**: Regex is too greedy or matches wrong text
|
||||||
|
|
||||||
|
## Template Structure Reference
|
||||||
|
|
||||||
|
### Basic Template Format
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
{:vendor "Vendor Name"
|
||||||
|
:keywords [#"unique-pattern-1" #"unique-pattern-2"]
|
||||||
|
:extract {:invoice-number #"Invoice\s+#\s+(\d+)"
|
||||||
|
:date #"Date:\s+(\d{2}/\d{2}/\d{2})"
|
||||||
|
:customer-identifier #"Bill To:\s+([A-Za-z\s]+)"
|
||||||
|
:total #"Total:\s+\$([\d,]+\.\d{2})"}
|
||||||
|
:parser {:date [:clj-time "MM/dd/yy"]
|
||||||
|
:total [:trim-commas nil]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Extraction Patterns
|
||||||
|
|
||||||
|
**Invoice Number:**
|
||||||
|
- Look for: `"Invoice #12345"` or `"INV: 12345"`
|
||||||
|
- Pattern: `#"Invoice\s*#?\s*(\d+)"` or `#"INV:\s*(\d+)"`
|
||||||
|
|
||||||
|
**Date:**
|
||||||
|
- Common formats: `"01/15/26"`, `"Jan 15, 2026"`, `"2026-01-15"`
|
||||||
|
- Pattern: `#"(\d{2}/\d{2}/\d{2})"` for MM/dd/yy
|
||||||
|
- Parser: `:date [:clj-time "MM/dd/yy"]`
|
||||||
|
|
||||||
|
**Customer Identifier:**
|
||||||
|
- Look for: `"Bill To: Customer Name"` or `"Sold To: Customer Name"`
|
||||||
|
- Pattern: `#"Bill To:\s+([A-Za-z\s]+?)(?=\s{2,}|\n)"`
|
||||||
|
- Use non-greedy `+?` and lookahead `(?=...)` to stop at boundaries
|
||||||
|
|
||||||
|
**Total:**
|
||||||
|
- Look for: `"Total: $100.00"` or `"Amount Due: 100.00"`
|
||||||
|
- Pattern: `#"Total:\s+\$?([\d,]+\.\d{2})"`
|
||||||
|
- Parser: `:total [:trim-commas nil]` removes commas
|
||||||
|
|
||||||
|
### Advanced Patterns
|
||||||
|
|
||||||
|
**Multi-line customer address:**
|
||||||
|
When customer info spans multiple lines (name + address):
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
:customer-identifier #"(?s)I\s+([A-Z][A-Z\s]+?)\s{2,}.*?L\s+([0-9][A-Z0-9\s]+?)(?=\s{2,}|\n)"
|
||||||
|
:account-number #"(?s)L\s+([0-9][A-Z0-9\s]+?)(?=\s{2,}|\n)"
|
||||||
|
```
|
||||||
|
|
||||||
|
The `(?s)` flag makes `.` match newlines. Use non-greedy `+?` and lookaheads `(?=...)` to capture clean values.
|
||||||
|
|
||||||
|
**Multiple date formats:**
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
:parser {:date [:clj-time ["MM/dd/yy" "yyyy-MM-dd"]]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Credit memos (negative amounts):**
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
:parser {:total [:trim-commas-and-negate nil]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Best Practices
|
||||||
|
|
||||||
|
1. IMPORTANT, CRITICAL!! **Start with a failing test** - Define expected values before implementing
|
||||||
|
2. **Test actual PDF parsing** - Use `parse-file` or `parse` with real PDF text
|
||||||
|
3. **Verify each field individually** - Separate assertions for clarity
|
||||||
|
4. **Handle date comparisons carefully** - Compare year/month/day separately if needed
|
||||||
|
5. **Use `str/trim`** - Account for extra whitespace in extracted values
|
||||||
|
|
||||||
|
## Example Test Structure
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(deftest parse-vendor-invoice-12345
|
||||||
|
(testing "Should parse Vendor invoice with expected values"
|
||||||
|
(let [results (sut/parse-file (io/file "dev-resources/INVOICE.pdf")
|
||||||
|
"INVOICE.pdf")
|
||||||
|
result (first results)]
|
||||||
|
(is (some? results) "Should return results")
|
||||||
|
(is (some? result) "Template should match")
|
||||||
|
(when result
|
||||||
|
(is (= "Vendor Name" (:vendor-code result)))
|
||||||
|
(is (= "12345" (:invoice-number result)))
|
||||||
|
(is (= "Customer Name" (:customer-identifier result)))
|
||||||
|
(is (= "100.00" (:total result)))))))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **Keywords must all match** - Every pattern in `:keywords` must be found in the PDF
|
||||||
|
2. **Capture groups required** - Regexes need `()` to extract values
|
||||||
|
3. **PDF text != visual text** - Layout may differ from what you see visually
|
||||||
|
4. **Greedy quantifiers** - Use `+?` instead of `+` to avoid over-matching
|
||||||
|
5. **Case sensitivity** - Regex is case-sensitive unless you use `(?i)` flag
|
||||||
|
|
||||||
|
## Post-Creation Checklist
|
||||||
|
|
||||||
|
After creating the template:
|
||||||
|
|
||||||
|
- [ ] Test passes: `lein test auto-ap.parse.templates-test`
|
||||||
|
- [ ] Format is correct: `lein cljfmt check`
|
||||||
|
- [ ] Code compiles: `lein check`
|
||||||
|
- [ ] Template is in correct position in `pdf-templates` vector
|
||||||
|
- [ ] Keywords uniquely identify this vendor (won't match other templates)
|
||||||
|
- [ ] Test file follows naming conventions
|
||||||
|
|
||||||
|
## Integration with Workflow
|
||||||
|
|
||||||
|
This skill is typically used as part of a larger workflow:
|
||||||
|
|
||||||
|
1. User provides PDF and requirements
|
||||||
|
2. This skill creates template and test
|
||||||
|
3. User reviews and refines if needed
|
||||||
|
4. Test is run to verify extraction
|
||||||
|
5. Code is committed
|
||||||
|
|
||||||
|
The skill ensures consistency with existing patterns and reduces manual boilerplate when adding new vendor support.
|
||||||
188
.opencode/skills/invoice-template-creator/references/examples.md
Normal file
188
.opencode/skills/invoice-template-creator/references/examples.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# Invoice Template Examples
|
||||||
|
|
||||||
|
## Simple Single Invoice
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
{:vendor "Gstar Seafood"
|
||||||
|
:keywords [#"G Star Seafood"]
|
||||||
|
:extract {:total #"Total\s{2,}([\d\-,]+\.\d{2,2}+)"
|
||||||
|
:customer-identifier #"(.*?)(?:\s+)Invoice #"
|
||||||
|
:date #"Invoice Date\s{2,}([0-9]+/[0-9]+/[0-9]+)"
|
||||||
|
:invoice-number #"Invoice #\s+(\d+)"}
|
||||||
|
:parser {:date [:clj-time "MM/dd/yyyy"]
|
||||||
|
:total [:trim-commas nil]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-Invoice Statement
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
{:vendor "Southbay Fresh Produce"
|
||||||
|
:keywords [#"(SOUTH BAY FRESH PRODUCE|SOUTH BAY PRODUCE)"]
|
||||||
|
:extract {:date #"^([0-9]+/[0-9]+/[0-9]+)"
|
||||||
|
:customer-identifier #"To:[^\n]*\n\s+([A-Za-z' ]+)\s{2}"
|
||||||
|
:invoice-number #"INV #\/(\d+)"
|
||||||
|
:total #"\$([0-9.]+)\."}
|
||||||
|
:parser {:date [:clj-time "MM/dd/yyyy"]}
|
||||||
|
:multi #"\n"
|
||||||
|
:multi-match? #"^[0-9]+/[0-9]+/[0-9]+\s+INV "}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Customer with Address (Multi-line)
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
{:vendor "Bonanza Produce"
|
||||||
|
:keywords [#"530-544-4136"]
|
||||||
|
:extract {:invoice-number #"NO\s+(\d{8,})\s+\d{2}/\d{2}/\d{2}"
|
||||||
|
:date #"NO\s+\d{8,}\s+(\d{2}/\d{2}/\d{2})"
|
||||||
|
:customer-identifier #"(?s)I\s+([A-Z][A-Z\s]+?)\s{2,}.*?L\s+([0-9][A-Z0-9\s]+?)(?=\s{2,}|\n)"
|
||||||
|
:account-number #"(?s)L\s+([0-9][A-Z0-9\s]+?)(?=\s{2,}|\n)"
|
||||||
|
:total #"SHIPPED\s+[\d\.]+\s+TOTAL\s+([\d\.]+)"}
|
||||||
|
:parser {:date [:clj-time "MM/dd/yy"]
|
||||||
|
:total [:trim-commas nil]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Credit Memo (Negative Amounts)
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
{:vendor "General Produce Company"
|
||||||
|
:keywords [#"916-552-6495"]
|
||||||
|
:extract {:date #"DATE.*\n.*\n.*?([0-9]+/[0-9]+/[0-9]+)"
|
||||||
|
:invoice-number #"CREDIT NO.*\n.*\n.*?(\d{5,}?)\s+"
|
||||||
|
:account-number #"CUST NO.*\n.*\n\s+(\d+)"
|
||||||
|
:total #"TOTAL:\s+\|\s*(.*)"}
|
||||||
|
:parser {:date [:clj-time "MM/dd/yy"]
|
||||||
|
:total [:trim-commas-and-negate nil]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complex Date Parsing
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
{:vendor "Ben E. Keith"
|
||||||
|
:keywords [#"BEN E. KEITH"]
|
||||||
|
:extract {:date #"Customer No Mo Day Yr.*?\n.*?\d{5,}\s{2,}(\d+\s+\d+\s+\d+)"
|
||||||
|
:customer-identifier #"Customer No Mo Day Yr.*?\n.*?(\d{5,})"
|
||||||
|
:invoice-number #"Invoice No.*?\n.*?(\d{8,})"
|
||||||
|
:total #"Total Invoice.*?\n.*?([\-]?[0-9]+\.[0-9]{2,})"}
|
||||||
|
:parser {:date [:month-day-year nil]
|
||||||
|
:total [:trim-commas-and-negate nil]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multiple Date Formats
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
{:vendor "RNDC"
|
||||||
|
:keywords [#"P.O.Box 743564"]
|
||||||
|
:extract {:date #"(?:INVOICE|CREDIT) DATE\n(?:.*?)(\S+)\n"
|
||||||
|
:account-number #"Store Number:\s+(\d+)"
|
||||||
|
:invoice-number #"(?:INVOICE|CREDIT) DATE\n(?:.*?)\s{2,}(\d+?)\s+\S+\n"
|
||||||
|
:total #"Net Amount(?:.*\n){4}(?:.*?)([\-]?[0-9\.]+)\n"}
|
||||||
|
:parser {:date [:clj-time ["MM/dd/yy" "dd-MMM-yy"]]
|
||||||
|
:total [:trim-commas-and-negate nil]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Regex Patterns
|
||||||
|
|
||||||
|
### Phone Numbers
|
||||||
|
```clojure
|
||||||
|
#"\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dollar Amounts
|
||||||
|
```clojure
|
||||||
|
#"\$?([0-9,]+\.[0-9]{2})"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dates (MM/dd/yy)
|
||||||
|
```clojure
|
||||||
|
#"([0-9]{2}/[0-9]{2}/[0-9]{2})"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dates (MM/dd/yyyy)
|
||||||
|
```clojure
|
||||||
|
#"([0-9]{2}/[0-9]{2}/[0-9]{4})"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-line Text (dotall mode)
|
||||||
|
```clojure
|
||||||
|
#"(?s)start.*?end"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Non-greedy Match
|
||||||
|
```clojure
|
||||||
|
#"(pattern.+?)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lookahead Boundary
|
||||||
|
```clojure
|
||||||
|
#"value(?=\s{2,}|\n)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Field Extraction Strategies
|
||||||
|
|
||||||
|
### 1. Simple Line-based
|
||||||
|
Use `[^\n]*` to match until end of line:
|
||||||
|
```clojure
|
||||||
|
#"Invoice:\s+([^\n]+)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Whitespace Boundary
|
||||||
|
Use `(?=\s{2,}|\n)` to stop at multiple spaces or newline:
|
||||||
|
```clojure
|
||||||
|
#"Customer:\s+(.+?)(?=\s{2,}|\n)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Specific Marker
|
||||||
|
Match until a specific pattern is found:
|
||||||
|
```clojure
|
||||||
|
#"(?s)Start(.*?)End"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Multi-part Extraction
|
||||||
|
Use multiple capture groups for related fields:
|
||||||
|
```clojure
|
||||||
|
#"Date:\s+(\d{2})/(\d{2})/(\d{2})"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parser Options
|
||||||
|
|
||||||
|
### Date Parsers
|
||||||
|
- `[:clj-time "MM/dd/yyyy"]` - Standard US date
|
||||||
|
- `[:clj-time "MM/dd/yy"]` - 2-digit year
|
||||||
|
- `[:clj-time "MMM dd, yyyy"]` - Named month
|
||||||
|
- `[:clj-time ["MM/dd/yy" "yyyy-MM-dd"]]` - Multiple formats
|
||||||
|
- `[:month-day-year nil]` - Space-separated (1 15 26)
|
||||||
|
|
||||||
|
### Number Parsers
|
||||||
|
- `[:trim-commas nil]` - Remove commas from numbers
|
||||||
|
- `[:trim-commas-and-negate nil]` - Handle negative/credit amounts
|
||||||
|
- `[:trim-commas-and-remove-dollars nil]` - Remove $ and commas
|
||||||
|
- `nil` - No parsing, return raw string
|
||||||
|
|
||||||
|
## Testing Patterns
|
||||||
|
|
||||||
|
### Basic Test Structure
|
||||||
|
```clojure
|
||||||
|
(deftest parse-vendor-invoice
|
||||||
|
(testing "Should parse vendor invoice"
|
||||||
|
(let [results (sut/parse-file (io/file "dev-resources/INVOICE.pdf")
|
||||||
|
"INVOICE.pdf")
|
||||||
|
result (first results)]
|
||||||
|
(is (some? result))
|
||||||
|
(is (= "Vendor" (:vendor-code result)))
|
||||||
|
(is (= "12345" (:invoice-number result))))))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Date Testing
|
||||||
|
```clojure
|
||||||
|
(let [d (:date result)]
|
||||||
|
(is (= 2026 (time/year d)))
|
||||||
|
(is (= 1 (time/month d)))
|
||||||
|
(is (= 15 (time/day d))))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-field Verification
|
||||||
|
```clojure
|
||||||
|
(is (= "Expected Name" (:customer-identifier result)))
|
||||||
|
(is (= "Expected Street" (str/trim (:account-number result))))
|
||||||
|
(is (= "Expected City, ST 12345" (str/trim (:location result))))
|
||||||
|
```
|
||||||
248
.opencode/skills/testing-conventions/SKILL.md
Normal file
248
.opencode/skills/testing-conventions/SKILL.md
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
---
|
||||||
|
name: testing-conventions
|
||||||
|
description: Describe the way that tests should be authored, conventions, tools, helpers, superceding any conventions found in existing tests.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Testing Conventions Skill
|
||||||
|
|
||||||
|
This skill documents the testing conventions for `test/clj/auto_ap/`.
|
||||||
|
|
||||||
|
## Test Focus: User-Observable Behavior
|
||||||
|
|
||||||
|
**Primary rule**: Test user-observable behavior. If an endpoint or function makes a database change, verify the change by querying the database directly rather than asserting on markup.
|
||||||
|
|
||||||
|
**other rules**:
|
||||||
|
1. Don't test the means of doing work. For example, if there is a middleware that makes something available on a request, don't bother testing that wrapper.
|
||||||
|
2. prefer :refer testing imports, rather than :as reference
|
||||||
|
3. Prefer structured edits from clojure-mcp
|
||||||
|
|
||||||
|
### When to Assert on Database State
|
||||||
|
|
||||||
|
When testing an endpoint that modifies data:
|
||||||
|
1. Verify the database change by querying the entity directly
|
||||||
|
2. Use `dc/pull` or `dc/q` to verify the data was stored correctly
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
;; CORRECT: Verify the database change directly
|
||||||
|
(deftest test-create-transaction
|
||||||
|
(let [result @(post-create-transaction {:amount 100.0})]
|
||||||
|
(let [entity (dc/pull (dc/db conn) [:db/id :transaction/amount] (:transaction/id result))]
|
||||||
|
(is (= 100.0 (:transaction/amount entity))))))
|
||||||
|
|
||||||
|
;; CORRECT: Verify response status and headers
|
||||||
|
(is (= 201 (:status response)))
|
||||||
|
(is (= "application/json" (get-in response [:headers "content-type"])))
|
||||||
|
|
||||||
|
;; CORRECT: Check for expected text content
|
||||||
|
(is (re-find #"Transaction created" (get-in response [:body "message"])))
|
||||||
|
```
|
||||||
|
|
||||||
|
### When Markup Testing is Acceptable
|
||||||
|
|
||||||
|
Markup testing (HTML/SSR response bodies) is acceptable when:
|
||||||
|
- Validating response status codes and headers
|
||||||
|
- Checking for presence/absence of specific text strings
|
||||||
|
- Verifying small, expected elements within the markup
|
||||||
|
- Testing SSR component rendering
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
;; ACCEPTABLE: Response codes and headers
|
||||||
|
(is (= 200 (:status response)))
|
||||||
|
(is (= "application/json" (get-in response [:headers "content-type"])))
|
||||||
|
|
||||||
|
;; ACCEPTABLE: Text content within markup
|
||||||
|
(is (re-find #"Transaction found" response-body))
|
||||||
|
|
||||||
|
;; ACCEPTABLE: Small element checks
|
||||||
|
(is (re-find #">Amount: \$100\.00<" response-body))
|
||||||
|
```
|
||||||
|
|
||||||
|
### When to Avoid Markup Testing
|
||||||
|
|
||||||
|
Do not use markup assertions for:
|
||||||
|
- Verifying complex data structures (use database queries instead)
|
||||||
|
- Complex nested content that's easier to query
|
||||||
|
- Business logic verification (test behavior, not presentation)
|
||||||
|
|
||||||
|
## Database Setup
|
||||||
|
|
||||||
|
All tests in `test/clj/auto_ap/` use a shared database fixture (`wrap-setup`) that:
|
||||||
|
1. Creates a temporary in-memory Datomic database (`datomic:mem://test`)
|
||||||
|
2. Loads the full schema from `io/resources/schema.edn`
|
||||||
|
3. Installs custom Datomic functions from `io/resources/functions.edn`
|
||||||
|
4. Cleans up the database after each test
|
||||||
|
|
||||||
|
## Using the Fixture
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(ns my-test
|
||||||
|
(:require
|
||||||
|
[auto-ap.integration.util :refer [wrap-setup]]
|
||||||
|
[clojure.test :as t]))
|
||||||
|
|
||||||
|
(use-fixtures :each wrap-setup)
|
||||||
|
|
||||||
|
(deftest my-test
|
||||||
|
;; tests here can access the test database
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Helper Functions
|
||||||
|
|
||||||
|
`test/clj/auto_ap/integration/util.clj` provides helper functions for creating test data:
|
||||||
|
|
||||||
|
### Identity Helpers
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
;; Add a unique string to avoid collisions
|
||||||
|
(str "CLIENT" (rand-int 100000))
|
||||||
|
(str "INVOICE " (rand-int 1000000))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Entity Builders
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
;; Client
|
||||||
|
(test-client
|
||||||
|
[:db/id "client-id"
|
||||||
|
:client/code "CLIENT123"
|
||||||
|
:client/locations ["DT" "MH"]
|
||||||
|
:client/bank-accounts [:bank-account-id]])
|
||||||
|
|
||||||
|
;; Vendor
|
||||||
|
(test-vendor
|
||||||
|
[:db/id "vendor-id"
|
||||||
|
:vendor/name "Vendorson"
|
||||||
|
:vendor/default-account "test-account-id"])
|
||||||
|
|
||||||
|
;; Bank Account
|
||||||
|
(test-bank-account
|
||||||
|
[:db/id "bank-account-id"
|
||||||
|
:bank-account/code "TEST-BANK-123"
|
||||||
|
:bank-account/type :bank-account-type/check])
|
||||||
|
|
||||||
|
;; Transaction
|
||||||
|
(test-transaction
|
||||||
|
[:db/id "transaction-id"
|
||||||
|
:transaction/date #inst "2022-01-01"
|
||||||
|
:transaction/client "test-client-id"
|
||||||
|
:transaction/bank-account "test-bank-account-id"
|
||||||
|
:transaction/id (str (java.util.UUID/randomUUID))
|
||||||
|
:transaction/amount 100.0
|
||||||
|
:transaction/description-original "original description"])
|
||||||
|
|
||||||
|
;; Payment
|
||||||
|
(test-payment
|
||||||
|
[:db/id "test-payment-id"
|
||||||
|
:payment/date #inst "2022-01-01"
|
||||||
|
:payment/client "test-client-id"
|
||||||
|
:payment/bank-account "test-bank-account-id"
|
||||||
|
:payment/type :payment-type/check
|
||||||
|
:payment/vendor "test-vendor-id"
|
||||||
|
:payment/amount 100.0])
|
||||||
|
|
||||||
|
;; Invoice
|
||||||
|
(test-invoice
|
||||||
|
[:db/id "test-invoice-id"
|
||||||
|
:invoice/date #inst "2022-01-01"
|
||||||
|
:invoice/client "test-client-id"
|
||||||
|
:invoice/status :invoice-status/unpaid
|
||||||
|
:invoice/import-status :import-status/imported
|
||||||
|
:invoice/total 100.0
|
||||||
|
:invoice/outstanding-balance 100.00
|
||||||
|
:invoice/vendor "test-vendor-id"
|
||||||
|
:invoice/invoice-number "INVOICE 123456"
|
||||||
|
:invoice/expense-accounts
|
||||||
|
[{:invoice-expense-account/account "test-account-id"
|
||||||
|
:invoice-expense-account/amount 100.0
|
||||||
|
:invoice-expense-account/location "DT"}]])
|
||||||
|
|
||||||
|
;; Account
|
||||||
|
(test-account
|
||||||
|
[:db/id "account-id"
|
||||||
|
:account/name "Account"
|
||||||
|
:account/type :account-type/asset])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Data Setup (`setup-test-data`)
|
||||||
|
|
||||||
|
Creates a minimal but complete dataset for testing:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(defn setup-test-data [data]
|
||||||
|
(:tempids @(dc/transact conn (into data
|
||||||
|
[(test-account :db/id "test-account-id")
|
||||||
|
(test-client :db/id "test-client-id"
|
||||||
|
:client/bank-accounts [(test-bank-account :db/id "test-bank-account-id")])
|
||||||
|
(test-vendor :db/id "test-vendor-id")
|
||||||
|
{:db/id "accounts-payable-id"
|
||||||
|
:account/name "Accounts Payable"
|
||||||
|
:db/ident :account/accounts-payable
|
||||||
|
:account/numeric-code 21000
|
||||||
|
:account/account-set "default"}]))))
|
||||||
|
```
|
||||||
|
|
||||||
|
Use like:
|
||||||
|
```clojure
|
||||||
|
(let [{:strs [test-client-id test-bank-account-id test-vendor-id]} (setup-test-data [])]
|
||||||
|
...)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token Helpers
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
;; Admin token
|
||||||
|
(admin-token)
|
||||||
|
|
||||||
|
;; User token (optionally scoped to specific client)
|
||||||
|
(user-token) ; Default: client-id 1
|
||||||
|
(user-token client-id) ; Scoped to specific client
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(ns my-test
|
||||||
|
(:require
|
||||||
|
[clojure.test :as t]
|
||||||
|
[auto-ap.datomic :refer [conn]]
|
||||||
|
[auto-ap.integration.util :refer [wrap-setup admin-token setup-test-data test-transaction]]))
|
||||||
|
|
||||||
|
(use-fixtures :each wrap-setup)
|
||||||
|
|
||||||
|
(deftest test-transaction-import
|
||||||
|
(testing "Should import a transaction"
|
||||||
|
(let [{:strs [client-id bank-account-id]} (setup-test-data [])
|
||||||
|
tx-result @(dc/transact conn
|
||||||
|
[(test-transaction
|
||||||
|
{:db/id "test-tx-id"
|
||||||
|
:transaction/client client-id
|
||||||
|
:transaction/bank-account bank-account-id
|
||||||
|
:transaction/amount 50.0})])]
|
||||||
|
(is (= 1 (count (:tx-data tx-result))))
|
||||||
|
;; Verify by querying the database, not markup
|
||||||
|
(let [entity (dc/pull (dc/db conn) [:transaction/amount] (:db/id tx-result))]
|
||||||
|
(is (= 50.0 (:transaction/amount entity)))))))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Note on Temp IDs
|
||||||
|
|
||||||
|
Test data often uses string-based temp IDs like `"client-id"`, `"bank-account-id"`, etc. When transacting, the returned `:tempids` map maps these symbolic IDs to Datomic's internal entity IDs:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(let [{:strs [client-id bank-account-id]} (:tempids @(dc/transact conn txes))]
|
||||||
|
...)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Memory Database
|
||||||
|
|
||||||
|
All tests use `datomic:mem://test` - an in-memory database. This ensures:
|
||||||
|
- Tests are fast
|
||||||
|
- Tests don't interfere with each other
|
||||||
|
- No setup required to run tests locally
|
||||||
|
|
||||||
|
The database is automatically deleted after each test completes.
|
||||||
|
|
||||||
|
# running tests
|
||||||
|
prefer to use clojure nrepl evaluation skill over leiningen, but worst case,
|
||||||
|
use leiningen to run tests
|
||||||
9
AGENTS.md
Normal file
9
AGENTS.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
## Pull Requests on Gitea
|
||||||
|
|
||||||
|
This project uses **gitea story-basking.ts net** as the primary remote for PRs. Use `tea` (the Gitea CLI) to create and manage pull requests. The gitea remote is the one you push to, NOT origin and NOT deploy.
|
||||||
|
|
||||||
|
**When opening a PR**, load and follow the **gitea-tea** skill. In short:
|
||||||
|
- Target branch is always `master`
|
||||||
|
- Use `tea pulls create -r notid/integreat -b master --title "..." --description "..."`
|
||||||
|
|
||||||
|
Use 'bd' for task tracking
|
||||||
BIN
dev-resources/13595522.pdf
Normal file
BIN
dev-resources/13595522.pdf
Normal file
Binary file not shown.
BIN
dev-resources/INVOICE - 03881260.pdf
Executable file
BIN
dev-resources/INVOICE - 03881260.pdf
Executable file
Binary file not shown.
BIN
dev-resources/INVOICE - 03882095.pdf
Executable file
BIN
dev-resources/INVOICE - 03882095.pdf
Executable file
Binary file not shown.
229
docs/plans/2026-02-07-feat-add-invoice-template-03881260-plan.md
Normal file
229
docs/plans/2026-02-07-feat-add-invoice-template-03881260-plan.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
---
|
||||||
|
title: Add New Invoice Template for Produce Distributor (Invoice 03881260)
|
||||||
|
type: feat
|
||||||
|
date: 2026-02-07
|
||||||
|
status: completed
|
||||||
|
---
|
||||||
|
|
||||||
|
# Add New Invoice Template for Produce Distributor (Invoice 03881260)
|
||||||
|
|
||||||
|
**Status:** ✅ Completed
|
||||||
|
|
||||||
|
**Summary:** Successfully implemented a new PDF parsing template for Bonanza Produce invoices. All tests pass.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Implement a new PDF parsing template for a produce/food distributor invoice type. The invoice originates from a distributor with multiple locations (South Lake Tahoe, Sparks NV, Elko NV) and serves customers like "NICK THE GREEK".
|
||||||
|
|
||||||
|
## Problem Statement / Motivation
|
||||||
|
|
||||||
|
Currently, invoices from this produce distributor cannot be automatically parsed, requiring manual data entry. The invoice has a unique layout with multiple warehouse locations and specific formatting that doesn't match existing templates.
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
Add a new template entry to `src/clj/auto_ap/parse/templates.clj` for **Bonanza Produce** with regex patterns to extract:
|
||||||
|
- Invoice number
|
||||||
|
- Date (MM/dd/yy format)
|
||||||
|
- Customer identifier (including address for disambiguation)
|
||||||
|
- Total amount
|
||||||
|
|
||||||
|
## Technical Considerations
|
||||||
|
|
||||||
|
### Vendor Identification Strategy
|
||||||
|
|
||||||
|
**Vendor Name:** Bonanza Produce
|
||||||
|
|
||||||
|
Based on the PDF analysis, use these unique identifiers as keywords:
|
||||||
|
- `"3717 OSGOOD AVE"` - Unique South Lake Tahoe address
|
||||||
|
- `"SPARKS, NEVADA"` - Primary warehouse location
|
||||||
|
- `"1925 FREEPORT BLVD"` - Sparks warehouse address
|
||||||
|
|
||||||
|
**Recommended keyword combination:** `[#"3717 OSGOOD AVE" #"SPARKS, NEVADA"]` - These two together uniquely identify this vendor.
|
||||||
|
|
||||||
|
### Extract Patterns Required
|
||||||
|
|
||||||
|
From the PDF text analysis:
|
||||||
|
|
||||||
|
| Field | Value in PDF | Proposed Regex |
|
||||||
|
|-------|--------------|----------------|
|
||||||
|
| `:invoice-number` | `03881260` | `#"INVOICE\s+(\d+)"` |
|
||||||
|
| `:date` | `01/20/26` | `#"(\d{2}/\d{2}/\d{2})"` (after invoice #) |
|
||||||
|
| `:customer-identifier` | `NICK THE GREEK` | `#"BILL TO.*\n\s+([A-Z][A-Z\s]+)"` |
|
||||||
|
| `:total` | `23.22` | `#"TOTAL\s+([\d\.]+)"` or `#"TOTAL\s+([\d\.]+)\s*$"` (end of line) |
|
||||||
|
|
||||||
|
### Parser Configuration
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
:parser {:date [:clj-time "MM/dd/yy"]
|
||||||
|
:total [:trim-commas nil]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Date format note:** The invoice uses 2-digit year format (`01/20/26`), so use `"MM/dd/yy"` format string.
|
||||||
|
|
||||||
|
### Template Structure
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
{:vendor "Bonanza Produce"
|
||||||
|
:keywords [#"3717 OSGOOD AVE" #"SPARKS, NEVADA"]
|
||||||
|
:extract {:invoice-number #"INVOICE\s+(\d+)"
|
||||||
|
:date #"INVOICE\s+\d+\s+(\d{2}/\d{2}/\d{2})"
|
||||||
|
:customer-identifier #"BILL TO.*?\n\s+([A-Z][A-Z\s]+)(?:\s{2,}|\n)"
|
||||||
|
:total #"TOTAL\s+([\d\.]+)(?:\s*$|\s+TOTAL)"}
|
||||||
|
:parser {:date [:clj-time "MM/dd/yy"]
|
||||||
|
:total [:trim-commas nil]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Is this a single invoice or multi-invoice document?**
|
||||||
|
- Current PDF shows single invoice
|
||||||
|
- Check if statements from this vendor contain multiple invoices
|
||||||
|
- If multi-invoice, need `:multi` and `:multi-match?` keys
|
||||||
|
|
||||||
|
2. **Are credit memos formatted differently?**
|
||||||
|
- Current example shows standard invoice
|
||||||
|
- Need to verify if credits have different layout
|
||||||
|
- May need separate template for credit memos
|
||||||
|
|
||||||
|
3. **How to capture the full customer address in the regex?**
|
||||||
|
- The customer name is on one line: "NICK THE GREEK"
|
||||||
|
- The street address is on the next line: "600 VISTA WAY"
|
||||||
|
- The city/state/zip is on the third line: "MILPITAS, CA 95035"
|
||||||
|
- The regex needs to span multiple lines to capture all three components
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Template successfully matches invoices from this vendor
|
||||||
|
- [ ] Correctly extracts invoice number (e.g., `03881260`)
|
||||||
|
- [ ] Correctly extracts date and parses to proper format
|
||||||
|
- [ ] Correctly extracts customer identifier (e.g., `NICK THE GREEK`)
|
||||||
|
- [ ] Correctly extracts total amount (e.g., `23.22`)
|
||||||
|
- [ ] Parser handles edge cases (commas in amounts, different date formats)
|
||||||
|
- [ ] Tested with at least 3 different invoices from this vendor
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
### Phase 1: Extract PDF Text
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Convert PDF to text for analysis
|
||||||
|
pdftotext -layout "dev-resources/INVOICE - 03881260.pdf" -
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: Determine Vendor Name
|
||||||
|
|
||||||
|
1. Examine the PDF header for company name/logo
|
||||||
|
2. Search for known identifiers (phone numbers, addresses)
|
||||||
|
3. Identify the vendor code for `:vendor` field
|
||||||
|
|
||||||
|
### Phase 3: Develop Regex Patterns
|
||||||
|
|
||||||
|
Test patterns in REPL:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(require '[clojure.string :as str])
|
||||||
|
|
||||||
|
(def text "...") ; paste PDF text here
|
||||||
|
|
||||||
|
;; Test invoice number pattern
|
||||||
|
(re-find #"INVOICE\s+(\d+)" text)
|
||||||
|
|
||||||
|
;; Test date pattern
|
||||||
|
(re-find #"INVOICE\s+\d+\s+(\d{2}/\d{2}/\d{2})" text)
|
||||||
|
|
||||||
|
;; Test customer pattern
|
||||||
|
(re-find #"BILL TO.*?\n\s+([A-Z][A-Z\s]+)" text)
|
||||||
|
|
||||||
|
;; Test total pattern
|
||||||
|
(re-find #"TOTAL\s+([\d\.]+)" text)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 4: Add Template
|
||||||
|
|
||||||
|
Add to `src/clj/auto_ap/parse/templates.clj` in the `pdf-templates` vector:
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
;; Bonanza Produce
|
||||||
|
{:vendor "Bonanza Produce"
|
||||||
|
:keywords [#"3717 OSGOOD AVE" #"SPARKS, NEVADA"]
|
||||||
|
:extract {:invoice-number #"INVOICE\s+(\d+)"
|
||||||
|
:date #"INVOICE\s+\d+\s+(\d{2}/\d{2}/\d{2})"
|
||||||
|
:customer-identifier #"BILL TO.*?\n\s+([A-Z][A-Z\s]+)(?:\s{2,}|\n)"
|
||||||
|
:total #"TOTAL\s+([\d\.]+)(?:\s*$|\s+TOTAL)"}
|
||||||
|
:parser {:date [:clj-time "MM/dd/yy"]
|
||||||
|
:total [:trim-commas nil]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 5: Test Implementation
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
;; Load the namespace
|
||||||
|
(require '[auto-ap.parse :as p])
|
||||||
|
(require '[auto-ap.parse.templates :as t])
|
||||||
|
|
||||||
|
;; Test parsing
|
||||||
|
(p/parse "...pdf text here...")
|
||||||
|
|
||||||
|
;; Or test full file
|
||||||
|
(p/parse-file "dev-resources/INVOICE - 03881260.pdf" "INVOICE - 03881260.pdf")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Considerations
|
||||||
|
|
||||||
|
1. **Date edge cases:** Ensure 2-digit year parsing works correctly (26 → 2026)
|
||||||
|
2. **Amount edge cases:** Test with larger amounts that may include commas
|
||||||
|
3. **Customer name variations:** Test with different customer names/lengths
|
||||||
|
4. **Multi-page invoices:** Verify template handles page breaks if applicable
|
||||||
|
|
||||||
|
## Known PDF Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
SOUTH LAKE TAHOE, CA
|
||||||
|
3717 OSGOOD AVE.
|
||||||
|
...
|
||||||
|
SPARKS, NEVADA ELKO, NEVADA
|
||||||
|
1925 FREEPORT BLVD... 428 RIVER ST...
|
||||||
|
|
||||||
|
CUST. PHONE 775-622-0159 ... INVOICE DATE
|
||||||
|
... 03881260 01/20/26
|
||||||
|
B NICKGR
|
||||||
|
I NICK THE GREEK S NICK THE GREEK
|
||||||
|
L NICK THE GREEK H NICK THE GREEK
|
||||||
|
L 600 VISTA WAY I VIA MICHELE
|
||||||
|
...
|
||||||
|
TOTAL
|
||||||
|
TOTAL 23.22
|
||||||
|
```
|
||||||
|
|
||||||
|
## References & Research
|
||||||
|
|
||||||
|
### Similar Templates for Reference
|
||||||
|
|
||||||
|
Based on `src/clj/auto_ap/parse/templates.clj`, these templates have similar patterns:
|
||||||
|
|
||||||
|
1. **Gstar Seafood** (lines 19-26) - Simple single invoice, uses `:trim-commas`
|
||||||
|
2. **Don Vito Ozuna Food Corp** (lines 121-127) - Uses customer-identifier with multiline pattern
|
||||||
|
3. **C&L Produce** (lines 260-267) - Similar "Bill To" pattern for customer extraction
|
||||||
|
|
||||||
|
### File Locations
|
||||||
|
|
||||||
|
- Templates: `src/clj/auto_ap/parse/templates.clj`
|
||||||
|
- Parser logic: `src/clj/auto_ap/parse.clj`
|
||||||
|
- Utility functions: `src/clj/auto_ap/parse/util.clj`
|
||||||
|
- Test PDF: `dev-resources/INVOICE - 03881260.pdf`
|
||||||
|
|
||||||
|
### Parser Utilities Available
|
||||||
|
|
||||||
|
From `src/clj/auto_ap/parse/util.clj`:
|
||||||
|
- `:clj-time` - Date parsing with format strings
|
||||||
|
- `:trim-commas` - Remove commas from numbers
|
||||||
|
- `:trim-commas-and-negate` - Handle credit/negative amounts
|
||||||
|
- `:month-day-year` - Special format for space-separated dates
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Identify the vendor name** by examining the PDF more closely or asking the user
|
||||||
|
2. **Test regex patterns** in the REPL with the actual PDF text
|
||||||
|
3. **Refine patterns** based on edge cases discovered during testing
|
||||||
|
4. **Add template** to templates.clj
|
||||||
|
5. **Test with multiple invoices** from this vendor to ensure robustness
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
---
|
||||||
|
title: Rebase Invoice Templates, Merge to Master, and Integrate Branches
|
||||||
|
type: refactor
|
||||||
|
date: 2026-02-08
|
||||||
|
---
|
||||||
|
|
||||||
|
# Rebase Invoice Templates, Merge to Master, and Integrate Branches
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This plan outlines a series of git operations to reorganize the branch structure by:
|
||||||
|
1. Creating a rebase commit with all invoice template changes
|
||||||
|
2. Applying those changes onto `master`
|
||||||
|
3. Removing them from the current `clauding` branch
|
||||||
|
4. Merging `master` back into `clauding`
|
||||||
|
5. Finally merging `clauding` into `get-transactions2-page-working`
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
### Branch Structure (as of Feb 8, 2026)
|
||||||
|
|
||||||
|
```
|
||||||
|
master (dc021b8c)
|
||||||
|
├─ deploy/master (dc021b8c)
|
||||||
|
└─ (other branches)
|
||||||
|
└─ clauding (0155d91e) - HEAD
|
||||||
|
├─ 16 commits ahead of master
|
||||||
|
└─ Contains invoice template work for Bonanza Produce
|
||||||
|
├─ db1cb194 Add Bonanza Produce invoice template
|
||||||
|
├─ ec754233 Improve Bonanza Produce customer identifier extraction
|
||||||
|
├─ af7bc324 Add location extraction for Bonanza Produce invoices
|
||||||
|
├─ 62107c99 Extract customer name and address for Bonanza Produce
|
||||||
|
├─ 7ecd569e Add invoice-template-creator skill for automated template generation
|
||||||
|
└─ 0155d91e Add Bonanza Produce multi-invoice statement template
|
||||||
|
```
|
||||||
|
|
||||||
|
### Merge Base
|
||||||
|
|
||||||
|
- **Merge base between `clauding` and `master`**: `dc021b8c`
|
||||||
|
- **Commits on `clauding` since merge base**: 16 commits
|
||||||
|
- **Invoice template commits**: 6 commits (db1cb194 through 0155d91e)
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The current branch structure has:
|
||||||
|
1. Invoice template work mixed with other feature development in `clauding`
|
||||||
|
2. No clear separation between invoice template changes and transaction page work
|
||||||
|
3. A desire to get invoice template changes merged to `master` independently
|
||||||
|
4. A need to reorganize branches to prepare for merging `get-transactions2-page-working`
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
Use git rebase and merge operations to create a cleaner branch hierarchy:
|
||||||
|
|
||||||
|
1. **Create a new branch** (`invoice-templates-rebased`) with only invoice template commits
|
||||||
|
2. **Rebase those commits** onto current `master`
|
||||||
|
3. **Merge** this clean branch to `master`
|
||||||
|
4. **Remove invoice template commits** from `clauding` branch
|
||||||
|
5. **Merge `master` into `clauding`** to sync
|
||||||
|
6. **Merge `clauding` into `get-transactions2-page-working`**
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
### Phase 1: Extract and Rebase Invoice Templates
|
||||||
|
|
||||||
|
#### Step 1.1: Identify Invoice Template Commits
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From clauding branch, find the range of invoice template commits
|
||||||
|
git log --oneline --reverse dc021b8c..clauding
|
||||||
|
```
|
||||||
|
|
||||||
|
**Invoice template commits to extract** (6 commits in order):
|
||||||
|
1. `db1cb194` - Add Bonanza Produce invoice template
|
||||||
|
2. `ec754233` - Improve Bonanza Produce customer identifier extraction
|
||||||
|
3. `af7bc324` - Add location extraction for Bonanza Produce invoices
|
||||||
|
4. `62107c99` - Extract customer name and address for Bonanza Produce
|
||||||
|
5. `7ecd569e` - Add invoice-template-creator skill for automated template generation
|
||||||
|
6. `0155d91e` - Add Bonanza Produce multi-invoice statement template
|
||||||
|
|
||||||
|
#### Step 1.2: Create Rebased Branch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a new branch from master with only invoice template commits
|
||||||
|
git checkout master
|
||||||
|
git pull origin master # Ensure master is up to date
|
||||||
|
git checkout -b invoice-templates-rebased
|
||||||
|
|
||||||
|
# Cherry-pick the invoice template commits in order
|
||||||
|
git cherry-pick db1cb194
|
||||||
|
git cherry-pick ec754233
|
||||||
|
git cherry-pick af7bc324
|
||||||
|
git cherry-pick 62107c99
|
||||||
|
git cherry-pick 7ecd569e
|
||||||
|
git cherry-pick 0155d91e
|
||||||
|
|
||||||
|
# Resolve any conflicts that arise during cherry-pick
|
||||||
|
# Run tests after each cherry-pick if conflicts occur
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 1.3: Verify Rebased Branch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify the commits are correctly applied
|
||||||
|
git log --oneline master..invoice-templates-rebased
|
||||||
|
|
||||||
|
# Run tests to ensure invoice templates still work
|
||||||
|
lein test auto-ap.parse.templates-test
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 1.4: Merge to Master
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Merge the clean invoice templates to master
|
||||||
|
git checkout master
|
||||||
|
git merge invoice-templates-rebased --no-edit
|
||||||
|
|
||||||
|
# Push to remote
|
||||||
|
git push origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: Clean Up Clauding Branch
|
||||||
|
|
||||||
|
#### Step 2.1: Remove Invoice Template Commits from Clauding
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From clauding branch, find the commit before the first invoice template
|
||||||
|
git log --oneline clauding | grep -B1 "db1cb194"
|
||||||
|
|
||||||
|
# Suppose that's commit X, rebase clauding to remove invoice templates
|
||||||
|
git checkout clauding
|
||||||
|
|
||||||
|
# Option A: Interactive rebase (recommended for cleanup)
|
||||||
|
git rebase -i <commit-before-invoice-templates>
|
||||||
|
|
||||||
|
# In the editor, delete lines corresponding to invoice template commits:
|
||||||
|
# db1cb194
|
||||||
|
# ec754233
|
||||||
|
# af7bc324
|
||||||
|
# 62107c99
|
||||||
|
# 7ecd569e
|
||||||
|
# 0155d91e
|
||||||
|
|
||||||
|
# Save and exit to rebase
|
||||||
|
|
||||||
|
# Resolve any conflicts that occur
|
||||||
|
# Run tests after rebase
|
||||||
|
```
|
||||||
|
|
||||||
|
**OR**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option B: Hard reset to commit before invoice templates
|
||||||
|
# Identify the commit hash before db1cb194 (let's call it COMMIT_X)
|
||||||
|
git reset --hard COMMIT_X
|
||||||
|
|
||||||
|
# Then add back any non-invoice template commits from clauding
|
||||||
|
# (commits after the invoice templates that should remain)
|
||||||
|
git cherry-pick <non-invoice-commits-if-any>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2.2: Verify Clauding Branch Cleanup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify invoice template commits are removed
|
||||||
|
git log --oneline | grep -i "bonanza" # Should be empty
|
||||||
|
|
||||||
|
# Verify other commits remain
|
||||||
|
git log --oneline -20
|
||||||
|
|
||||||
|
# Run tests to ensure nothing broke
|
||||||
|
lein test
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2.3: Force Push Updated Clauding
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Force push the cleaned branch (use --force-with-lease for safety)
|
||||||
|
git push --force-with-lease origin clauding
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: Sync Clauding with Master
|
||||||
|
|
||||||
|
#### Step 3.1: Merge Master into Clauding
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout clauding
|
||||||
|
git merge master --no-edit
|
||||||
|
|
||||||
|
# Resolve any conflicts
|
||||||
|
# Run tests
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 3.2: Push Synced Clauding
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin clauding
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 4: Final Merge to get-transactions2-page-working
|
||||||
|
|
||||||
|
#### Step 4.1: Merge Clauding to get-transactions2-page-working
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout get-transactions2-page-working
|
||||||
|
git merge clauding --no-edit
|
||||||
|
|
||||||
|
# Resolve any conflicts
|
||||||
|
# Run tests
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 4.2: Push Final Branch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin get-transactions2-page-working
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### Pre-operations Validation
|
||||||
|
- [ ] All invoice template commits identified correctly (6 commits)
|
||||||
|
- [ ] Merge base commit (`dc021b8c`) confirmed
|
||||||
|
- [ ] Current branch state documented
|
||||||
|
- [ ] Team notified of branch manipulation
|
||||||
|
|
||||||
|
### Post-Rebase Validation
|
||||||
|
- [ ] `invoice-templates-rebased` branch created from `master`
|
||||||
|
- [ ] All 6 invoice template commits applied correctly
|
||||||
|
- [ ] All invoice template tests pass
|
||||||
|
- [ ] No conflicts or unexpected changes during cherry-pick
|
||||||
|
|
||||||
|
### Post-Master Validation
|
||||||
|
- [ ] Invoice templates merged to `master`
|
||||||
|
- [ ] Changes pushed to remote `master`
|
||||||
|
- [ ] CI/CD passes on `master`
|
||||||
|
|
||||||
|
### Post-Cleanup Validation
|
||||||
|
- [ ] `clauding` branch has only non-invoice template commits
|
||||||
|
- [ ] No Bonanza Produce commits remain in `clauding` history
|
||||||
|
- [ ] All `clauding` tests pass
|
||||||
|
- [ ] Force push successful
|
||||||
|
|
||||||
|
### Post-Sync Validation
|
||||||
|
- [ ] `clauding` merged with `master`
|
||||||
|
- [ ] All conflicts resolved
|
||||||
|
- [ ] Changes pushed to remote
|
||||||
|
|
||||||
|
### Final Merge Validation
|
||||||
|
- [ ] `get-transactions2-page-working` merged with `clauding`
|
||||||
|
- [ ] All conflicts resolved
|
||||||
|
- [ ] Final tests pass
|
||||||
|
- [ ] Changes pushed to remote
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
- **Branch structure**: Invoice templates cleanly separated on `master`
|
||||||
|
- **Commit history**: Linear, no duplicate invoice template commits
|
||||||
|
- **Tests passing**: 100% of existing tests pass after each step
|
||||||
|
- **No data loss**: All work preserved in appropriate branches
|
||||||
|
- **Branch clarity**: Each branch has a clear, focused purpose
|
||||||
|
|
||||||
|
## Dependencies & Risks
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- [ ] All current work on `clauding` should be backed up or committed
|
||||||
|
- [ ] Team should be aware of branch manipulation to avoid force pushing
|
||||||
|
- [ ] CI/CD should be monitored during operations
|
||||||
|
|
||||||
|
### Risks
|
||||||
|
1. **Force push risk**: Force pushing `clauding` will rewrite history
|
||||||
|
- **Mitigation**: Use `--force-with-lease`, notify team beforehand
|
||||||
|
|
||||||
|
2. **Conflict resolution**: Multiple merge/conflict resolution points
|
||||||
|
- **Mitigation**: Test after each step, resolve conflicts carefully
|
||||||
|
|
||||||
|
3. **Work loss**: Potential to lose commits if operations go wrong
|
||||||
|
- **Mitigation**: Create backups, verify each step before proceeding
|
||||||
|
|
||||||
|
4. **CI/CD disruption**: Force pushes may affect CI/CD pipelines
|
||||||
|
- **Mitigation**: Coordinate with team, avoid during active deployments
|
||||||
|
|
||||||
|
### Contingency Plan
|
||||||
|
|
||||||
|
If something goes wrong:
|
||||||
|
1. **Recover `clauding` branch**:
|
||||||
|
```bash
|
||||||
|
git checkout clauding
|
||||||
|
git reset --hard origin/clauding # Restore from remote backup
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Recover master**:
|
||||||
|
```bash
|
||||||
|
git checkout master
|
||||||
|
git reset --hard origin/master # Restore from deploy/master
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Manual cherry-pick recovery**: If rebasing failed, manually cherry-pick remaining commits
|
||||||
|
|
||||||
|
## Alternative Approaches Considered
|
||||||
|
|
||||||
|
### Approach 1: Squash and Merge
|
||||||
|
**Pros**: Single clean commit, simple history
|
||||||
|
**Cons**: Loses individual commit history and context
|
||||||
|
|
||||||
|
**Rejected because**: Team uses merge commits (not squash), and individual commit history is valuable for tracking invoice template development.
|
||||||
|
|
||||||
|
### Approach 2: Keep Branches Separate
|
||||||
|
**Pros**: No branch manipulation needed
|
||||||
|
**Cons**: Branches remain tangled, harder to track progress
|
||||||
|
|
||||||
|
**Rejected because**: Goal is to cleanly separate invoice templates from transaction work.
|
||||||
|
|
||||||
|
### Approach 3: Rebase Clauding Onto Master
|
||||||
|
**Pros**: Linear history
|
||||||
|
**Cons**: Requires force push, may lose merge context
|
||||||
|
|
||||||
|
**Rejected because**: Current team workflow uses merge commits, and merging master into clauding preserves the integration point.
|
||||||
|
|
||||||
|
### Approach 4: Create New Branch Instead of Cleanup
|
||||||
|
**Pros**: Less risky, preserves full history
|
||||||
|
**Cons**: Accumulates branches, harder to track
|
||||||
|
|
||||||
|
**Rejected because**: Goal is cleanup and reorganization, not preservation.
|
||||||
|
|
||||||
|
## Related Work
|
||||||
|
|
||||||
|
- **Previous invoice template work**: `2026-02-07-feat-add-invoice-template-03881260-plan.md`
|
||||||
|
- **Current branch structure**: `clauding` has hierarchical relationship with `get-transactions2-page-working`
|
||||||
|
- **Team git workflow**: Uses merge commits (not rebasing), per repo research
|
||||||
|
|
||||||
|
## References & Research
|
||||||
|
|
||||||
|
### Internal References
|
||||||
|
- **Branch management patterns**: Repo research analysis (see `task_id: ses_3c2287be8ffe9icFi5jHEspaqh`)
|
||||||
|
- **Invoice template location**: `src/clj/auto_ap/parse/templates.clj`
|
||||||
|
- **Current branch structure**: Git log analysis
|
||||||
|
|
||||||
|
### Git Operations Documentation
|
||||||
|
- **Cherry-pick**: `git cherry-pick <commit>`
|
||||||
|
- **Interactive rebase**: `git rebase -i <base>`
|
||||||
|
- **Force push with lease**: `git push --force-with-lease`
|
||||||
|
- **Merge commits**: `git merge <branch> --no-edit`
|
||||||
|
|
||||||
|
### File Locations
|
||||||
|
- Templates: `src/clj/auto_ap/parse/templates.clj`
|
||||||
|
- Parser logic: `src/clj/auto_ap/parse.clj`
|
||||||
|
- Invoice PDF: `dev-resources/INVOICE - 03881260.pdf`
|
||||||
|
|
||||||
|
## Testing Plan
|
||||||
|
|
||||||
|
### Before Each Major Step
|
||||||
|
```bash
|
||||||
|
# Verify current branch state
|
||||||
|
git branch -vv
|
||||||
|
git log --oneline -10
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
lein test
|
||||||
|
|
||||||
|
# Run specific invoice template tests
|
||||||
|
lein test auto-ap.parse.templates-test
|
||||||
|
```
|
||||||
|
|
||||||
|
### After Each Major Step
|
||||||
|
- Verify commit count and order
|
||||||
|
- Run full test suite
|
||||||
|
- Check for unintended changes
|
||||||
|
- Verify remote branch state matches local
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Team coordination**: Inform team before force pushing to avoid conflicts
|
||||||
|
- **Backup strategy**: All commits are preserved in the rebase process
|
||||||
|
- **Testing**: Verify at each step to catch issues early
|
||||||
|
- **Safety first**: Use `--force-with-lease` instead of `--force`
|
||||||
|
- **Documentation**: This plan serves as documentation for the operation
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
---
|
||||||
|
title: Move Detailed Sales Data to DuckDB and Parquet
|
||||||
|
type: refactor
|
||||||
|
status: active
|
||||||
|
date: 2026-04-24
|
||||||
|
---
|
||||||
|
|
||||||
|
# Move Detailed Sales Data to DuckDB and Parquet
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Detailed sales records (orders, charges, line items, refunds) are currently stored in Datomic. Because Datomic is append-only, this high-volume data causes significant storage bloat. We will move these details to Parquet files stored on S3, using DuckDB as the query engine for views and summaries, while keeping the high-level `sales-summaries` in Datomic for ledger calculations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Frame
|
||||||
|
|
||||||
|
The system stores every individual sale and payment detail in Datomic. While useful for auditing, this data is rarely accessed in detail after a few weeks, yet it permanently increases the Datomic database size. The app needs a "colder" but still queryable storage layer for these details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements Trace
|
||||||
|
|
||||||
|
- R1. Detailed sales/payment entities must be moved from Datomic to Parquet files on S3.
|
||||||
|
- R2. `sales-summaries` must remain in Datomic to ensure ledger calculations remain performant and stable.
|
||||||
|
- R3. The "Sales Orders" and "Payments" views must continue to function (filtering, sorting, pagination) by querying the Parquet files via DuckDB.
|
||||||
|
- R4. The daily sales summary job must be updated to aggregate data from DuckDB instead of Datomic.
|
||||||
|
- R5. The system must handle "voids" of payments/orders in an immutable file format.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
- **In Scope:**
|
||||||
|
- Implementation of Parquet writer for sales data.
|
||||||
|
- DuckDB integration for reading S3 Parquet files.
|
||||||
|
- Migration of existing detailed data from Datomic to S3.
|
||||||
|
- Updating the summary aggregation job.
|
||||||
|
- **Out of Scope:**
|
||||||
|
- Moving `sales-summaries` out of Datomic.
|
||||||
|
- Implementing a real-time streaming pipeline (sticking to batch/daily flushes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context & Research
|
||||||
|
|
||||||
|
### Relevant Code and Patterns
|
||||||
|
|
||||||
|
- **Production Flow:** `auto-ap.square.core3`, `auto-ap.ezcater.core`, and `auto-ap.routes.ezcater-xls` all produce tagged maps that are currently sent to `dc/transact`.
|
||||||
|
- **Read Flow:** `auto-ap.datomic.sales-orders` and `auto-ap.ssr.payments` perform the current Datomic queries.
|
||||||
|
- **Aggregation:** `auto-ap.jobs.sales-summaries` uses `dc/q` to sum totals for the day.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Technical Decisions
|
||||||
|
|
||||||
|
- **Storage Format:** Parquet. It is columnar, highly compressed, and natively supported by DuckDB.
|
||||||
|
- **Storage Location:** AWS S3. This removes the need for a managed database server.
|
||||||
|
- **Query Engine:** DuckDB. It can query Parquet files directly on S3 without importing them into a local database.
|
||||||
|
- **Write Strategy:** Daily Batch. To avoid the "small file problem" in S3/Parquet, data will be buffered (locally or in a staging table) and flushed as one file per day: `s3://bucket/sales-details/YYYY-MM-DD.parquet`.
|
||||||
|
- **Voiding Strategy:** Append-only log. A "void" is simply a new record with the same `external-id` and a `status: voided`. The read query will always select the record with the latest timestamp for a given ID.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Units
|
||||||
|
|
||||||
|
- U1. **S3 Storage & DuckDB Infrastructure**
|
||||||
|
|
||||||
|
**Goal:** Setup the S3 bucket structure and the DuckDB connection utility.
|
||||||
|
|
||||||
|
**Requirements:** R1, R3
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/clj/auto_ap/storage/parquet.clj` (DuckDB connection and S3 config)
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Implement a `with-duckdb` wrapper that initializes DuckDB, loads the `httpfs` extension, and configures S3 credentials.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- A test that can run a simple `SELECT 1` via DuckDB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- U2. **Parquet Writer Implementation**
|
||||||
|
|
||||||
|
**Goal:** Create a service to convert sales maps into Parquet files and upload them to S3.
|
||||||
|
|
||||||
|
**Requirements:** R1
|
||||||
|
|
||||||
|
**Dependencies:** U1
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/clj/auto_ap/storage/parquet.clj`
|
||||||
|
- Test: `test/clj/auto_ap/storage/parquet_test.clj`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Implement a `flush-to-parquet` function that takes a collection of maps and uses a library to create the file.
|
||||||
|
- Implement the S3 upload logic.
|
||||||
|
- **Recovery:** Implement a "flush-log" in the local SQLite WAL. Mark records as `flushed: true` only after receiving a successful 200 OK from S3. On startup, the system should check for unflushed records and trigger a retry.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Happy path: Write a list of 10 sales orders to a Parquet file and verify it exists on S3.
|
||||||
|
- Error path: Simulate an S3 connection failure during flush and verify that records remain in the local WAL and are successfully flushed on the next attempt.
|
||||||
|
- Edge case: Handle empty data sets without creating empty files.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- Successful upload of a Parquet file that is readable by an external DuckDB CLI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- U3. **Redirect Production Flow**
|
||||||
|
|
||||||
|
**Goal:** Change the Square/EzCater integrations to write to the Parquet writer instead of Datomic.
|
||||||
|
|
||||||
|
**Requirements:** R1
|
||||||
|
|
||||||
|
**Dependencies:** U2
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/clj/auto_ap/square/core3.clj`
|
||||||
|
- Modify: `src/clj/auto_ap/ezcater/core.clj`
|
||||||
|
- Modify: `src/clj/auto_ap/routes/ezcater_xls.clj`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Replace `dc/transact` calls for detailed sales/charges with calls to the new `parquet/write` service.
|
||||||
|
- *Note:* Keep the transaction for any related entities that must stay in Datomic (e.g., Client updates).
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- Run a Square import and verify that no new detailed entities appear in Datomic, but a new Parquet file is created.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- U4. **DuckDB Read Layer for Views**
|
||||||
|
|
||||||
|
**Goal:** Update the "Sales Orders" and "Payments" views to fetch data from DuckDB.
|
||||||
|
|
||||||
|
**Requirements:** R3, R5
|
||||||
|
|
||||||
|
**Dependencies:** U1
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/clj/auto_ap/datomic/sales_orders.clj`
|
||||||
|
- Modify: `src/clj/auto_ap/ssr/payments.clj`
|
||||||
|
- Test: `test/clj/auto_ap/integration/graphql/checks.clj`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Replace Datomic `q` and `pull` calls with DuckDB SQL queries.
|
||||||
|
- **Performance:** To optimize pagination, implement a "Metadata Index" file on S3 (or a Datomic entity) that stores the total record count per day. Use this to calculate pagination totals without scanning all Parquet files.
|
||||||
|
- **Deterministic Voids:** Use a combination of `timestamp` and a monotonic `sequence_number` for the `QUALIFY` clause to ensure deterministic results for records updated in the same millisecond.
|
||||||
|
- Map DuckDB result sets back to the existing map formats used by the views to minimize frontend changes.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Happy path: List payments for a client across a date range.
|
||||||
|
- Integration: Void a payment in S3 and verify the view shows it as voided.
|
||||||
|
- Performance: Verify pagination totals load in < 200ms using the metadata index.
|
||||||
|
- Edge case: Handle two updates to the same record in the same millisecond and verify the latest sequence number wins.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- The Payments table in the UI loads correctly and reflects the data in S3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- U5. **Update Summary Aggregation Job**
|
||||||
|
|
||||||
|
**Goal:** Update the `sales-summaries` job to calculate totals using DuckDB.
|
||||||
|
|
||||||
|
**Requirements:** R2, R4
|
||||||
|
|
||||||
|
**Dependencies:** U1
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/clj/auto_ap/jobs/sales_summaries.clj`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- In `get-payment-items`, `get-discounts`, `get-tax`, etc., replace the `dc/q` calls with DuckDB SQL `SUM` and `GROUP BY` queries against the daily Parquet files.
|
||||||
|
- Ensure the results are still written to the `sales-summary` entities in Datomic.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- Run the `sales-summaries-v2` job and verify that the resulting Datomic summaries match the values in the S3 Parquet files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- U6. **Historical Data Migration**
|
||||||
|
|
||||||
|
**Goal:** Move all existing detailed sales data from Datomic to Parquet files.
|
||||||
|
|
||||||
|
**Requirements:** R1
|
||||||
|
|
||||||
|
**Dependencies:** U2
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/clj/auto_ap/migration/sales_to_parquet.clj`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Write a script that iterates through all historical sales orders and payments in Datomic.
|
||||||
|
- Group them by **Business Date** (the date of the sale, not the transaction date) to ensure consistency with future DuckDB queries.
|
||||||
|
- Write each day's data to the corresponding `YYYY-MM-DD.parquet` file on S3.
|
||||||
|
- Log any records with missing dates to a "dead-letter" file for manual review.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- Count of records in Datomic vs count of records in S3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- U7. **Datomic Cleanup**
|
||||||
|
|
||||||
|
**Goal:** Remove the detailed data from Datomic to reclaim space.
|
||||||
|
|
||||||
|
**Requirements:** R1
|
||||||
|
|
||||||
|
**Dependencies:** U6
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/clj/auto_ap/migration/cleanup_sales.clj`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Use `[:db/retractEntity ...]` to remove all `#:sales-order`, `#:charge`, and `#:sales-refund` entities.
|
||||||
|
- **Batching:** Perform retractions in batches (e.g., by month) with a cooldown period between batches to avoid excessive Datomic transaction log bloat and performance degradation.
|
||||||
|
- *Safety:* Only run this after verifying U6 and U4.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- Datomic database size decreases; detailed queries in Datomic return empty, while DuckDB queries return data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System-Wide Impact
|
||||||
|
|
||||||
|
- **Interaction graph:** The integration cores now depend on the Parquet/S3 service. The SSR views and Background Jobs now depend on the DuckDB service.
|
||||||
|
- **Error propagation:** S3 downtime will now cause "Sales Orders" views to fail and the Summary Job to fail. We should implement basic retry logic in the DuckDB wrapper.
|
||||||
|
- **State lifecycle risks:** There is a window between the "production" of a sale and the "flush" to Parquet. If the app crashes before a flush, data could be lost. *Mitigation:* Use a small local SQLite file as a write-ahead log for the daily buffer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks & Dependencies
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| S3 Latency for Views | Use DuckDB's caching and only query the files for the requested date range. |
|
||||||
|
| Data Loss before Flush | Implement a local SQLite staging file for the current day's data. |
|
||||||
|
| Schema Drift | Use a strict schema for Parquet files; handle missing columns in SQL with `COALESCE`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources & References
|
||||||
|
|
||||||
|
- Related code: `src/clj/auto_ap/jobs/sales_summaries.clj`
|
||||||
|
- Related code: `src/clj/auto_ap/ssr/payments.clj`
|
||||||
|
- External docs: [DuckDB S3 Integration](https://duckdb.org/docs/extensions/httpfs)
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
---
|
||||||
|
module: Invoice Parsing
|
||||||
|
date: 2026-02-07
|
||||||
|
problem_type: integration_failure
|
||||||
|
component: pdf_template_parser
|
||||||
|
symptoms:
|
||||||
|
- "Bonanza Produce multi-invoice statement (13595522.pdf) fails to parse correctly"
|
||||||
|
- "Single invoice template extracts only one invoice instead of four"
|
||||||
|
- "Multi-invoice statement lacks I/L markers present in single invoices"
|
||||||
|
- "Customer identifier extraction pattern requires different regex for statements"
|
||||||
|
root_cause: template_inadequate
|
||||||
|
resolution_type: template_fix
|
||||||
|
severity: high
|
||||||
|
tags: [pdf, parsing, invoice, bonanza-produce, multi-invoice, integration]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bonanza Produce Multi-Invoice Statement Template Fix
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Bonanza Produce sends two different invoice formats:
|
||||||
|
1. **Single invoices** (e.g., 03881260.pdf) with I/L markers and specific layout
|
||||||
|
2. **Multi-invoice statements** (e.g., 13595522.pdf) containing 4 invoices per page
|
||||||
|
|
||||||
|
The single invoice template failed to parse multi-invoice statements because:
|
||||||
|
- Multi-invoice statements lack the I/L (Invoice/Location) markers used in single invoice templates
|
||||||
|
- The layout structure is completely different, with invoices listed as table rows instead of distinct sections
|
||||||
|
- Customer identifier extraction requires a different regex pattern
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- Component: PDF Template Parser (Clojure)
|
||||||
|
- Date: 2026-02-07
|
||||||
|
- Test File: `test/clj/auto_ap/parse/templates_test.clj`
|
||||||
|
- Template File: `src/clj/auto_ap/parse/templates.clj`
|
||||||
|
- Test Document: `dev-resources/13595522.pdf` (4 invoices on single page)
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- Single invoice template only parses first invoice from multi-invoice statement
|
||||||
|
- Parse returns single result instead of 4 separate invoice records
|
||||||
|
- `:customer-identifier` extraction returns empty or incorrect values for statements
|
||||||
|
- Test `parse-bonanza-produce-statement-13595522` expects 4 results but receives 1
|
||||||
|
|
||||||
|
## What Didn't Work
|
||||||
|
|
||||||
|
**Attempted Solution 1: Reuse single invoice template with `:multi` flag**
|
||||||
|
- Added `:multi #"\n"` and `:multi-match?` pattern to existing single invoice template
|
||||||
|
- **Why it failed:** The single invoice template's regex patterns (e.g., `I\s+([A-Z][A-Z\s]+?)\s{2,}.*?L\s+`) expect I/L markers that don't exist in multi-invoice statements. The layout structure is fundamentally different.
|
||||||
|
|
||||||
|
**Attempted Solution 2: Using simpler customer identifier pattern**
|
||||||
|
- Tried pattern `#"(.*?)\s+RETURN"` extracted from multi-invoice statement text
|
||||||
|
- **Why it failed:** This pattern alone doesn't account for the statement's column-based layout. Need to combine with `:multi` and `:multi-match?` flags to parse multiple invoices.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Added a dedicated multi-invoice template that:
|
||||||
|
1. Uses different keywords to identify multi-invoice statements
|
||||||
|
2. Employs `:multi` and `:multi-match?` flags for multiple invoice extraction
|
||||||
|
3. Uses simpler regex patterns suitable for the statement layout
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
;; Bonanza Produce Statement (multi-invoice)
|
||||||
|
{:vendor "Bonanza Produce"
|
||||||
|
:keywords [#"The perishable agricultural commodities" #"SPARKS, NEVADA"]
|
||||||
|
:extract {:invoice-number #"^\s+[0-9]{2}/[0-9]{2}/[0-9]{2}\s+([0-9]+)\s+INVOICE"
|
||||||
|
:customer-identifier #"(.*?)\s+RETURN"
|
||||||
|
:date #"^\s+([0-9]{2}/[0-9]{2}/[0-9]{2})"
|
||||||
|
:total #"^\s+[0-9]{2}/[0-9]{2}/[0-9]{2}\s+[0-9]+\s+INVOICE\s+([\d.]+)"}
|
||||||
|
:parser {:date [:clj-time "MM/dd/yy"]
|
||||||
|
:total [:trim-commas nil]}
|
||||||
|
:multi #"\n"
|
||||||
|
:multi-match? #"\s+[0-9]{2}/[0-9]{2}/[0-9]{2}\s+[0-9]+\s+INVOICE"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key differences from single invoice template:**
|
||||||
|
- `:keywords`: Look for statement header text instead of phone number
|
||||||
|
- `:customer-identifier`: Pattern `#"(.*?)\s+RETURN"` works for statement format
|
||||||
|
- `:multi #"\n"`: Split results on newline boundaries
|
||||||
|
- `:multi-match?`: Match invoice header pattern to identify individual invoices
|
||||||
|
- No I/L markers: Patterns scan from left margin without location markers
|
||||||
|
|
||||||
|
## Why This Works
|
||||||
|
|
||||||
|
1. **Statement-specific keywords:** "The perishable agricultural commodities" and "SPARKS, NEVADA" uniquely identify multi-invoice statements vs. single invoices (which have phone number 530-544-4136)
|
||||||
|
|
||||||
|
2. **Multi-flag parsing:** The `:multi` and `:multi-match?` flags tell the parser to split the document on newlines and identify individual invoices using the date/invoice-number pattern, rather than treating the whole page as one invoice
|
||||||
|
|
||||||
|
3. **Simplified patterns:** Without I/L markers, patterns scan from line start (`^\s+`) and extract columns based on whitespace positions. The `:customer-identifier` pattern `(.*?)\s+RETURN` captures everything before "RETURN" on each line
|
||||||
|
|
||||||
|
4. **Separate templates:** Having distinct templates for single invoices vs. statements prevents conflict and allows optimization for each format
|
||||||
|
|
||||||
|
## Prevention
|
||||||
|
|
||||||
|
**When adding templates for vendors with multiple document formats:**
|
||||||
|
|
||||||
|
1. **Create separate templates:** Don't try to make one template handle both formats. Use distinct keywords to identify each format
|
||||||
|
|
||||||
|
2. **Test both single and multi-invoice documents:** Ensure templates parse expected number of invoices:
|
||||||
|
```clojure
|
||||||
|
(is (= 4 (count results)) "Should parse 4 invoices from statement")
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Verify `:multi` usage:** Multi-invoice templates should have both `:multi` and `:multi-match?` flags:
|
||||||
|
```clojure
|
||||||
|
:multi #"\n"
|
||||||
|
:multi-match? #"\s+[0-9]{2}/[0-9]{2}/[0-9]{2}\s+[0-9]+\s+INVOICE"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Check pattern scope:** Multi-invoice statements often lack structural markers (I/L), so patterns should:
|
||||||
|
- Use `^\s+` to anchor at line start
|
||||||
|
- Extract from whitespace-separated columns
|
||||||
|
- Avoid patterns requiring specific markers
|
||||||
|
|
||||||
|
5. **Run all template tests:** Before committing, run:
|
||||||
|
```bash
|
||||||
|
lein test auto-ap.parse.templates-test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Issues
|
||||||
|
|
||||||
|
- Single invoice template: `src/clj/auto_ap/parse/templates.clj` lines 756-765
|
||||||
|
- Similar multi-invoice patterns: Search for `:multi` and `:multi-match?` in `src/clj/auto_ap/parse/templates.clj`
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
- **Tests:** `test/clj/auto_ap/parse/templates_test.clj` (lines 36-53)
|
||||||
|
- **Template:** `src/clj/auto_ap/parse/templates.clj` (lines 767-777)
|
||||||
|
- **Test document:** `dev-resources/13595522.pdf`
|
||||||
|
- **Template parser:** `src/clj/auto_ap/parse.clj`
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
---
|
||||||
|
module: SSR Admin
|
||||||
|
component: testing_framework
|
||||||
|
date: '2026-02-07'
|
||||||
|
problem_type: best_practice
|
||||||
|
resolution_type: test_fix
|
||||||
|
severity: medium
|
||||||
|
root_cause: inadequate_documentation
|
||||||
|
symptoms:
|
||||||
|
- Route tests only verified HTTP status codes (200), not actual HTML content
|
||||||
|
- No verification that route responses contain expected page elements
|
||||||
|
- Could have false positives where routes return empty or wrong content
|
||||||
|
rails_version: 7.1.0
|
||||||
|
tags:
|
||||||
|
- testing
|
||||||
|
- routes
|
||||||
|
- hiccup
|
||||||
|
- html-verification
|
||||||
|
- clojure
|
||||||
|
- str-includes
|
||||||
|
---
|
||||||
|
|
||||||
|
# Enhancing Route Tests with HTML Content Verification
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Route tests for the SSR admin modules (vendors and transaction-rules) were only verifying HTTP status codes, making them vulnerable to false positives. A route could return a 200 status but with empty or incorrect HTML content, and the tests would still pass.
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- Tests like `(is (= 200 (:status response)))` only checked HTTP status
|
||||||
|
- No assertions about the actual HTML content returned
|
||||||
|
- Route handlers could return malformed or empty hiccup vectors without test failures
|
||||||
|
- Dialog routes could return generic HTML without the expected content
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
Missing best practice for route testing in Clojure SSR applications. Unlike Rails controller tests that can use `assert_select` or Capybara matchers, there was no established pattern for verifying hiccup-rendered HTML content.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Enhanced route tests to verify HTML content using `clojure.string/includes?` checks on the rendered HTML string.
|
||||||
|
|
||||||
|
### Implementation Pattern
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
;; BEFORE: Only status check
|
||||||
|
(deftest page-route-returns-html-response
|
||||||
|
(testing "Page route returns HTML response"
|
||||||
|
(let [request {:identity (admin-token)}
|
||||||
|
response ((get sut/key->handler :auto-ap.routes.admin.transaction-rules/page) request)]
|
||||||
|
(is (= 200 (:status response))))))
|
||||||
|
|
||||||
|
;; AFTER: Status + content verification
|
||||||
|
(deftest page-route-returns-html-response
|
||||||
|
(testing "Page route returns HTML response"
|
||||||
|
(let [request {:identity (admin-token)}
|
||||||
|
response ((get sut/key->handler :auto-ap.routes.admin.transaction-rules/page) request)
|
||||||
|
html-str (apply str (:body response))]
|
||||||
|
(is (= 200 (:status response)))
|
||||||
|
(is (str/includes? html-str "Transaction Rules")))))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Changes
|
||||||
|
|
||||||
|
1. **Convert body to string**: Use `(apply str (:body response))` to convert hiccup vectors to HTML string
|
||||||
|
2. **Add content assertions**: Use `clojure.string/includes?` to verify expected content exists
|
||||||
|
3. **Test-specific content**: Match content unique to that route (page titles, button text, entity names)
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
- `test/clj/auto_ap/ssr/admin/vendors_test.clj`
|
||||||
|
- Added `vendor-page-route-contains-vendor-content` test
|
||||||
|
|
||||||
|
- `test/clj/auto_ap/ssr/admin/transaction_rules_test.clj`
|
||||||
|
- Enhanced 7 route tests with content verification:
|
||||||
|
- `page-route-returns-html-response` → checks for "Transaction Rules"
|
||||||
|
- `table-route-returns-table-data` → checks for "New Transaction Rule"
|
||||||
|
- `edit-dialog-route-returns-dialog` → checks for entity-specific content
|
||||||
|
- `account-typeahead-route-works` → checks for "account"
|
||||||
|
- `location-select-route-works` → checks for "location"
|
||||||
|
- `execute-dialog-route-works` → checks for "Code transactions"
|
||||||
|
- `new-dialog-route-returns-empty-form` → checks for "Transaction rule"
|
||||||
|
|
||||||
|
### Testing Strategy
|
||||||
|
|
||||||
|
For each route, identify the minimal but specific content that indicates the route is working:
|
||||||
|
|
||||||
|
- **Page routes**: Check for page title or heading
|
||||||
|
- **Dialog routes**: Check for dialog-specific button text or the entity name being edited
|
||||||
|
- **Typeahead routes**: Check for the resource type (e.g., "account")
|
||||||
|
- **Table routes**: Check for action buttons or empty state messages
|
||||||
|
|
||||||
|
## Prevention
|
||||||
|
|
||||||
|
When writing route tests, always:
|
||||||
|
|
||||||
|
1. ✅ Verify HTTP status code (200, 302, etc.)
|
||||||
|
2. ✅ Verify response contains expected HTML content
|
||||||
|
3. ✅ Use specific content unique to that route
|
||||||
|
4. ✅ Avoid overly generic strings that might appear on any page
|
||||||
|
|
||||||
|
### Template for Route Tests
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(deftest [route-name]-returns-expected-content
|
||||||
|
(testing "[Route description]"
|
||||||
|
(let [request {:identity (admin-token)
|
||||||
|
;; Add route-params, query-params as needed
|
||||||
|
}
|
||||||
|
response ((get sut/key->handler :auto-ap.routes.[module]/[route]) request)
|
||||||
|
html-str (apply str (:body response))]
|
||||||
|
(is (= 200 (:status response)))
|
||||||
|
(is (str/includes? html-str "[Expected content]")))))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tools Used
|
||||||
|
|
||||||
|
- `clojure.string/includes?` - Simple string containment check
|
||||||
|
- `apply str` - Converts hiccup vector to HTML string
|
||||||
|
- No additional dependencies needed
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
- **Catches regressions**: Tests fail if route returns wrong content
|
||||||
|
- **Self-documenting**: Test assertions describe expected behavior
|
||||||
|
- **Lightweight**: No complex HTML parsing libraries required
|
||||||
|
- **Fast**: String operations are performant
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- Similar pattern could apply to any Clojure SSR application using hiccup
|
||||||
|
- For more complex DOM assertions, consider adding hickory or enlive for structured HTML parsing
|
||||||
2
parquet-wal/test-type.jsonl
Normal file
2
parquet-wal/test-type.jsonl
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{"seq-no":1777103077792,"record":{"id":2}}{"seq-no":1777103077984,"record":{"id":1,"name":"test"}}{"seq-no":1777103126496,"record":{"id":2}}
|
||||||
|
{"seq-no":1777103126692,"record":{"id":1,"name":"test"}}
|
||||||
22
project.clj
22
project.clj
@@ -93,18 +93,14 @@
|
|||||||
|
|
||||||
[hiccup "2.0.0-alpha2"]
|
[hiccup "2.0.0-alpha2"]
|
||||||
|
|
||||||
;; needed for java 11
|
;; needed for java 11
|
||||||
[javax.xml.bind/jaxb-api "2.4.0-b180830.0359"]
|
[javax.xml.bind/jaxb-api "2.4.0-b180830.0359"]
|
||||||
[io.forward/clojure-mail "1.0.8"]
|
[io.forward/clojure-mail "1.0.8"]
|
||||||
[lambdaisland/edn-lines "1.0.10"]]
|
[lambdaisland/edn-lines "1.0.10"]
|
||||||
:managed-dependencies [;; explicit dependencies to get to latest versions for above
|
[org.duckdb/duckdb_jdbc "1.1.0"]
|
||||||
[com.fasterxml.jackson.core/jackson-core "2.12.0"]
|
[org.xerial/sqlite-jdbc "3.45.1.0"]
|
||||||
[com.fasterxml.jackson.core/jackson-databind "2.12.0"]
|
[com.fasterxml.jackson.core/jackson-core "2.12.0"]
|
||||||
[com.fasterxml.jackson.core/jackson-annotations "2.12.0"]
|
[com.fasterxml.jackson.core/jackson-databind "2.12.0"]
|
||||||
[com.fasterxml.jackson.dataformat/jackson-dataformat-cbor "2.12.0"]
|
|
||||||
|
|
||||||
[commons-codec "1.12"]]
|
|
||||||
:plugins [[lein-ring "0.9.7"]
|
|
||||||
[lein-cljsbuild "1.1.5"]
|
[lein-cljsbuild "1.1.5"]
|
||||||
[lein-ancient "0.6.15"]]
|
[lein-ancient "0.6.15"]]
|
||||||
:clean-targets ^{:protect false} ["resources/public/js/compiled" "target"]
|
:clean-targets ^{:protect false} ["resources/public/js/compiled" "target"]
|
||||||
@@ -144,7 +140,7 @@
|
|||||||
[com.bhauman/rebel-readline-cljs "0.1.4" :exclusions [org.clojure/clojurescript]]
|
[com.bhauman/rebel-readline-cljs "0.1.4" :exclusions [org.clojure/clojurescript]]
|
||||||
[javax.servlet/servlet-api "2.5"]]
|
[javax.servlet/servlet-api "2.5"]]
|
||||||
:plugins [[lein-pdo "0.1.1"]]
|
:plugins [[lein-pdo "0.1.1"]]
|
||||||
:jvm-opts ["-Dconfig=config/dev.edn" "-Xms4G" "-Xmx20G" "-XX:-OmitStackTraceInFastThrow"]}
|
:jvm-opts ["-Dconfig=config/dev.edn" "-Xms4G" "-Xmx20G" "-XX:-OmitStackTraceInFastThrow" "-Djava.library.path=/home/noti/.local/lib"]}
|
||||||
|
|
||||||
:uberjar
|
:uberjar
|
||||||
{:java-cmd "/usr/lib/jvm/java-11-openjdk/bin/java"
|
{:java-cmd "/usr/lib/jvm/java-11-openjdk/bin/java"
|
||||||
|
|||||||
@@ -1,171 +1,180 @@
|
|||||||
(ns auto-ap.datomic.sales-orders
|
(ns auto-ap.datomic.sales-orders
|
||||||
(:require
|
(:require
|
||||||
[auto-ap.datomic
|
[auto-ap.storage.parquet :as pq]
|
||||||
:refer [add-sorter-fields-2
|
[auto-ap.time :as atime]
|
||||||
apply-pagination
|
[clj-time.coerce :as coerce]
|
||||||
apply-sort-3
|
|
||||||
conn
|
|
||||||
merge-query
|
|
||||||
pull-id
|
|
||||||
pull-many
|
|
||||||
query2
|
|
||||||
visible-clients]]
|
|
||||||
[clj-time.coerce :as c]
|
|
||||||
[clj-time.core :as time]
|
|
||||||
[clojure.set :as set]
|
[clojure.set :as set]
|
||||||
|
[clojure.string :as str]
|
||||||
[com.brunobonacci.mulog :as mu]
|
[com.brunobonacci.mulog :as mu]
|
||||||
[datomic.api :as dc]
|
[ring.util.codec :as ring-codec]))
|
||||||
[iol-ion.query]))
|
|
||||||
|
|
||||||
(defn <-datomic [result]
|
(defn- payment-methods->charges [pm-str]
|
||||||
(-> result
|
(when (not-empty pm-str)
|
||||||
(update :sales-order/date c/from-date)
|
(mapv (fn [pm] {:charge/type-name pm})
|
||||||
(update :sales-order/charges (fn [cs]
|
(str/split pm-str #","))))
|
||||||
(map (fn [c]
|
|
||||||
(-> c
|
|
||||||
(update :charge/processor :db/ident)
|
|
||||||
(set/rename-keys {:expected-deposit/_charges :expected-deposit})
|
|
||||||
(update :expected-deposit first)))
|
|
||||||
cs)))))
|
|
||||||
|
|
||||||
(def default-read '[:db/id
|
(defn <-row
|
||||||
:sales-order/external-id,
|
"Convert a flat parquet row into the shape consumers expect."
|
||||||
:sales-order/location,
|
[row]
|
||||||
:sales-order/date,
|
(let [pm (:payment-methods row)]
|
||||||
:sales-order/total,
|
(-> row
|
||||||
:sales-order/tax,
|
(set/rename-keys
|
||||||
:sales-order/tip,
|
{:external-id :sales-order/external-id
|
||||||
:sales-order/line-items,
|
:location :sales-order/location
|
||||||
:sales-order/discount,
|
:total :sales-order/total
|
||||||
:sales-order/returns,
|
:tax :sales-order/tax
|
||||||
:sales-order/service-charge,
|
:tip :sales-order/tip
|
||||||
:sales-order/vendor,
|
:discount :sales-order/discount
|
||||||
:sales-order/source,
|
:service-charge :sales-order/service-charge
|
||||||
:sales-order/reference-link,
|
:vendor :sales-order/vendor
|
||||||
{:sales-order/client [:client/name :db/id :client/code]
|
:client-code :sales-order/client-code
|
||||||
:sales-order/charges [
|
:date :sales-order/date
|
||||||
:charge/type-name,
|
:source :sales-order/source
|
||||||
:charge/total,
|
:reference-link :sales-order/reference-link
|
||||||
:charge/tax,
|
:payment-methods :sales-order/payment-methods
|
||||||
:charge/tip,
|
:processors :sales-order/processors
|
||||||
:charge/external-id,
|
:categories :sales-order/categories})
|
||||||
:charge/note,
|
(update :sales-order/date #(some-> % str))
|
||||||
:charge/date,
|
(dissoc :entity-type :_seq-no)
|
||||||
:charge/client,
|
(assoc :sales-order/charges (payment-methods->charges pm)))))
|
||||||
:charge/location,
|
|
||||||
:charge/reference-link,
|
|
||||||
{:charge/processor [:db/ident]} {:expected-deposit/_charges [:db/id]}]}])
|
|
||||||
|
|
||||||
(defn raw-graphql-ids [db args]
|
(defn build-where-clause [args]
|
||||||
(let [visible-clients (set (map :db/id (:clients args)))
|
(let [clauses (keep identity
|
||||||
selected-clients (->> (cond
|
[(when-let [c (:client-code args)]
|
||||||
(:client-id args)
|
(str "external_id.client = '" c "'"))
|
||||||
(set/intersection #{(:client-id args)}
|
(when-let [v (:vendor args)]
|
||||||
visible-clients)
|
(str "external_id.vendor = '" (name v) "'"))
|
||||||
|
(when-let [l (:location args)]
|
||||||
|
(str "location = '" l "'"))])]
|
||||||
|
(when (seq clauses)
|
||||||
|
(str "WHERE " (str/join " AND " clauses)))))
|
||||||
|
|
||||||
|
(defn build-sort-clause [args]
|
||||||
|
(let [sort (or (:sort args) "date")
|
||||||
|
order (or (:order args) "DESC")]
|
||||||
|
(str "ORDER BY " sort " " order)))
|
||||||
|
|
||||||
(:client-code args)
|
(def page-size 100)
|
||||||
(set/intersection #{(pull-id db [:client/code (:client-code args)])}
|
|
||||||
visible-clients)
|
|
||||||
|
|
||||||
:else
|
(defn raw-graphql-ids [args]
|
||||||
visible-clients)
|
(let [start (some-> (:start (:date-range args)) .toString)
|
||||||
(take 10)
|
end (some-> (:end (:date-range args)) (.substring 0 10))
|
||||||
set)
|
limit (or (:limit args) page-size)
|
||||||
_ (mu/log ::selected-clients
|
offset (or (:offset args) 0)]
|
||||||
:selected-clients selected-clients)
|
(when start
|
||||||
query (cond-> {:query {:find []
|
(let [result (pq/get-sales-orders start end
|
||||||
:in ['$ '[?clients ?start-date ?end-date]]
|
{:client (:client-code args)
|
||||||
:where '[[(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]]}
|
:vendor (:vendor args)
|
||||||
:args [db [selected-clients
|
:location (:location args)
|
||||||
(some-> (:start (:date-range args)) c/to-date)
|
:sort (or (:sort args) "date")
|
||||||
(some-> (:end (:date-range args)) c/to-date )]]}
|
:order "DESC"
|
||||||
|
:limit limit
|
||||||
|
:offset offset})]
|
||||||
|
{:ids (mapv #(str (:external-id %)) (:rows result))
|
||||||
|
:rows (:rows result)
|
||||||
|
:count (:count result)}))))
|
||||||
|
|
||||||
(:sort args) (add-sorter-fields-2 {"client" ['[?e :sales-order/client ?c]
|
(defn graphql-results [rows _ids _args]
|
||||||
'[?c :client/name ?sort-client]]
|
(mapv <-row rows))
|
||||||
"location" ['[?e :sales-order/location ?sort-location]]
|
|
||||||
"source" ['[?e :sales-order/source ?sort-source]]
|
|
||||||
"date" ['[?e :sales-order/date ?sort-date]]
|
|
||||||
"total" ['[?e :sales-order/total ?sort-total]]
|
|
||||||
"tax" ['[?e :sales-order/tax ?sort-tax]]
|
|
||||||
"tip" ['[?e :sales-order/tip ?sort-tip]]}
|
|
||||||
args)
|
|
||||||
(:category args)
|
|
||||||
(merge-query {:query {:in ['?category]
|
|
||||||
:where ['[?e :sales-order/line-items ?li]
|
|
||||||
'[?li :order-line-item/category ?category]]}
|
|
||||||
:args [(:category args)]})
|
|
||||||
|
|
||||||
(:processor args)
|
(defn- extract-date-str [v]
|
||||||
(merge-query {:query {:in ['?processor]
|
(when v
|
||||||
:where ['[?e :sales-order/charges ?chg]
|
(cond
|
||||||
'[?chg :charge/processor ?processor]]}
|
(instance? org.joda.time.DateTime v) (atime/unparse-local v atime/normal-date)
|
||||||
:args [(keyword "ccp-processor"
|
(instance? org.joda.time.LocalDate v) (atime/unparse-local v atime/normal-date)
|
||||||
(name (:processor args)))]})
|
(instance? java.util.Date v) (atime/unparse-local (coerce/to-date-time v) atime/normal-date)
|
||||||
(:type-name args)
|
(instance? java.time.LocalDate v) (.toString v)
|
||||||
(merge-query {:query {:in ['?type-name]
|
(string? v) (if (re-find #"^\d{2}/\d{2}/\d{4}" v)
|
||||||
:where ['[?e :sales-order/charges ?chg]
|
(-> (java.time.LocalDate/parse v (java.time.format.DateTimeFormatter/ofPattern "MM/dd/yyyy"))
|
||||||
'[?chg :charge/type-name ?type-name]]}
|
.toString)
|
||||||
:args [(:type-name args)]})
|
(if (> (count v) 10) (.substring v 0 10) v))
|
||||||
|
:else (str v))))
|
||||||
|
|
||||||
(:total-gte args)
|
(defn- get-date [qp k]
|
||||||
(merge-query {:query {:in ['?total-gte]
|
(or (extract-date-str (get qp k))
|
||||||
:where ['[?e :sales-order/total ?a]
|
(extract-date-str (get qp (name k)))))
|
||||||
'[(>= ?a ?total-gte)]]}
|
|
||||||
:args [(:total-gte args)]})
|
|
||||||
|
|
||||||
(:total-lte args)
|
(defn- kw->str [v]
|
||||||
(merge-query {:query {:in ['?total-lte]
|
(when (some? v)
|
||||||
:where ['[?e :sales-order/total ?a]
|
(if (keyword? v) (name v) (str v))))
|
||||||
'[(<= ?a ?total-lte)]]}
|
|
||||||
:args [(:total-lte args)]})
|
|
||||||
|
|
||||||
(:total args)
|
(defn- qp->opts [qp]
|
||||||
(merge-query {:query {:in ['?total]
|
(let [sort-params (:sort qp)
|
||||||
:where ['[?e :sales-order/total ?sales-order-total]
|
sort-key (when (seq sort-params) (-> sort-params first :name))
|
||||||
'[(iol-ion.query/dollars= ?sales-order-total ?total)]]}
|
sort-dir (when (seq sort-params) (-> sort-params first :dir))]
|
||||||
:args [(:total args)]})
|
(cond-> {}
|
||||||
|
(some? (:client-code qp)) (assoc :client (kw->str (:client-code qp)))
|
||||||
|
(some? (:location qp)) (assoc :location (kw->str (:location qp)))
|
||||||
|
(not-empty (:payment-method qp)) (assoc :payment-method (:payment-method qp))
|
||||||
|
(some? (:processor qp)) (assoc :processor (kw->str (:processor qp)))
|
||||||
|
(not-empty (:category qp)) (assoc :category (:category qp))
|
||||||
|
(:total-gte qp) (assoc :total-gte (:total-gte qp))
|
||||||
|
(:total-lte qp) (assoc :total-lte (:total-lte qp))
|
||||||
|
sort-key (assoc :sort sort-key)
|
||||||
|
sort-dir (assoc :order (or sort-dir "DESC"))
|
||||||
|
true (assoc :limit (or (:per-page qp) 25)
|
||||||
|
:offset (or (:start qp) 0)))))
|
||||||
|
|
||||||
true
|
(defn- default-date-range []
|
||||||
(merge-query {:query {:find ['?date '?e]
|
(let [today (.toString (java.time.LocalDate/now))
|
||||||
:where ['[?e :sales-order/date ?date]]}}))]
|
week-ago (.toString (.minusDays (java.time.LocalDate/now) 7))]
|
||||||
|
[week-ago today]))
|
||||||
|
|
||||||
(cond->> (query2 query)
|
(defn- qp->date-range [qp]
|
||||||
true (apply-sort-3 (assoc args :default-asc? false))
|
(let [[default-start default-end] (default-date-range)]
|
||||||
true (apply-pagination args))))
|
[(or (get-date qp :start-date)
|
||||||
|
(extract-date-str (get-in qp [:date-range :start]))
|
||||||
|
default-start)
|
||||||
|
(or (get-date qp :end-date)
|
||||||
|
(extract-date-str (get-in qp [:date-range :end]))
|
||||||
|
default-end)]))
|
||||||
|
|
||||||
(defn graphql-results [ids db _]
|
(defn- request->client-codes [request]
|
||||||
(let [results (->> (pull-many db default-read ids)
|
(let [clients (:clients request)
|
||||||
(group-by :db/id))
|
codes (keep :client/code clients)]
|
||||||
payments (->> ids
|
(when (seq codes) codes)))
|
||||||
(map results)
|
|
||||||
(map first)
|
|
||||||
(mapv <-datomic))]
|
|
||||||
payments))
|
|
||||||
|
|
||||||
(defn summarize-orders [ids]
|
(defn fetch-page-ssr
|
||||||
|
"Fetch sales orders from parquet for the SSR page."
|
||||||
|
[request]
|
||||||
|
(let [qp (:query-params request)
|
||||||
|
raw-qp (some-> (:query-string request)
|
||||||
|
ring-codec/form-decode
|
||||||
|
(->> (into {} (remove (fn [[_ v]] (str/blank? v))))))
|
||||||
|
[start end] (qp->date-range (merge raw-qp qp))
|
||||||
|
opts (qp->opts qp)
|
||||||
|
client-codes (request->client-codes request)
|
||||||
|
opts (if client-codes (assoc opts :client-codes client-codes) opts)
|
||||||
|
result (pq/get-sales-orders start end opts)
|
||||||
|
rows (mapv <-row (:rows result))]
|
||||||
|
{:rows rows :count (:count result)}))
|
||||||
|
|
||||||
(let [[total tax] (->>
|
(defn summarize-page-ssr
|
||||||
(dc/q {:find ['(sum ?t) '(sum ?tax)]
|
"Summarize all matching sales orders via parquet."
|
||||||
:with ['?id]
|
[request]
|
||||||
:in ['$ '[?id ...]]
|
(let [qp (:query-params request)
|
||||||
:where ['[?id :sales-order/total ?t]
|
raw-qp (some-> (:query-string request)
|
||||||
'[?id :sales-order/tax ?tax]]}
|
ring-codec/form-decode
|
||||||
(dc/db conn)
|
(->> (into {} (remove (fn [[_ v]] (str/blank? v))))))
|
||||||
ids)
|
[start end] (qp->date-range (merge raw-qp qp))
|
||||||
first)]
|
opts (dissoc (qp->opts qp) :limit :offset :sort :order)
|
||||||
{:total total
|
client-codes (request->client-codes request)
|
||||||
:tax tax}))
|
opts (if client-codes (assoc opts :client-codes client-codes) opts)]
|
||||||
|
(pq/get-sales-orders-summary start end opts)))
|
||||||
|
|
||||||
|
(defn summarize-orders [rows]
|
||||||
|
(when (seq rows)
|
||||||
|
(let [total (reduce + 0.0 (map #(or (:sales-order/total %) 0.0) rows))
|
||||||
|
tax (reduce + 0.0 (map #(or (:sales-order/tax %) 0.0) rows))]
|
||||||
|
{:total total
|
||||||
|
:tax tax})))
|
||||||
|
|
||||||
(defn get-graphql [args]
|
(defn get-graphql [args]
|
||||||
(let [db (dc/db conn)
|
(let [{:keys [ids rows count]} (mu/trace ::get-sales-order-ids [] (raw-graphql-ids args))]
|
||||||
{ids-to-retrieve :ids matching-count :count} (mu/trace ::get-sales-order-ids [] (raw-graphql-ids db args))]
|
[(mu/trace ::get-results [] (graphql-results rows ids args))
|
||||||
[(->> (mu/trace ::get-results [] (graphql-results ids-to-retrieve db args)))
|
count
|
||||||
matching-count
|
(summarize-orders rows)]))
|
||||||
(summarize-orders ids-to-retrieve)]))
|
|
||||||
|
|
||||||
(defn summarize-graphql [args]
|
(defn summarize-graphql [args]
|
||||||
(let [db (dc/db conn)
|
(let [{:keys [rows]} (raw-graphql-ids args)]
|
||||||
{ids-to-retrieve :ids matching-count :count} (mu/trace ::get-sales-order-ids [] (raw-graphql-ids db args))]
|
(summarize-orders rows)))
|
||||||
(summarize-orders ids-to-retrieve)))
|
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
(ns auto-ap.ezcater.core
|
(ns auto-ap.ezcater.core
|
||||||
(:require
|
(:require
|
||||||
[auto-ap.datomic :refer [conn random-tempid]]
|
[auto-ap.datomic :refer [conn random-tempid]]
|
||||||
|
[auto-ap.storage.parquet :as parquet]
|
||||||
[datomic.api :as dc]
|
[datomic.api :as dc]
|
||||||
[clj-http.client :as client]
|
[clj-http.client :as client]
|
||||||
[venia.core :as v]
|
[venia.core :as v]
|
||||||
@@ -20,42 +21,41 @@
|
|||||||
:body (json/write-str {"query" (v/graphql-query q)})
|
:body (json/write-str {"query" (v/graphql-query q)})
|
||||||
:as :json})
|
:as :json})
|
||||||
:body
|
:body
|
||||||
:data
|
:data))
|
||||||
))
|
|
||||||
|
|
||||||
(defn get-caterers [integration]
|
(defn get-caterers [integration]
|
||||||
(:caterers (query integration {:venia/queries [{:query/data
|
(:caterers (query integration {:venia/queries [{:query/data
|
||||||
[:caterers [:name :uuid [:address [:name :street]]]]}]} )))
|
[:caterers [:name :uuid [:address [:name :street]]]]}]})))
|
||||||
|
|
||||||
(defn get-subscriptions [integration]
|
(defn get-subscriptions [integration]
|
||||||
(->> (query integration {:venia/queries [{:query/data
|
(->> (query integration {:venia/queries [{:query/data
|
||||||
[:subscribers [:id [:subscriptions [:parentId :parentEntity :eventEntity :eventKey]] ]]}]} )
|
[:subscribers [:id [:subscriptions [:parentId :parentEntity :eventEntity :eventKey]]]]}]})
|
||||||
:subscribers
|
:subscribers
|
||||||
first
|
first
|
||||||
:subscriptions))
|
:subscriptions))
|
||||||
|
|
||||||
(defn get-integrations []
|
(defn get-integrations []
|
||||||
(map first (dc/q '[:find (pull ?i [:ezcater-integration/api-key
|
(map first (dc/q '[:find (pull ?i [:ezcater-integration/api-key
|
||||||
:ezcater-integration/subscriber-uuid
|
:ezcater-integration/subscriber-uuid
|
||||||
:db/id
|
:db/id
|
||||||
:ezcater-integration/integration-status [:db/id]])
|
{:ezcater-integration/integration-status [:db/id]}])
|
||||||
:in $
|
:in $
|
||||||
:where [?i :ezcater-integration/api-key]]
|
:where [?i :ezcater-integration/api-key]]
|
||||||
(dc/db conn))))
|
(dc/db conn))))
|
||||||
|
|
||||||
(defn mark-integration-status [integration integration-status]
|
(defn mark-integration-status [integration integration-status]
|
||||||
@(dc/transact conn
|
@(dc/transact conn
|
||||||
[{:db/id (:db/id integration)
|
[{:db/id (:db/id integration)
|
||||||
:ezcater-integration/integration-status (assoc integration-status
|
:ezcater-integration/integration-status (assoc integration-status
|
||||||
:db/id (or (-> integration :ezcater-integration/integration-status :db/id)
|
:db/id (or (-> integration :ezcater-integration/integration-status :db/id)
|
||||||
(random-tempid)))}]))
|
(random-tempid)))}]))
|
||||||
|
|
||||||
(defn upsert-caterers
|
(defn upsert-caterers
|
||||||
([integration]
|
([integration]
|
||||||
@(dc/transact
|
@(dc/transact
|
||||||
conn
|
conn
|
||||||
(for [caterer (get-caterers integration)]
|
(for [caterer (get-caterers integration)]
|
||||||
{:db/id (:db/id integration)
|
{:db/id (:db/id integration)
|
||||||
:ezcater-integration/caterers [{:ezcater-caterer/name (str (:name caterer) " (" (:street (:address caterer)) ")")
|
:ezcater-integration/caterers [{:ezcater-caterer/name (str (:name caterer) " (" (:street (:address caterer)) ")")
|
||||||
:ezcater-caterer/search-terms (str (:name caterer) " " (:street (:address caterer)))
|
:ezcater-caterer/search-terms (str (:name caterer) " " (:street (:address caterer)))
|
||||||
:ezcater-caterer/uuid (:uuid caterer)}]}))))
|
:ezcater-caterer/uuid (:uuid caterer)}]}))))
|
||||||
@@ -64,14 +64,14 @@
|
|||||||
([integration]
|
([integration]
|
||||||
(let [extant (get-subscriptions integration)
|
(let [extant (get-subscriptions integration)
|
||||||
to-ensure (set (map first (dc/q '[:find ?cu
|
to-ensure (set (map first (dc/q '[:find ?cu
|
||||||
:in $
|
:in $
|
||||||
:where [_ :client/ezcater-locations ?el]
|
:where [_ :client/ezcater-locations ?el]
|
||||||
[?el :ezcater-location/caterer ?c]
|
[?el :ezcater-location/caterer ?c]
|
||||||
[?c :ezcater-caterer/uuid ?cu]]
|
[?c :ezcater-caterer/uuid ?cu]]
|
||||||
(dc/db conn))))
|
(dc/db conn))))
|
||||||
to-create (set/difference
|
to-create (set/difference
|
||||||
to-ensure
|
to-ensure
|
||||||
(set (map :parentId extant)))]
|
(set (map :parentId extant)))]
|
||||||
(doseq [parentId to-create]
|
(doseq [parentId to-create]
|
||||||
(query integration
|
(query integration
|
||||||
{:venia/operation {:operation/type :mutation
|
{:venia/operation {:operation/type :mutation
|
||||||
@@ -94,7 +94,6 @@
|
|||||||
:eventKey 'cancelled}}
|
:eventKey 'cancelled}}
|
||||||
[[:subscription [:parentId :parentEntity :eventEntity :eventKey]]]]]})))))
|
[[:subscription [:parentId :parentEntity :eventEntity :eventKey]]]]]})))))
|
||||||
|
|
||||||
|
|
||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn upsert-ezcater
|
(defn upsert-ezcater
|
||||||
([] (upsert-ezcater (get-integrations)))
|
([] (upsert-ezcater (get-integrations)))
|
||||||
@@ -115,12 +114,11 @@
|
|||||||
|
|
||||||
(defn get-caterer [caterer-uuid]
|
(defn get-caterer [caterer-uuid]
|
||||||
(dc/pull (dc/db conn)
|
(dc/pull (dc/db conn)
|
||||||
'[:ezcater-caterer/name
|
'[:ezcater-caterer/name
|
||||||
{:ezcater-integration/_caterers [:ezcater-integration/api-key]}
|
{:ezcater-integration/_caterers [:ezcater-integration/api-key]}
|
||||||
{:ezcater-location/_caterer [:ezcater-location/location
|
{:ezcater-location/_caterer [:ezcater-location/location
|
||||||
{:client/_ezcater-locations [:client/code]}]}]
|
{:client/_ezcater-locations [:client/code]}]}]
|
||||||
[:ezcater-caterer/uuid caterer-uuid]))
|
[:ezcater-caterer/uuid caterer-uuid]))
|
||||||
|
|
||||||
|
|
||||||
(defn round-carry-cents [f]
|
(defn round-carry-cents [f]
|
||||||
(with-precision 2 (double (.setScale (bigdec f) 2 java.math.RoundingMode/HALF_UP))))
|
(with-precision 2 (double (.setScale (bigdec f) 2 java.math.RoundingMode/HALF_UP))))
|
||||||
@@ -135,126 +133,159 @@
|
|||||||
0.15M
|
0.15M
|
||||||
:else
|
:else
|
||||||
0.07M)]
|
0.07M)]
|
||||||
(round-carry-cents
|
(round-carry-cents
|
||||||
(* commision%
|
(* commision%
|
||||||
0.01M
|
0.01M
|
||||||
(+
|
(+
|
||||||
(-> order :totals :subTotal :subunits )
|
(-> order :totals :subTotal :subunits)
|
||||||
(reduce +
|
|
||||||
0
|
|
||||||
(map (comp :subunits :cost) (:feesAndDiscounts (:catererCart order)))))))))
|
|
||||||
|
|
||||||
(defn ccp-fee [order]
|
|
||||||
(round-carry-cents
|
|
||||||
(* 0.000299M
|
|
||||||
(+
|
|
||||||
(-> order :totals :subTotal :subunits )
|
|
||||||
(-> order :totals :salesTax :subunits )
|
|
||||||
(reduce +
|
(reduce +
|
||||||
0
|
0
|
||||||
(map (comp :subunits :cost) (:feesAndDiscounts (:catererCart order))))))))
|
(map (comp :subunits :cost) (:feesAndDiscounts (:catererCart order)))))))))
|
||||||
|
|
||||||
|
(defn ccp-fee [order]
|
||||||
|
(round-carry-cents
|
||||||
|
(* 0.000299M
|
||||||
|
(+
|
||||||
|
(-> order :totals :subTotal :subunits)
|
||||||
|
(-> order :totals :salesTax :subunits)
|
||||||
|
(reduce +
|
||||||
|
0
|
||||||
|
(map (comp :subunits :cost) (:feesAndDiscounts (:catererCart order))))))))
|
||||||
|
|
||||||
(defn order->sales-order [{{:keys [timestamp]} :event {:keys [orderItems]} :catererCart :keys [client-code client-location uuid] :as order}]
|
(defn order->sales-order [{{:keys [timestamp]} :event {:keys [orderItems]} :catererCart :keys [client-code client-location uuid] :as order}]
|
||||||
(let [adjustment (round-carry-cents (- (+ (-> order :totals :subTotal :subunits (* 0.01))
|
(let [adjustment (round-carry-cents (- (+ (-> order :totals :subTotal :subunits (* 0.01))
|
||||||
(-> order :totals :salesTax :subunits (* 0.01)))
|
(-> order :totals :salesTax :subunits (* 0.01)))
|
||||||
(-> order :catererCart :totals :catererTotalDue )
|
(-> order :catererCart :totals :catererTotalDue)
|
||||||
(commision order)
|
(commision order)
|
||||||
(ccp-fee order)))
|
(ccp-fee order)))
|
||||||
service-charge (+ (commision order) (ccp-fee order))
|
service-charge (+ (commision order) (ccp-fee order))
|
||||||
tax (-> order :totals :salesTax :subunits (* 0.01))
|
tax (-> order :totals :salesTax :subunits (* 0.01))
|
||||||
tip (-> order :totals :tip :subunits (* 0.01))]
|
tip (-> order :totals :tip :subunits (* 0.01))]
|
||||||
#:sales-order
|
#:sales-order
|
||||||
{:date (atime/localize (coerce/to-date-time timestamp))
|
{:date (atime/localize (coerce/to-date-time timestamp))
|
||||||
:external-id (str "ezcater/order/" client-code "-" client-location "-" uuid)
|
:external-id (str "ezcater/order/" client-code "-" client-location "-" uuid)
|
||||||
:client [:client/code client-code]
|
:client [:client/code client-code]
|
||||||
:location client-location
|
:location client-location
|
||||||
:reference-link (str (url/url "https://ezmanage.ezcater.com/orders/" uuid ))
|
:reference-link (str (url/url "https://ezmanage.ezcater.com/orders/" uuid))
|
||||||
:line-items [#:order-line-item
|
:line-items [#:order-line-item
|
||||||
{:external-id (str "ezcater/order/" client-code "-" client-location "-" uuid "-" 0)
|
{:external-id (str "ezcater/order/" client-code "-" client-location "-" uuid "-" 0)
|
||||||
:item-name "EZCater Catering"
|
:item-name "EZCater Catering"
|
||||||
:category "EZCater Catering"
|
:category "EZCater Catering"
|
||||||
:discount adjustment
|
:discount adjustment
|
||||||
:tax tax
|
:tax tax
|
||||||
:total (+ (-> order :totals :subTotal :subunits (* 0.01))
|
:total (+ (-> order :totals :subTotal :subunits (* 0.01))
|
||||||
tax
|
tax
|
||||||
tip)}]
|
tip)}]
|
||||||
:charges [#:charge
|
:charges [#:charge
|
||||||
{:type-name "CARD"
|
{:type-name "CARD"
|
||||||
:date (atime/localize (coerce/to-date-time timestamp))
|
:date (atime/localize (coerce/to-date-time timestamp))
|
||||||
:client [:client/code client-code]
|
:client [:client/code client-code]
|
||||||
:location client-location
|
:location client-location
|
||||||
:external-id (str "ezcater/charge/" uuid)
|
:external-id (str "ezcater/charge/" uuid)
|
||||||
:processor :ccp-processor/ezcater
|
:processor :ccp-processor/ezcater
|
||||||
:total (+ (-> order :totals :subTotal :subunits (* 0.01))
|
:total (+ (-> order :totals :subTotal :subunits (* 0.01))
|
||||||
tax
|
tax
|
||||||
tip)
|
tip)
|
||||||
:tip tip}]
|
:tip tip}]
|
||||||
|
|
||||||
:total (+ (-> order :totals :subTotal :subunits (* 0.01))
|
:total (+ (-> order :totals :subTotal :subunits (* 0.01))
|
||||||
tax
|
tax
|
||||||
tip)
|
tip)
|
||||||
:discount adjustment
|
:discount adjustment
|
||||||
:service-charge service-charge
|
:service-charge service-charge
|
||||||
:tax tax
|
:tax tax
|
||||||
:tip tip
|
:tip tip
|
||||||
:returns 0.0
|
:returns 0.0
|
||||||
:vendor :vendor/ccp-ezcater}))
|
:vendor :vendor/ccp-ezcater}))
|
||||||
|
|
||||||
|
(defn- flatten-order-to-parquet! [order]
|
||||||
|
"Flatten a sales-order into entity-type tagged maps and buffer to parquet."
|
||||||
|
(let [so-ext-id (:sales-order/external-id order)
|
||||||
|
so-date (some-> (:sales-order/date order) .toString)
|
||||||
|
client (:sales-order/client order)
|
||||||
|
client-code (if (map? client) (:client/code client) client)]
|
||||||
|
(parquet/buffer! "sales-order"
|
||||||
|
{:entity-type "sales-order"
|
||||||
|
:external-id so-ext-id
|
||||||
|
:client-code client-code
|
||||||
|
:location (:sales-order/location order)
|
||||||
|
:vendor (:sales-order/vendor order)
|
||||||
|
:total (:sales-order/total order)
|
||||||
|
:tax (:sales-order/tax order)
|
||||||
|
:tip (:sales-order/tip order)
|
||||||
|
:discount (:sales-order/discount order)
|
||||||
|
:service-charge (:sales-order/service-charge order)
|
||||||
|
:date so-date})
|
||||||
|
(when-let [charges (:sales-order/charges order)]
|
||||||
|
(doseq [chg charges]
|
||||||
|
(parquet/buffer! "charge"
|
||||||
|
{:entity-type "charge"
|
||||||
|
:external-id (:charge/external-id chg)
|
||||||
|
:type-name (:charge/type-name chg)
|
||||||
|
:total (:charge/total chg)
|
||||||
|
:tax (:charge/tax chg)
|
||||||
|
:tip (:charge/tip chg)
|
||||||
|
:date so-date
|
||||||
|
:processor (some-> (:charge/processor chg) name)
|
||||||
|
:sales-order-external-id so-ext-id})))
|
||||||
|
(when-let [items (:sales-order/line-items order)]
|
||||||
|
(doseq [li items]
|
||||||
|
(parquet/buffer! "line-item"
|
||||||
|
{:entity-type "line-item"
|
||||||
|
:item-name (:order-line-item/item-name li)
|
||||||
|
:category (:order-line-item/category li)
|
||||||
|
:total (:order-line-item/total li)
|
||||||
|
:tax (:order-line-item/tax li)
|
||||||
|
:discount (:order-line-item/discount li)
|
||||||
|
:sales-order-external-id so-ext-id})))))
|
||||||
|
|
||||||
(defn get-by-id [integration id]
|
(defn get-by-id [integration id]
|
||||||
(query
|
(query
|
||||||
integration
|
integration
|
||||||
{:venia/queries [[:order {:id id}
|
{:venia/queries [[:order {:id id}
|
||||||
[:uuid
|
[:uuid
|
||||||
:orderNumber
|
:orderNumber
|
||||||
:orderSourceType
|
:orderSourceType
|
||||||
[:caterer
|
[:caterer
|
||||||
[:name
|
[:name
|
||||||
:uuid
|
:uuid
|
||||||
[:address [:street]]]]
|
[:address [:street]]]]
|
||||||
[:event
|
[:event
|
||||||
[:timestamp
|
[:timestamp
|
||||||
:catererHandoffFoodTime
|
:catererHandoffFoodTime
|
||||||
:orderType]]
|
:orderType]]
|
||||||
[:catererCart [[:orderItems
|
[:catererCart [[:orderItems
|
||||||
[:name
|
[:name
|
||||||
:quantity
|
:quantity
|
||||||
:posItemId
|
:posItemId
|
||||||
[:totalInSubunits
|
[:totalInSubunits
|
||||||
[:currency
|
[:currency
|
||||||
:subunits]]]]
|
:subunits]]]]
|
||||||
[:totals
|
[:totals
|
||||||
[:catererTotalDue]]
|
[:catererTotalDue]]
|
||||||
[:feesAndDiscounts
|
[:feesAndDiscounts
|
||||||
{:type 'DELIVERY_FEE}
|
{:type 'DELIVERY_FEE}
|
||||||
[[:cost
|
[[:cost
|
||||||
[:currency
|
[:currency
|
||||||
:subunits]]]]]]
|
:subunits]]]]]]
|
||||||
[:totals [[:customerTotalDue
|
[:totals [[:customerTotalDue
|
||||||
[
|
[:currency
|
||||||
:currency
|
:subunits]]
|
||||||
:subunits
|
[:pointOfSaleIntegrationFee
|
||||||
]]
|
[:currency
|
||||||
[:pointOfSaleIntegrationFee
|
:subunits]]
|
||||||
[
|
[:tip
|
||||||
:currency
|
[:currency
|
||||||
:subunits
|
:subunits]]
|
||||||
]]
|
[:salesTax
|
||||||
[:tip
|
[:currency
|
||||||
[:currency
|
:subunits]]
|
||||||
:subunits]]
|
[:salesTaxRemittance
|
||||||
[:salesTax
|
[:currency
|
||||||
[
|
:subunits]]
|
||||||
:currency
|
[:subTotal
|
||||||
:subunits
|
[:currency
|
||||||
]]
|
:subunits]]]]]]]}))
|
||||||
[:salesTaxRemittance
|
|
||||||
[:currency
|
|
||||||
:subunits
|
|
||||||
]]
|
|
||||||
[:subTotal
|
|
||||||
[:currency
|
|
||||||
:subunits]]]]]]]}))
|
|
||||||
|
|
||||||
(defn lookup-order [json]
|
(defn lookup-order [json]
|
||||||
(let [caterer (get-caterer (get json "parent_id"))
|
(let [caterer (get-caterer (get json "parent_id"))
|
||||||
@@ -262,26 +293,31 @@
|
|||||||
client (-> caterer :ezcater-location/_caterer first :client/_ezcater-locations :client/code)
|
client (-> caterer :ezcater-location/_caterer first :client/_ezcater-locations :client/code)
|
||||||
location (-> caterer :ezcater-location/_caterer first :ezcater-location/location)]
|
location (-> caterer :ezcater-location/_caterer first :ezcater-location/location)]
|
||||||
(if (and client location)
|
(if (and client location)
|
||||||
(doto
|
(doto
|
||||||
(-> (get-by-id integration (get json "entity_id"))
|
(-> (get-by-id integration (get json "entity_id"))
|
||||||
(:order)
|
(:order)
|
||||||
(assoc :client-code client
|
(assoc :client-code client
|
||||||
:client-location location))
|
:client-location location))
|
||||||
(#(alog/info ::order-details :detail %)))
|
(#(alog/info ::order-details :detail %)))
|
||||||
(alog/warn ::caterer-no-longer-has-location :json json))))
|
(alog/warn ::caterer-no-longer-has-location :json json))))
|
||||||
|
|
||||||
(defn import-order [json]
|
(defn import-order [json]
|
||||||
;; {"id" "bf3dcf5c-a68f-42d9-9084-049133e03d3d", "parent_type" "Caterer", "parent_id" "91541331-d7ae-4634-9e8b-ccbbcfb2ce70", "entity_type" "Order", "entity_id" "9ab05fee-a9c5-483b-a7f2-14debde4b7a8", "key" "accepted", "occurred_at" "2022-07-21T19:21:07.549Z"}
|
|
||||||
(alog/info
|
(alog/info
|
||||||
::try-import-order
|
::try-import-order
|
||||||
:json json)
|
:json json)
|
||||||
@(dc/transact conn (filter identity
|
(when-let [order (some-> json
|
||||||
[(some-> json
|
(lookup-order)
|
||||||
(lookup-order)
|
(order->sales-order)
|
||||||
(order->sales-order)
|
(update :sales-order/date coerce/to-date)
|
||||||
(update :sales-order/date coerce/to-date)
|
(update-in [:sales-order/charges 0 :charge/date] coerce/to-date))]
|
||||||
(update-in [:sales-order/charges 0 :charge/date] coerce/to-date))])))
|
(try
|
||||||
|
(flatten-order-to-parquet! order)
|
||||||
|
(alog/info ::order-buffered
|
||||||
|
:external-id (:sales-order/external-id order))
|
||||||
|
(catch Exception e
|
||||||
|
(alog/error ::buffer-failed
|
||||||
|
:exception e
|
||||||
|
:order (:sales-order/external-id order))))))
|
||||||
(defn upsert-recent []
|
(defn upsert-recent []
|
||||||
(upsert-ezcater)
|
(upsert-ezcater)
|
||||||
(let [last-sunday (coerce/to-date (time/plus (second (->> (time/today)
|
(let [last-sunday (coerce/to-date (time/plus (second (->> (time/today)
|
||||||
@@ -312,30 +348,30 @@
|
|||||||
"key" "accepted",
|
"key" "accepted",
|
||||||
"occurred_at" "2022-07-21T19:21:07.549Z"}
|
"occurred_at" "2022-07-21T19:21:07.549Z"}
|
||||||
ezcater-order (lookup-order lookup-map)
|
ezcater-order (lookup-order lookup-map)
|
||||||
extant-order (dc/pull (dc/db conn) '[:sales-order/total
|
extant-order (dc/pull (dc/db conn '[:sales-order/total]
|
||||||
:sales-order/tax
|
:sales-order/tax
|
||||||
:sales-order/tip
|
:sales-order/tip
|
||||||
:sales-order/discount
|
:sales-order/discount
|
||||||
:sales-order/external-id
|
:sales-order/external-id
|
||||||
{:sales-order/charges [:charge/tax
|
{:sales-order/charges [:charge/tax
|
||||||
:charge/tip
|
:charge/tip
|
||||||
:charge/total
|
:charge/total
|
||||||
:charge/external-id]
|
:charge/external-id]
|
||||||
:sales-order/line-items [:order-line-item/external-id
|
:sales-order/line-items [:order-line-item/external-id
|
||||||
:order-line-item/total
|
:order-line-item/total
|
||||||
:order-line-item/tax
|
:order-line-item/tax
|
||||||
:order-line-item/discount]}]
|
:order-line-item/discount]})
|
||||||
[:sales-order/external-id order])
|
[:sales-order/external-id order])
|
||||||
|
|
||||||
updated-order (-> (order->sales-order ezcater-order)
|
updated-order (-> (order->sales-order ezcater-order)
|
||||||
(select-keys
|
(select-keys
|
||||||
#{:sales-order/total
|
#{:sales-order/total
|
||||||
:sales-order/tax
|
:sales-order/tax
|
||||||
:sales-order/tip
|
:sales-order/tip
|
||||||
:sales-order/discount
|
:sales-order/discount
|
||||||
:sales-order/charges
|
:sales-order/charges
|
||||||
:sales-order/external-id
|
:sales-order/external-id
|
||||||
:sales-order/line-items})
|
:sales-order/line-items})
|
||||||
(update :sales-order/line-items
|
(update :sales-order/line-items
|
||||||
(fn [c]
|
(fn [c]
|
||||||
(map #(select-keys % #{:order-line-item/external-id
|
(map #(select-keys % #{:order-line-item/external-id
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
[auto-ap.jobs.core :refer [execute]]
|
[auto-ap.jobs.core :refer [execute]]
|
||||||
[auto-ap.logging :as alog]
|
[auto-ap.logging :as alog]
|
||||||
[auto-ap.time :as atime]
|
[auto-ap.time :as atime]
|
||||||
|
[auto-ap.storage.parquet :as pq]
|
||||||
[clj-time.coerce :as c]
|
[clj-time.coerce :as c]
|
||||||
[clj-time.core :as time]
|
[clj-time.core :as time]
|
||||||
[clj-time.periodic :as per]
|
[clj-time.periodic :as per]
|
||||||
@@ -39,17 +40,14 @@
|
|||||||
(dc/db conn)
|
(dc/db conn)
|
||||||
number)))
|
number)))
|
||||||
|
|
||||||
|
|
||||||
(defn delete-all []
|
(defn delete-all []
|
||||||
@(dc/transact-async conn
|
@(dc/transact-async conn
|
||||||
(->>
|
(->>
|
||||||
(dc/q '[:find ?ss
|
(dc/q '[:find ?ss
|
||||||
:where [?ss :sales-summary/date]]
|
:where [?ss :sales-summary/date]]
|
||||||
(dc/db conn))
|
(dc/db conn))
|
||||||
(map (fn [[ ss]]
|
(map (fn [[ss]]
|
||||||
[:db/retractEntity ss])))))
|
[:db/retractEntity ss])))))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(defn dirty-sales-summaries [c]
|
(defn dirty-sales-summaries [c]
|
||||||
(let [client-id (dc/entid (dc/db conn) c)]
|
(let [client-id (dc/entid (dc/db conn) c)]
|
||||||
@@ -98,101 +96,86 @@
|
|||||||
"card refunds" 41400
|
"card refunds" 41400
|
||||||
"food app refunds" 41400})
|
"food app refunds" 41400})
|
||||||
|
|
||||||
(defn get-payment-items [c date]
|
(defn- get-payment-items-parquet [c date]
|
||||||
(->>
|
(let [date-str (.toString date)]
|
||||||
(dc/q '[:find ?processor ?type-name (sum ?total)
|
(when-let [rows (seq (pq/query-deduped "charge" date-str date-str))]
|
||||||
:with ?c
|
(let [client-code (if (map? c) (:client/code c) c)
|
||||||
:in $ [?clients ?start-date ?end-date]
|
filtered (filter #(= client-code (:client-code %)) rows)]
|
||||||
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
(reduce
|
||||||
[?e :sales-order/charges ?c]
|
(fn [acc {:keys [processor type-name total]}]
|
||||||
[?c :charge/type-name ?type-name]
|
(update acc
|
||||||
(or-join [?c ?processor]
|
(cond
|
||||||
(and [?c :charge/processor ?p]
|
(= type-name "CARD") "Card Payments"
|
||||||
[?p :db/ident ?processor])
|
(= type-name "CASH") "Cash Payments"
|
||||||
(and
|
(#{"SQUARE_GIFT_CARD" "WALLET" "GIFT_CARD"} type-name) "Gift Card Payments"
|
||||||
(not [?c :charge/processor])
|
(#{"doordash" "grubhub" "uber-eats"} processor) "Food App Payments"
|
||||||
[(ground :ccp-processor/na) ?processor]))
|
:else "Unknown")
|
||||||
[?c :charge/total ?total]]
|
(fnil + 0.0)
|
||||||
(dc/db conn)
|
(or total 0.0)))
|
||||||
[[c] date date])
|
{}
|
||||||
(reduce
|
filtered)))))
|
||||||
(fn [acc [processor type-name total]]
|
|
||||||
(update
|
|
||||||
acc
|
|
||||||
(cond (= type-name "CARD")
|
|
||||||
"Card Payments"
|
|
||||||
(= type-name "CASH")
|
|
||||||
"Cash Payments"
|
|
||||||
(#{"SQUARE_GIFT_CARD" "WALLET" "GIFT_CARD"} type-name)
|
|
||||||
"Gift Card Payments"
|
|
||||||
(#{:ccp-processor/toast
|
|
||||||
#_:ccp-processor/ezcater
|
|
||||||
#_:ccp-processor/koala
|
|
||||||
:ccp-processor/doordash
|
|
||||||
:ccp-processor/grubhub
|
|
||||||
:ccp-processor/uber-eats} processor)
|
|
||||||
"Food App Payments"
|
|
||||||
:else
|
|
||||||
"Unknown")
|
|
||||||
(fnil + 0.0)
|
|
||||||
total))
|
|
||||||
{})
|
|
||||||
(map (fn [[k v]]
|
|
||||||
{:db/id (str (java.util.UUID/randomUUID))
|
|
||||||
:sales-summary-item/sort-order 0
|
|
||||||
:sales-summary-item/category k
|
|
||||||
|
|
||||||
:ledger-mapped/amount (if (= "Card Payments" k)
|
|
||||||
(- v (get-fee c date))
|
|
||||||
v)
|
|
||||||
:ledger-mapped/ledger-side :ledger-side/debit}))))
|
|
||||||
|
|
||||||
(defn get-discounts [c date]
|
(defn- get-discounts-parquet [c date]
|
||||||
(when-let [discount (ffirst (dc/q '[:find (sum ?discount)
|
(let [client-code (if (map? c) (:client/code c) c)
|
||||||
:with ?e
|
date-str (.toString date)
|
||||||
:in $ [?clients ?start-date ?end-date]
|
discount (auto-ap.storage.sales-summaries/sum-discounts client-code date-str date-str)]
|
||||||
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
(when (and discount (pos? discount))
|
||||||
[?e :sales-order/discount ?discount]]
|
{:db/id (str (java.util.UUID/randomUUID))
|
||||||
(dc/db conn)
|
:sales-summary-item/sort-order 1
|
||||||
[[c] date date]))]
|
:sales-summary-item/category "Discounts"
|
||||||
|
:ledger-mapped/amount discount
|
||||||
|
:ledger-mapped/ledger-side :ledger-side/debit})))
|
||||||
|
|
||||||
|
(defn- get-refund-items-parquet [c date]
|
||||||
|
(let [client-code (if (map? c) (:client/code c) c)
|
||||||
|
date-str (.toString date)
|
||||||
|
refunds (auto-ap.storage.sales-summaries/sum-refunds-by-type client-code date-str date-str)]
|
||||||
|
(when (seq refunds)
|
||||||
|
(map (fn [[type-name total]]
|
||||||
|
{:db/id (str (java.util.UUID/randomUUID))
|
||||||
|
:sales-summary-item/sort-order 3
|
||||||
|
:sales-summary-item/category (cond
|
||||||
|
(= type-name "CARD") "Card Refunds"
|
||||||
|
(= type-name "CASH") "Cash Refunds"
|
||||||
|
:else "Food App Refunds")
|
||||||
|
:ledger-mapped/amount total
|
||||||
|
:ledger-mapped/ledger-side :ledger-side/credit})
|
||||||
|
refunds))))
|
||||||
|
|
||||||
|
(defn- get-tax-parquet [c date]
|
||||||
|
(let [client-code (if (map? c) (:client/code c) c)
|
||||||
|
date-str (.toString date)
|
||||||
|
tax (auto-ap.storage.sales-summaries/sum-taxes client-code date-str date-str)]
|
||||||
{:db/id (str (java.util.UUID/randomUUID))
|
{:db/id (str (java.util.UUID/randomUUID))
|
||||||
|
:sales-summary-item/category "Tax"
|
||||||
:sales-summary-item/sort-order 1
|
:sales-summary-item/sort-order 1
|
||||||
:sales-summary-item/category "Discounts"
|
:ledger-mapped/ledger-side :ledger-side/credit
|
||||||
:ledger-mapped/amount discount
|
:ledger-mapped/amount (or tax 0.0)}))
|
||||||
:ledger-mapped/ledger-side :ledger-side/debit}))
|
|
||||||
|
|
||||||
(defn get-refund-items [c date]
|
|
||||||
(->>
|
|
||||||
(dc/q '[:find ?type-name (sum ?t)
|
|
||||||
:with ?e
|
|
||||||
:in $ [?clients ?start-date ?end-date]
|
|
||||||
:where
|
|
||||||
:where [(iol-ion.query/scan-sales-refunds $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
|
||||||
[?e :sales-refund/type ?type-name]
|
|
||||||
[?e :sales-refund/total ?t]]
|
|
||||||
(dc/db conn)
|
|
||||||
[[c] date date])
|
|
||||||
(reduce
|
|
||||||
(fn [acc [type-name total]]
|
|
||||||
(update
|
|
||||||
acc
|
|
||||||
(cond (= type-name "CARD")
|
|
||||||
"Card Refunds"
|
|
||||||
(= type-name "CASH")
|
|
||||||
"Cash Refunds"
|
|
||||||
:else
|
|
||||||
"Food App Refunds")
|
|
||||||
(fnil + 0.0)
|
|
||||||
total))
|
|
||||||
{})
|
|
||||||
(map (fn [[k v]]
|
|
||||||
{:db/id (str (java.util.UUID/randomUUID))
|
|
||||||
:sales-summary-item/sort-order 3
|
|
||||||
:sales-summary-item/category k
|
|
||||||
:ledger-mapped/amount v
|
|
||||||
:ledger-mapped/ledger-side :ledger-side/credit}))))
|
|
||||||
|
|
||||||
|
(defn- get-tip-parquet [c date]
|
||||||
|
(let [client-code (if (map? c) (:client/code c) c)
|
||||||
|
date-str (.toString date)
|
||||||
|
tip (auto-ap.storage.sales-summaries/sum-tips client-code date-str date-str)]
|
||||||
|
{:ledger-mapped/ledger-side :ledger-side/credit
|
||||||
|
:sales-summary-item/sort-order 2
|
||||||
|
:db/id (str (java.util.UUID/randomUUID))
|
||||||
|
:sales-summary-item/category "Tip"
|
||||||
|
:ledger-mapped/amount (or tip 0.0)}))
|
||||||
|
|
||||||
|
(defn- get-sales-parquet [c date]
|
||||||
|
(let [client-code (if (map? c) (:client/code c) c)
|
||||||
|
date-str (.toString date)
|
||||||
|
sales (auto-ap.storage.sales-summaries/sum-sales-by-category client-code date-str date-str)]
|
||||||
|
(for [{:keys [category total tax discount]} sales]
|
||||||
|
{:db/id (str (java.util.UUID/randomUUID))
|
||||||
|
:sales-summary-item/category (or category "Unknown")
|
||||||
|
:sales-summary-item/sort-order 0
|
||||||
|
:sales-summary-item/total total
|
||||||
|
:sales-summary-item/net (- (+ total discount) tax)
|
||||||
|
:sales-summary-item/tax tax
|
||||||
|
:sales-summary-item/discount discount
|
||||||
|
:ledger-mapped/ledger-side :ledger-side/credit
|
||||||
|
:ledger-mapped/amount (- (+ total discount) tax)})))
|
||||||
|
|
||||||
(defn get-fees [c date]
|
(defn get-fees [c date]
|
||||||
(when-let [fee (get-fee c date)]
|
(when-let [fee (get-fee c date)]
|
||||||
@@ -293,19 +276,17 @@
|
|||||||
|
|
||||||
:sales-summary/items
|
:sales-summary/items
|
||||||
(->>
|
(->>
|
||||||
(get-sales c date)
|
(get-sales-parquet c date)
|
||||||
(concat (get-payment-items c date))
|
(concat (get-payment-items-parquet c date))
|
||||||
(concat (get-refund-items c date))
|
(concat (get-refund-items-parquet c date))
|
||||||
(cons (get-discounts c date))
|
(cons (get-discounts-parquet c date))
|
||||||
(cons (get-fees c date))
|
(cons (get-fees c date))
|
||||||
(cons (get-tax c date))
|
(cons (get-tax-parquet c date))
|
||||||
(cons (get-tip c date))
|
(cons (get-tip-parquet c date))
|
||||||
(cons (get-returns c date))
|
|
||||||
(filter identity)
|
(filter identity)
|
||||||
(map (fn [z]
|
(map (fn [z]
|
||||||
(assoc z :ledger-mapped/account (some-> z :sales-summary-item/category str/lower-case name->number lookup-account)
|
(assoc z :ledger-mapped/account (some-> z :sales-summary-item/category str/lower-case name->number lookup-account)
|
||||||
:sales-summary-item/manual? false))
|
:sales-summary-item/manual? false))))}]
|
||||||
)) }]
|
|
||||||
(if (seq (:sales-summary/items result))
|
(if (seq (:sales-summary/items result))
|
||||||
(do
|
(do
|
||||||
(alog/info ::upserting-summaries
|
(alog/info ::upserting-summaries
|
||||||
@@ -313,12 +294,11 @@
|
|||||||
@(dc/transact conn [[:upsert-entity result]]))
|
@(dc/transact conn [[:upsert-entity result]]))
|
||||||
@(dc/transact conn [{:db/id id :sales-summary/dirty false}]))))))
|
@(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" ])
|
(comment
|
||||||
date #inst "2024-04-14T00:00:00-07:00"]
|
;; TODO: Move to test file or proper location
|
||||||
(get-payment-items c date)
|
(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)))
|
||||||
|
|
||||||
|
|
||||||
(defn reset-summaries []
|
(defn reset-summaries []
|
||||||
@(dc/transact conn (->> (dc/q '[:find ?sos
|
@(dc/transact conn (->> (dc/q '[:find ?sos
|
||||||
@@ -328,16 +308,13 @@
|
|||||||
(map (fn [[sos]]
|
(map (fn [[sos]]
|
||||||
[:db/retractEntity sos])))))
|
[:db/retractEntity sos])))))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(comment
|
(comment
|
||||||
(auto-ap.datomic/transact-schema conn)
|
(auto-ap.datomic/transact-schema conn)
|
||||||
|
|
||||||
@(dc/transact conn [{:db/ident :sales-summary/total-unknown-processor-payments
|
@(dc/transact conn [{:db/ident :sales-summary/total-unknown-processor-payments
|
||||||
:db/noHistory true,
|
:db/noHistory true,
|
||||||
:db/valueType :db.type/double
|
:db/valueType :db.type/double
|
||||||
:db/cardinality :db.cardinality/one}])
|
:db/cardinality :db.cardinality/one}])
|
||||||
|
|
||||||
(apply mark-dirty [:client/code "NGCL"] (last-n-days 30))
|
(apply mark-dirty [:client/code "NGCL"] (last-n-days 30))
|
||||||
|
|
||||||
@@ -356,7 +333,7 @@
|
|||||||
[?sos :sales-summary/date ?d]
|
[?sos :sales-summary/date ?d]
|
||||||
[(= ?d #inst "2024-04-10T00:00:00-07:00")]]
|
[(= ?d #inst "2024-04-10T00:00:00-07:00")]]
|
||||||
(dc/db conn))
|
(dc/db conn))
|
||||||
|
|
||||||
(dc/q '[:find ?n ?p2 (sum ?total)
|
(dc/q '[:find ?n ?p2 (sum ?total)
|
||||||
:with ?c
|
:with ?c
|
||||||
:in $ [?clients ?start-date ?end-date]
|
:in $ [?clients ?start-date ?end-date]
|
||||||
@@ -369,23 +346,18 @@
|
|||||||
(dc/db conn)
|
(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"])
|
[[(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]
|
:in $ [?clients ?start-date ?end-date]
|
||||||
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
||||||
[?e :sales-order/line-items ?li]
|
[?e :sales-order/line-items ?li]
|
||||||
[?li :order-line-item/item-name ?n] ]
|
[?li :order-line-item/item-name ?n]]
|
||||||
(dc/db conn)
|
(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"])
|
[[(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))
|
||||||
|
|
||||||
(defn -main [& _]
|
(defn -main [& _]
|
||||||
(execute "sales-summaries" sales-summaries-v2))
|
(execute "sales-summaries" sales-summaries-v2))
|
||||||
|
|
||||||
220
src/clj/auto_ap/migration/cleanup_sales.clj
Normal file
220
src/clj/auto_ap/migration/cleanup_sales.clj
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
(ns auto-ap.migration.cleanup-sales
|
||||||
|
(:require [auto-ap.datomic :refer [conn]]
|
||||||
|
[auto-ap.storage.parquet :as pq]
|
||||||
|
[amazonica.aws.s3 :as s3]
|
||||||
|
[datomic.api :as d-api]
|
||||||
|
[clojure.string :as str]))
|
||||||
|
|
||||||
|
(def ^:private BATCH-SIZE 1000)
|
||||||
|
(def ^:private DRY-RUN? true)
|
||||||
|
|
||||||
|
(defn- set-dry-run! [v]
|
||||||
|
(alter-var-root #'DRY-RUN? (constantly v)))
|
||||||
|
|
||||||
|
; -- query helpers
|
||||||
|
|
||||||
|
(defn- query-sales-order-ids
|
||||||
|
"Return all entity IDs that have :sales-order/external-id."
|
||||||
|
[db]
|
||||||
|
(->> (d-api/q '[:find ?e
|
||||||
|
:where [?e :sales-order/external-id]]
|
||||||
|
db)
|
||||||
|
(map first)))
|
||||||
|
|
||||||
|
(defn- collect-child-ids
|
||||||
|
"Gather child entity IDs for a batch of sales orders. Returns map with
|
||||||
|
keys :orders, :charges, :line-items, :refunds — each a vector of
|
||||||
|
entity IDs eligible for retraction."
|
||||||
|
[db order-ids]
|
||||||
|
(let [order-set (set order-ids)
|
||||||
|
charges (->> (d-api/q '[:find ?c
|
||||||
|
:in $ [?o ...]
|
||||||
|
:where [$ ?o :sales-order/charges ?c]]
|
||||||
|
db order-set)
|
||||||
|
(map second))
|
||||||
|
refunds (->> (d-api/q '[:find ?r
|
||||||
|
:in $ [?o ...]
|
||||||
|
:where [$ ?o :sales-order/refunds ?r]]
|
||||||
|
db order-set)
|
||||||
|
(map second))
|
||||||
|
line-items (->> (d-api/q '[:find ?li
|
||||||
|
:in $ [?c ...]
|
||||||
|
:where [$ ?c :charge/line-items ?li]]
|
||||||
|
db charges)
|
||||||
|
(map second))]
|
||||||
|
{:orders order-ids
|
||||||
|
:charges (vec charges)
|
||||||
|
:line-items (vec line-items)
|
||||||
|
:refunds (vec refunds)}))
|
||||||
|
|
||||||
|
; -- transaction batching
|
||||||
|
|
||||||
|
(defn- batch-transact
|
||||||
|
"Issue [:db/retractEntity ...] transactions in batches of BATCH-SIZE.
|
||||||
|
conn$ is a Datomic connection object.
|
||||||
|
entity-ids should be a seq of Long entity IDs."
|
||||||
|
[conn entity-ids]
|
||||||
|
(let [batches (partition-all BATCH-SIZE entity-ids)
|
||||||
|
_ (doseq [[idx batch] (map-indexed vector batches)]
|
||||||
|
(let [n (count batch)
|
||||||
|
txes (map (fn [eid]
|
||||||
|
[:db/retractEntity eid])
|
||||||
|
batch)]
|
||||||
|
(println " batch" idx ":" n "retracts")
|
||||||
|
(when-not DRY-RUN?
|
||||||
|
@(d-api/transact conn txes))))]
|
||||||
|
:done))
|
||||||
|
|
||||||
|
(defn- retract-all-child-ids!
|
||||||
|
"Retract orders, charges, line-items and refunds from all entity-ID
|
||||||
|
maps produced by collect-child-ids. Logs progress every batch."
|
||||||
|
[conn child-entity-map]
|
||||||
|
(doseq [[type id-seq] child-entity-map]
|
||||||
|
(when (seq id-seq)
|
||||||
|
(println "retracting" type ":" (count id-seq) "ids")
|
||||||
|
(batch-transact conn id-seq))))
|
||||||
|
|
||||||
|
; -- month grouping
|
||||||
|
|
||||||
|
(defn- group-orders-by-month
|
||||||
|
"Group sales order entity IDs by [year month] extracted from
|
||||||
|
:sales-order/day-value. Returns map {{y m} [eid ...]}."
|
||||||
|
[db order-ids]
|
||||||
|
(reduce (fn [acc eid]
|
||||||
|
(when-let [day-val (:sales-order/day-value
|
||||||
|
(d-api/entity db eid))]
|
||||||
|
(let [[y m _] (str/split (str day-val) #"-")
|
||||||
|
k [(Integer/parseInt y)
|
||||||
|
(Integer/parseInt m)]]
|
||||||
|
(update acc k conj eid))))
|
||||||
|
{}
|
||||||
|
order-ids))
|
||||||
|
|
||||||
|
; -- S3 verification (uses amazonica + parquet module)
|
||||||
|
|
||||||
|
(def ENTITY-TYPES ["sales-order" "charge"
|
||||||
|
"line-item" "sales-refund"])
|
||||||
|
|
||||||
|
(defn- s3-keys-for-date
|
||||||
|
"Build S3 parquet keys for all entity types on a given date."
|
||||||
|
[date-str]
|
||||||
|
(mapv #(pq/parquet-key % date-str) ENTITY-TYPES))
|
||||||
|
|
||||||
|
(defn- days-in-month
|
||||||
|
"Return seq of YYYY-MM-DD strings for all days in [year month]."
|
||||||
|
[year month]
|
||||||
|
(let [start (java.time.LocalDate/of year month 1)
|
||||||
|
first-of-next (.plusMonths start 1)
|
||||||
|
diff (.toEpochDay first-of-next)
|
||||||
|
start-day (.toEpochDay start)]
|
||||||
|
(for [d (range start-day diff)]
|
||||||
|
(.toString (java.time.LocalDate/ofEpochDay d)))))
|
||||||
|
|
||||||
|
(defn- object-exists?
|
||||||
|
"Check if an S3 object exists via head-object (no download)."
|
||||||
|
[key]
|
||||||
|
(try
|
||||||
|
(s3/get-object {:bucket-name pq/*bucket*
|
||||||
|
:key key}
|
||||||
|
{:request-method :head})
|
||||||
|
true
|
||||||
|
(catch com.amazonaws.services.s3.model.AmazonS3Exception _
|
||||||
|
false)))
|
||||||
|
|
||||||
|
(defn- verify-month-in-s3?
|
||||||
|
"Check that every day in [year month] has at least one backing
|
||||||
|
Parquet file on S3 across all entity types.
|
||||||
|
Returns a map {:ok bool :missing vec-of-dates}."
|
||||||
|
[year month]
|
||||||
|
(let [dates (days-in-month year month)]
|
||||||
|
(loop [[d & rest] dates
|
||||||
|
result []]
|
||||||
|
(if-not d
|
||||||
|
{:ok (empty? result)
|
||||||
|
:missing result}
|
||||||
|
(let [keys (s3-keys-for-date d)
|
||||||
|
found? (some object-exists? keys)]
|
||||||
|
(recur rest
|
||||||
|
(if found?
|
||||||
|
result
|
||||||
|
(conj result d))))))))
|
||||||
|
|
||||||
|
; -- public API: delete-by-month
|
||||||
|
|
||||||
|
(defn- delete-by-month [conn client-entid year month]
|
||||||
|
"Retract all sales entities for a specific year+month.
|
||||||
|
Returns :ok on success, :skipped if S3 verification failed."
|
||||||
|
(println "=== deleting" year "-" month
|
||||||
|
"dry-run? =" DRY-RUN?)
|
||||||
|
(let [db (d-api/db conn)
|
||||||
|
all-ids (query-sales-order-ids db)
|
||||||
|
group (group-orders-by-month db all-ids)
|
||||||
|
target-keys (get group [year month] [])]
|
||||||
|
(if (zero? (count target-keys))
|
||||||
|
(do (println " no orders found for" year "-" month)
|
||||||
|
:skipped)
|
||||||
|
(do
|
||||||
|
(let [child-maps (collect-child-ids db target-keys)
|
||||||
|
total-ids (->> child-maps vals
|
||||||
|
(reduce into [])
|
||||||
|
distinct
|
||||||
|
count)]
|
||||||
|
(println " " total-ids "total entities to retract")
|
||||||
|
(when-not DRY-RUN?
|
||||||
|
(retract-all-child-ids! conn child-maps)))
|
||||||
|
:ok))))
|
||||||
|
|
||||||
|
; -- public API: cleanup-all
|
||||||
|
|
||||||
|
(defn cleanup-all []
|
||||||
|
"Remove ALL sales-order, charge, line-item, sales-refund from
|
||||||
|
Datomic. Uses d-api/transact to issue [:db/retractEntity ...] for
|
||||||
|
each entity. Iterates over every month found in DB."
|
||||||
|
(let [db (d-api/db conn)
|
||||||
|
all-ids (query-sales-order-ids db)
|
||||||
|
group (group-orders-by-month db all-ids)
|
||||||
|
months (sort (keys group))]
|
||||||
|
(println "found" (count months) "months of data")
|
||||||
|
(doseq [[y m] months]
|
||||||
|
(delete-by-month conn nil y m))
|
||||||
|
(println "cleanup-all complete")))
|
||||||
|
|
||||||
|
; -- public API: safe-cleanup-all
|
||||||
|
|
||||||
|
(defn- collect-all-months [conn]
|
||||||
|
"Return sorted vec of [year month] pairs with sales orders in DB."
|
||||||
|
(let [db (d-api/db conn)
|
||||||
|
all-ids (query-sales-order-ids db)
|
||||||
|
grouped (group-orders-by-month db all-ids)]
|
||||||
|
(sort (keys grouped))))
|
||||||
|
|
||||||
|
(defn safe-cleanup-all []
|
||||||
|
"Same as cleanup-all but verifies S3 data exists first.
|
||||||
|
Before deleting a month's entities, checks that parquet files
|
||||||
|
exist in auto-ap.storage.parquet bucket under prefix 'sales-details'."
|
||||||
|
(let [conn$ conn
|
||||||
|
months (collect-all-months conn)]
|
||||||
|
(println "=== safe-cleanup-all"
|
||||||
|
"months:" (count months)
|
||||||
|
"dry-run? =" DRY-RUN?)
|
||||||
|
(doseq [[y m] months]
|
||||||
|
(when-not DRY-RUN?
|
||||||
|
(let [result (verify-month-in-s3? y m)
|
||||||
|
missing (:missing result)]
|
||||||
|
(cond
|
||||||
|
(:ok result)
|
||||||
|
(do (println "verified" y "-" m "S3 OK, deleting...")
|
||||||
|
(delete-by-month conn$ nil y m))
|
||||||
|
|
||||||
|
(> (count missing) 0)
|
||||||
|
(do (println "ERROR" y "-" m "missing in S3:"
|
||||||
|
(str/join ", " missing))
|
||||||
|
(throw
|
||||||
|
(ex-info
|
||||||
|
"Missing S3 data — aborting!"
|
||||||
|
{:year y :month m
|
||||||
|
:missing missing})))
|
||||||
|
|
||||||
|
:else
|
||||||
|
(println "SKIPPING" y "-" m "no parquet files")))))
|
||||||
|
(println "safe-cleanup-all complete")))
|
||||||
230
src/clj/auto_ap/migration/sales_to_parquet.clj
Normal file
230
src/clj/auto_ap/migration/sales_to_parquet.clj
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
(ns auto-ap.migration.sales-to-parquet
|
||||||
|
"Migrate historical sales data from Datomic to Parquet + S3.
|
||||||
|
|
||||||
|
Groups records by business date and writes daily partitions.
|
||||||
|
Dead-letter records (missing dates) are written separately.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
(migrate-all) ; full migration earliest → latest
|
||||||
|
(write-day-by-day \"2024-01-01\" \"2024-03-31\") ; date range
|
||||||
|
(write-dead-letter [flat]) ; write orphaned records"
|
||||||
|
(:require [auto-ap.datomic :refer [conn]]
|
||||||
|
[auto-ap.storage.parquet :as p]
|
||||||
|
[clojure.string :as str]
|
||||||
|
[datomic.api :as dc]))
|
||||||
|
|
||||||
|
(defn- fetch-all-sales-order-ids []
|
||||||
|
"Query Datomic for all sales-order external-ids (as entity IDs).
|
||||||
|
Returns a vector of entitity ids."
|
||||||
|
(->> (dc/q '[:find ?e
|
||||||
|
:where [?e :sales-order/external-id _]]
|
||||||
|
(dc/db conn))
|
||||||
|
(map first)
|
||||||
|
vec))
|
||||||
|
|
||||||
|
(def ^:private sales-order-read
|
||||||
|
'[:sales-order/external-id
|
||||||
|
:sales-order/date
|
||||||
|
{:sales-order/client [:client/code :client/name]}
|
||||||
|
:sales-order/location
|
||||||
|
{:sales-order/vendor [:vendor/name]}
|
||||||
|
:sales-order/total
|
||||||
|
:sales-order/tax
|
||||||
|
:sales-order/tip
|
||||||
|
:sales-order/discount
|
||||||
|
:sales-order/service-charge
|
||||||
|
:sales-order/source
|
||||||
|
:sales-order/reference-link
|
||||||
|
{:sales-order/charges
|
||||||
|
[:charge/external-id
|
||||||
|
:charge/type-name
|
||||||
|
:charge/total
|
||||||
|
:charge/tax
|
||||||
|
:charge/tip
|
||||||
|
:charge/date
|
||||||
|
{:charge/processor [:db/ident]}
|
||||||
|
:charge/returns
|
||||||
|
{:charge/client [:client/code]}]}
|
||||||
|
{:sales-order/line-items
|
||||||
|
[:order-line-item/item-name
|
||||||
|
:order-line-item/category
|
||||||
|
:order-line-item/total
|
||||||
|
:order-line-item/tax
|
||||||
|
:order-line-item/discount
|
||||||
|
:order-line-item/unit-price
|
||||||
|
:order-line-item/quantity
|
||||||
|
:order-line-item/note]}])
|
||||||
|
|
||||||
|
(defn- pull-sales-order-data [eids]
|
||||||
|
"Batch pull full sales-order entities plus nested children."
|
||||||
|
(if (empty? eids)
|
||||||
|
[]
|
||||||
|
(dc/pull-many (dc/db conn)
|
||||||
|
sales-order-read
|
||||||
|
eids)))
|
||||||
|
|
||||||
|
(defn- flatten-order-to-pieces! [order date-str flat]
|
||||||
|
"Flatten a pulled sales-order into :entity-type tagged maps.
|
||||||
|
Appends to the existing flat vector, which is returned."
|
||||||
|
(let [so-ext-id (:sales-order/external-id order)
|
||||||
|
so-date date-str
|
||||||
|
client-code (get-in order [:sales-order/client :client/code])
|
||||||
|
vendor-name (get-in order [:sales-order/vendor :vendor/name])
|
||||||
|
charges (:sales-order/charges order)
|
||||||
|
items (:sales-order/line-items order)
|
||||||
|
payment-methods (->> charges (map :charge/type-name) distinct (str/join ","))
|
||||||
|
processors (->> charges (map #(get-in % [:charge/processor :db/ident])) (remove nil?) distinct (map name) (str/join ","))
|
||||||
|
categories (->> items (map :order-line-item/category) (remove nil?) distinct (str/join ","))]
|
||||||
|
(vswap! flat conj
|
||||||
|
{:entity-type "sales-order"
|
||||||
|
:external-id (str so-ext-id)
|
||||||
|
:client-code client-code
|
||||||
|
:location (:sales-order/location order)
|
||||||
|
:vendor vendor-name
|
||||||
|
:total (:sales-order/total order)
|
||||||
|
:tax (:sales-order/tax order)
|
||||||
|
:tip (:sales-order/tip order)
|
||||||
|
:discount (:sales-order/discount order)
|
||||||
|
:service-charge (:sales-order/service-charge order)
|
||||||
|
:date so-date
|
||||||
|
:source (:sales-order/source order)
|
||||||
|
:reference-link (:sales-order/reference-link order)
|
||||||
|
:payment-methods payment-methods
|
||||||
|
:processors processors
|
||||||
|
:categories categories})
|
||||||
|
(when-let [charges (:sales-order/charges order)]
|
||||||
|
(doseq [chg charges]
|
||||||
|
(vswap! flat conj
|
||||||
|
{:entity-type "charge"
|
||||||
|
:external-id (str (get chg :charge/external-id))
|
||||||
|
:type-name (get chg :charge/type-name)
|
||||||
|
:total (get chg :charge/total)
|
||||||
|
:tax (get chg :charge/tax)
|
||||||
|
:tip (get chg :charge/tip)
|
||||||
|
:date so-date
|
||||||
|
:processor (get-in chg [:charge/processor :db/ident])
|
||||||
|
:sales-order-external-id (str so-ext-id)})
|
||||||
|
(when-let [returns (:charge/returns chg)]
|
||||||
|
(doseq [rt returns]
|
||||||
|
(vswap! flat conj
|
||||||
|
{:entity-type "sales-refund"
|
||||||
|
:type-name (get rt :type-name)
|
||||||
|
:total (get rt :total)
|
||||||
|
:sales-order-external-id (str so-ext-id)})))))
|
||||||
|
(when-let [items (:sales-order/line-items order)]
|
||||||
|
(doseq [li items]
|
||||||
|
(vswap! flat conj
|
||||||
|
{:entity-type "line-item"
|
||||||
|
:item-name (get li :order-line-item/item-name)
|
||||||
|
:category (get li :order-line-item/category)
|
||||||
|
:total (get li :order-line-item/total)
|
||||||
|
:tax (get li :order-line-item/tax)
|
||||||
|
:discount (get li :order-line-item/discount)
|
||||||
|
:sales-order-external-id (str so-ext-id)})))))
|
||||||
|
|
||||||
|
(defn -fetch-order-ids-for-date
|
||||||
|
"Query Datomic for all sales-order eids on a given business date."
|
||||||
|
[db date-str]
|
||||||
|
(let [ld (java.time.LocalDate/parse date-str)
|
||||||
|
start (-> ld (.atStartOfDay (java.time.ZoneId/of "America/Los_Angeles")) .toInstant java.util.Date/from)
|
||||||
|
end (-> ld (.plusDays 1) (.atStartOfDay (java.time.ZoneId/of "America/Los_Angeles")) .toInstant java.util.Date/from)]
|
||||||
|
(->> (dc/q '[:find ?e
|
||||||
|
:in $ ?start ?end
|
||||||
|
:where [?e :sales-order/date ?d]
|
||||||
|
[(>= ?d ?start)]
|
||||||
|
[(< ?d ?end)]]
|
||||||
|
db start end)
|
||||||
|
(map first)
|
||||||
|
vec)))
|
||||||
|
|
||||||
|
(defn write-day-by-day
|
||||||
|
([start-date end-date]
|
||||||
|
(write-day-by-day start-date end-date {}))
|
||||||
|
([start-date end-date opts]
|
||||||
|
(let [all-dates (set (or (opts :date-set) []))
|
||||||
|
date-range (if (empty? all-dates)
|
||||||
|
(p/date-seq start-date end-date)
|
||||||
|
(filter all-dates
|
||||||
|
(p/date-seq start-date end-date)))
|
||||||
|
batch-size (or (opts :batch-size) 100)]
|
||||||
|
(doseq [^String day date-range]
|
||||||
|
(println "[migration] processing" day)
|
||||||
|
(let [eids (-fetch-order-ids-for-date (dc/db conn) day)
|
||||||
|
batches (partition-all batch-size eids)]
|
||||||
|
(doseq [batch batches]
|
||||||
|
(let [orders (pull-sales-order-data batch)
|
||||||
|
flat (volatile! [])]
|
||||||
|
(doseq [o orders]
|
||||||
|
(flatten-order-to-pieces! o day flat))
|
||||||
|
(doseq [r @flat]
|
||||||
|
(p/buffer! (:entity-type r) r)))))
|
||||||
|
(doseq [etype ["sales-order" "charge"
|
||||||
|
"line-item" "sales-refund"]]
|
||||||
|
(p/flush-to-parquet! etype day))
|
||||||
|
(println "[migration]" day "complete"))
|
||||||
|
{:status :completed :total-days (count date-range)})))
|
||||||
|
|
||||||
|
(defn- write-dead-letter
|
||||||
|
([flat]
|
||||||
|
(write-dead-letter "dead" flat))
|
||||||
|
([prefix flat]
|
||||||
|
"Write records with missing dates to a parquet file."
|
||||||
|
(let [dead (filter #(nil? (:date %)) flat)]
|
||||||
|
(when (seq dead)
|
||||||
|
(doseq [r dead]
|
||||||
|
(p/buffer!
|
||||||
|
(str prefix "-" (:entity-type r))
|
||||||
|
r))))))
|
||||||
|
|
||||||
|
(defn- flush-all-types []
|
||||||
|
"Flush all entity-type buffers, tracking counts."
|
||||||
|
(let [etypes ["sales-order" "charge"
|
||||||
|
"line-item" "sales-refund"]
|
||||||
|
today (.toString (java.time.LocalDate/now))
|
||||||
|
start (p/total-buf-count)]
|
||||||
|
(doseq [et etypes]
|
||||||
|
(try
|
||||||
|
(p/flush-to-parquet! et today)
|
||||||
|
(catch Exception e
|
||||||
|
(println "[migration/flush]" et "error:" (.getMessage e)))))
|
||||||
|
{:records-flush (- (p/total-buf-count) start)}))
|
||||||
|
|
||||||
|
(defn- get-date-range []
|
||||||
|
"Get the earliest and latest business dates from Datomic."
|
||||||
|
(let [dates (->> (dc/q '[:find ?d
|
||||||
|
:where [_ :sales-order/date ?d]]
|
||||||
|
(dc/db conn))
|
||||||
|
(map first)
|
||||||
|
distinct
|
||||||
|
sort)]
|
||||||
|
[(when (seq dates) (.toString (first dates)))
|
||||||
|
(when (seq dates) (.toString (last dates)))]))
|
||||||
|
|
||||||
|
(defn migrate-all []
|
||||||
|
"Full migration from earliest to latest date: load unflushed,
|
||||||
|
fetch / buffer / flush day by day. Write dead-records for
|
||||||
|
sales orders with missing dates."
|
||||||
|
(println "[migration] starting full migration...")
|
||||||
|
(p/load-unflushed!)
|
||||||
|
(let [order-ids (fetch-all-sales-order-ids)
|
||||||
|
start-date (first (get-date-range))
|
||||||
|
end-date (second (get-date-range))]
|
||||||
|
(if-not (seq order-ids)
|
||||||
|
(do
|
||||||
|
(println "[migration] no orders found")
|
||||||
|
:no-orders)
|
||||||
|
(try
|
||||||
|
;; pull & buffer any orders missing a business date
|
||||||
|
(doseq [o (pull-sales-order-data order-ids)
|
||||||
|
:when (not (:sales-order/date o))]
|
||||||
|
(let [flat (volatile! [])]
|
||||||
|
(flatten-order-to-pieces! o "unknown" flat)
|
||||||
|
(doseq [r @flat]
|
||||||
|
(p/buffer! "dead" r))))
|
||||||
|
(write-day-by-day start-date end-date {:batch-size 100})
|
||||||
|
(flush-all-types)
|
||||||
|
(println "[migration] done")
|
||||||
|
:ok
|
||||||
|
(catch Exception e
|
||||||
|
(println "[migration/error]" (.getMessage e))
|
||||||
|
e)))))
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
[clojure.string :as str]
|
[clojure.string :as str]
|
||||||
[auto-ap.time :as atime]))
|
[auto-ap.time :as atime]))
|
||||||
|
|
||||||
|
|
||||||
(def pdf-templates
|
(def pdf-templates
|
||||||
[;; CHEF's WAREHOUSE
|
[;; CHEF's WAREHOUSE
|
||||||
{:vendor "CHFW"
|
{:vendor "CHFW"
|
||||||
@@ -45,8 +44,7 @@
|
|||||||
:parser {:date [:clj-time "MM/dd/yy"]}
|
:parser {:date [:clj-time "MM/dd/yy"]}
|
||||||
:multi #"\f\f"}
|
:multi #"\f\f"}
|
||||||
|
|
||||||
|
;; IMPACT PAPER
|
||||||
;; IMPACT PAPER
|
|
||||||
{:vendor "Impact Paper & Ink LTD"
|
{:vendor "Impact Paper & Ink LTD"
|
||||||
:keywords [#"650-692-5598"]
|
:keywords [#"650-692-5598"]
|
||||||
:extract {:total #"Total Amount\s+\$([\d\.\,\-]+)"
|
:extract {:total #"Total Amount\s+\$([\d\.\,\-]+)"
|
||||||
@@ -369,8 +367,7 @@
|
|||||||
:parser {:date [:clj-time "MM/dd/yyyy"]
|
:parser {:date [:clj-time "MM/dd/yyyy"]
|
||||||
:total [:trim-commas nil]}}
|
:total [:trim-commas nil]}}
|
||||||
|
|
||||||
|
;; Breakthru Bev
|
||||||
;; Breakthru Bev
|
|
||||||
{:vendor "Wine Warehouse"
|
{:vendor "Wine Warehouse"
|
||||||
:keywords [#"BREAKTHRU BEVERAGE"]
|
:keywords [#"BREAKTHRU BEVERAGE"]
|
||||||
:extract {:date #"Invoice Date:\s+([0-9]+/[0-9]+/[0-9]+)"
|
:extract {:date #"Invoice Date:\s+([0-9]+/[0-9]+/[0-9]+)"
|
||||||
@@ -686,13 +683,13 @@
|
|||||||
|
|
||||||
;; TODO DISABLING TO FOCUS ON STATEMENT
|
;; TODO DISABLING TO FOCUS ON STATEMENT
|
||||||
#_{:vendor "Reel Produce"
|
#_{:vendor "Reel Produce"
|
||||||
:keywords [#"reelproduce.com"]
|
:keywords [#"reelproduce.com"]
|
||||||
:extract {:date #"([0-9]+/[0-9]+/[0-9]+)"
|
:extract {:date #"([0-9]+/[0-9]+/[0-9]+)"
|
||||||
:customer-identifier #"Bill To(?:.*?)\n\n\s+(.*?)\s{2,}"
|
:customer-identifier #"Bill To(?:.*?)\n\n\s+(.*?)\s{2,}"
|
||||||
:invoice-number #"Invoice #\n.*?\n.*?([\d\-]+)\n"
|
:invoice-number #"Invoice #\n.*?\n.*?([\d\-]+)\n"
|
||||||
:total #"Total\s*\n\s+\$([\d\-,]+\.\d{2,2}+)"}
|
:total #"Total\s*\n\s+\$([\d\-,]+\.\d{2,2}+)"}
|
||||||
:parser {:date [:clj-time "MM/dd/yy"]
|
:parser {:date [:clj-time "MM/dd/yy"]
|
||||||
:total [:trim-commas-and-negate nil]}}
|
:total [:trim-commas-and-negate nil]}}
|
||||||
|
|
||||||
{:vendor "Eddie's Produce"
|
{:vendor "Eddie's Produce"
|
||||||
:keywords [#"Eddie's Produce"]
|
:keywords [#"Eddie's Produce"]
|
||||||
@@ -754,7 +751,30 @@
|
|||||||
:parser {:date [:clj-time "MM/dd/yyyy"]
|
:parser {:date [:clj-time "MM/dd/yyyy"]
|
||||||
:total [:trim-commas-and-negate nil]}
|
:total [:trim-commas-and-negate nil]}
|
||||||
:multi #"\n"
|
:multi #"\n"
|
||||||
:multi-match? #"INV #"}])
|
:multi-match? #"INV #"}
|
||||||
|
|
||||||
|
;; Bonanza Produce
|
||||||
|
{:vendor "Bonanza Produce"
|
||||||
|
:keywords [#"530-544-4136"]
|
||||||
|
:extract {:invoice-number #"NO\s+(\d{8,})\s+\d{2}/\d{2}/\d{2}"
|
||||||
|
:date #"NO\s+\d{8,}\s+(\d{2}/\d{2}/\d{2})"
|
||||||
|
:customer-identifier #"(?s)I\s+([A-Z][A-Z\s]+?)\s{2,}.*?L\s+([0-9][A-Z0-9\s]+?)(?=\s{2,}|\n)"
|
||||||
|
:account-number #"(?s)L\s+([0-9][A-Z0-9\s]+?)(?=\s{2,}|\n)"
|
||||||
|
:total #"SHIPPED\s+[\d\.]+\s+TOTAL\s+([\d\.]+)"}
|
||||||
|
:parser {:date [:clj-time "MM/dd/yy"]
|
||||||
|
:total [:trim-commas nil]}}
|
||||||
|
|
||||||
|
;; Bonanza Produce Statement (multi-invoice)
|
||||||
|
{:vendor "Bonanza Produce"
|
||||||
|
:keywords [#"The perishable agricultural commodities" #"SPARKS, NEVADA"]
|
||||||
|
:extract {:invoice-number #"^\s+[0-9]{2}/[0-9]{2}/[0-9]{2}\s+([0-9]+)\s+INVOICE"
|
||||||
|
:customer-identifier #"(.*?)\s+RETURN"
|
||||||
|
:date #"^\s+([0-9]{2}/[0-9]{2}/[0-9]{2})"
|
||||||
|
:total #"^\s+[0-9]{2}/[0-9]{2}/[0-9]{2}\s+[0-9]+\s+INVOICE\s+([\d.]+)"}
|
||||||
|
:parser {:date [:clj-time "MM/dd/yy"]
|
||||||
|
:total [:trim-commas nil]}
|
||||||
|
:multi #"\n"
|
||||||
|
:multi-match? #"\s+[0-9]{2}/[0-9]{2}/[0-9]{2}\s+[0-9]+\s+INVOICE"}])
|
||||||
|
|
||||||
(def excel-templates
|
(def excel-templates
|
||||||
[{:vendor "Mama Lu's Foods"
|
[{:vendor "Mama Lu's Foods"
|
||||||
@@ -784,43 +804,41 @@
|
|||||||
{:vendor "Daylight Foods"
|
{:vendor "Daylight Foods"
|
||||||
:keywords [#"CUSTNO"]
|
:keywords [#"CUSTNO"]
|
||||||
:extract (fn [sheet vendor]
|
:extract (fn [sheet vendor]
|
||||||
(alog/peek ::daylight-invoices
|
(alog/peek ::daylight-invoices
|
||||||
(transduce (comp
|
(transduce (comp
|
||||||
(drop 1)
|
(drop 1)
|
||||||
(filter
|
(filter
|
||||||
(fn [r]
|
(fn [r]
|
||||||
(and
|
(and
|
||||||
(seq r)
|
(seq r)
|
||||||
(->> r first not-empty))))
|
(->> r first not-empty))))
|
||||||
(map
|
(map
|
||||||
(fn [[customer-number _ _ _ invoice-number date amount :as row]]
|
(fn [[customer-number _ _ _ invoice-number date amount :as row]]
|
||||||
(println "DAT E is" date)
|
(println "DAT E is" date)
|
||||||
{:customer-identifier customer-number
|
{:customer-identifier customer-number
|
||||||
:text (str/join " " row)
|
:text (str/join " " row)
|
||||||
:full-text (str/join " " row)
|
:full-text (str/join " " row)
|
||||||
:date (try (or (u/parse-value :clj-time "MM/dd/yyyy" (str/trim date))
|
:date (try (or (u/parse-value :clj-time "MM/dd/yyyy" (str/trim date))
|
||||||
(try
|
(try
|
||||||
(atime/as-local-time
|
(atime/as-local-time
|
||||||
(time/plus (time/date-time 1900 1 1)
|
(time/plus (time/date-time 1900 1 1)
|
||||||
(time/days (dec (dec (Integer/parseInt "45663"))))))
|
(time/days (dec (dec (Integer/parseInt "45663"))))))
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
nil)
|
nil)))
|
||||||
))
|
|
||||||
|
(catch Exception e
|
||||||
(catch Exception e
|
(try
|
||||||
(try
|
(atime/as-local-time
|
||||||
(atime/as-local-time
|
(time/plus (time/date-time 1900 1 1)
|
||||||
(time/plus (time/date-time 1900 1 1)
|
(time/days (dec (dec (Integer/parseInt "45663"))))))
|
||||||
(time/days (dec (dec (Integer/parseInt "45663"))))))
|
(catch Exception e
|
||||||
(catch Exception e
|
nil))))
|
||||||
nil)
|
|
||||||
)
|
:invoice-number invoice-number
|
||||||
))
|
:total (str amount)
|
||||||
:invoice-number invoice-number
|
:vendor-code vendor})))
|
||||||
:total (str amount)
|
conj
|
||||||
:vendor-code vendor})))
|
[]
|
||||||
conj
|
sheet)))}])
|
||||||
[]
|
|
||||||
sheet)))}])
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
(ns auto-ap.routes.ezcater-xls
|
(ns auto-ap.routes.ezcater-xls
|
||||||
(:require
|
(:require
|
||||||
[auto-ap.datomic :refer [audit-transact conn]]
|
[auto-ap.datomic :refer [conn]]
|
||||||
[auto-ap.logging :as alog]
|
[auto-ap.logging :as alog]
|
||||||
[clojure.data.json :as json]
|
[clojure.data.json :as json]
|
||||||
[auto-ap.parse.excel :as excel]
|
[auto-ap.parse.excel :as excel]
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
[auto-ap.ssr.ui :refer [base-page]]
|
[auto-ap.ssr.ui :refer [base-page]]
|
||||||
[auto-ap.ssr.utils :refer [html-response]]
|
[auto-ap.ssr.utils :refer [html-response]]
|
||||||
[auto-ap.time :as atime]
|
[auto-ap.time :as atime]
|
||||||
|
[auto-ap.storage.parquet :as parquet]
|
||||||
[bidi.bidi :as bidi]
|
[bidi.bidi :as bidi]
|
||||||
[clj-time.coerce :as coerce]
|
[clj-time.coerce :as coerce]
|
||||||
[clojure.java.io :as io]
|
[clojure.java.io :as io]
|
||||||
@@ -54,54 +55,95 @@
|
|||||||
event-date (some-> (excel/xls-date->date event-date)
|
event-date (some-> (excel/xls-date->date event-date)
|
||||||
coerce/to-date-time
|
coerce/to-date-time
|
||||||
atime/as-local-time
|
atime/as-local-time
|
||||||
coerce/to-date )]
|
coerce/to-date)]
|
||||||
(cond (and event-date client-id location )
|
(cond (and event-date client-id location)
|
||||||
[:order #:sales-order
|
[:order #:sales-order
|
||||||
{:date event-date
|
{:date event-date
|
||||||
:external-id (str "ezcater/order/" client-id "-" location "-" order-number)
|
:external-id (str "ezcater/order/" client-id "-" location "-" order-number)
|
||||||
:client client-id
|
:client client-id
|
||||||
:location location
|
:location location
|
||||||
:reference-link (str order-number)
|
:reference-link (str order-number)
|
||||||
:line-items [#:order-line-item
|
:line-items [#:order-line-item
|
||||||
{:external-id (str "ezcater/order/" client-id "-" location "-" order-number "-" 0)
|
{:external-id (str "ezcater/order/" client-id "-" location "-" order-number "-" 0)
|
||||||
:item-name "EZCater Catering"
|
:item-name "EZCater Catering"
|
||||||
:category "EZCater Catering"
|
:category "EZCater Catering"
|
||||||
:discount (fmt-amount (or adjustments 0.0))
|
:discount (fmt-amount (or adjustments 0.0))
|
||||||
:tax (fmt-amount tax)
|
:tax (fmt-amount tax)
|
||||||
:total (fmt-amount (+ food-total
|
:total (fmt-amount (+ food-total
|
||||||
tax))}]
|
tax))}]
|
||||||
|
|
||||||
:charges [#:charge
|
:charges [#:charge
|
||||||
{:type-name "CARD"
|
{:type-name "CARD"
|
||||||
:date event-date
|
:date event-date
|
||||||
:client client-id
|
:client client-id
|
||||||
:location location
|
:location location
|
||||||
:external-id (str "ezcater/charge/" client-id "-" location "-" order-number "-" 0)
|
:external-id (str "ezcater/charge/" client-id "-" location "-" order-number "-" 0)
|
||||||
:processor :ccp-processor/ezcater
|
:processor :ccp-processor/ezcater
|
||||||
:total (fmt-amount (+ food-total
|
:total (fmt-amount (+ food-total
|
||||||
tax
|
tax
|
||||||
tip))
|
tip))
|
||||||
:tip (fmt-amount tip)}]
|
:tip (fmt-amount tip)}]
|
||||||
:total (fmt-amount (+ food-total
|
:total (fmt-amount (+ food-total
|
||||||
tax
|
tax
|
||||||
(or adjustments 0.0)))
|
(or adjustments 0.0)))
|
||||||
:discount (fmt-amount (or adjustments 0.0))
|
:discount (fmt-amount (or adjustments 0.0))
|
||||||
:service-charge (fmt-amount (+ fee commission))
|
:service-charge (fmt-amount (+ fee commission))
|
||||||
:tax (fmt-amount tax)
|
:tax (fmt-amount tax)
|
||||||
:tip (fmt-amount tip)
|
:tip (fmt-amount tip)
|
||||||
:returns 0.0
|
:returns 0.0
|
||||||
:vendor :vendor/ccp-ezcater}]
|
:vendor :vendor/ccp-ezcater}]
|
||||||
|
|
||||||
caterer-name
|
caterer-name
|
||||||
(do
|
(do
|
||||||
(alog/warn ::missing-client
|
(alog/warn ::missing-client
|
||||||
:order order-number
|
:order order-number
|
||||||
:store-name store-name
|
:store-name store-name
|
||||||
:caterer-name caterer-name)
|
:caterer-name caterer-name)
|
||||||
[:missing caterer-name])
|
[:missing caterer-name])
|
||||||
|
|
||||||
:else
|
:else
|
||||||
nil)))
|
nil)))
|
||||||
|
|
||||||
|
(defn- flatten-order-to-parquet! [order]
|
||||||
|
"Flatten a sales-order into entity-type tagged maps and buffer to parquet."
|
||||||
|
(let [so-ext-id (:sales-order/external-id order)
|
||||||
|
so-date (some-> (:sales-order/date order) .toString)
|
||||||
|
client (:sales-order/client order)
|
||||||
|
client-code (if (map? client) (:client/code client) client)]
|
||||||
|
(parquet/buffer! "sales-order"
|
||||||
|
{:entity-type "sales-order"
|
||||||
|
:external-id so-ext-id
|
||||||
|
:client-code client-code
|
||||||
|
:location (:sales-order/location order)
|
||||||
|
:vendor (:sales-order/vendor order)
|
||||||
|
:total (:sales-order/total order)
|
||||||
|
:tax (:sales-order/tax order)
|
||||||
|
:tip (:sales-order/tip order)
|
||||||
|
:discount (:sales-order/discount order)
|
||||||
|
:service-charge (:sales-order/service-charge order)
|
||||||
|
:date so-date})
|
||||||
|
(when-let [charges (:sales-order/charges order)]
|
||||||
|
(doseq [chg charges]
|
||||||
|
(parquet/buffer! "charge"
|
||||||
|
{:entity-type "charge"
|
||||||
|
:external-id (:charge/external-id chg)
|
||||||
|
:type-name (:charge/type-name chg)
|
||||||
|
:total (:charge/total chg)
|
||||||
|
:tax (:charge/tax chg)
|
||||||
|
:tip (:charge/tip chg)
|
||||||
|
:date so-date
|
||||||
|
:processor (some-> (:charge/processor chg) name)
|
||||||
|
:sales-order-external-id so-ext-id})))
|
||||||
|
(when-let [items (:sales-order/line-items order)]
|
||||||
|
(doseq [li items]
|
||||||
|
(parquet/buffer! "line-item"
|
||||||
|
{:entity-type "line-item"
|
||||||
|
:item-name (:order-line-item/item-name li)
|
||||||
|
:category (:order-line-item/category li)
|
||||||
|
:total (:order-line-item/total li)
|
||||||
|
:tax (:order-line-item/tax li)
|
||||||
|
:discount (:order-line-item/discount li)
|
||||||
|
:sales-order-external-id so-ext-id})))))
|
||||||
|
|
||||||
(defn stream->sales-orders [s]
|
(defn stream->sales-orders [s]
|
||||||
(let [clients (map first (dc/q '[:find (pull ?c [:client/code
|
(let [clients (map first (dc/q '[:find (pull ?c [:client/code
|
||||||
@@ -116,7 +158,7 @@
|
|||||||
object (str "/ezcater-xls/" (str (java.util.UUID/randomUUID)))]
|
object (str "/ezcater-xls/" (str (java.util.UUID/randomUUID)))]
|
||||||
(mu/log ::writing-temp-xls
|
(mu/log ::writing-temp-xls
|
||||||
:location object)
|
:location object)
|
||||||
(s3/put-object {:bucket-name (:data-bucket env)
|
(s3/put-object {:bucket-name (:data-bucket env)
|
||||||
:key object
|
:key object
|
||||||
:input-stream s})
|
:input-stream s})
|
||||||
(into []
|
(into []
|
||||||
@@ -158,13 +200,13 @@
|
|||||||
});")]])])
|
});")]])])
|
||||||
|
|
||||||
(defn upload-xls [{:keys [identity] :as request}]
|
(defn upload-xls [{:keys [identity] :as request}]
|
||||||
|
|
||||||
(let [file (or (get (:params request) :file)
|
(let [file (or (get (:params request) :file)
|
||||||
(get (:params request) "file"))]
|
(get (:params request) "file"))]
|
||||||
(mu/log ::uploading-file
|
(mu/log ::uploading-file
|
||||||
:file file)
|
:file file)
|
||||||
(with-open [s (io/input-stream (:tempfile file))]
|
(with-open [s (io/input-stream (:tempfile file))]
|
||||||
(try
|
(try
|
||||||
(let [parse-results (stream->sales-orders s)
|
(let [parse-results (stream->sales-orders s)
|
||||||
new-orders (->> parse-results
|
new-orders (->> parse-results
|
||||||
(filter (comp #{:order} first))
|
(filter (comp #{:order} first))
|
||||||
@@ -172,9 +214,20 @@
|
|||||||
|
|
||||||
missing-location (->> parse-results
|
missing-location (->> parse-results
|
||||||
(filter (comp #{:missing} first))
|
(filter (comp #{:missing} first))
|
||||||
(map last))]
|
(map last))
|
||||||
(audit-transact new-orders identity)
|
buffered-count (loop [orders new-orders
|
||||||
(html-response [:div (format "Successfully imported %d orders." (count new-orders))
|
count 0]
|
||||||
|
(if-let [o (first orders)]
|
||||||
|
(do
|
||||||
|
(try
|
||||||
|
(flatten-order-to-parquet! o)
|
||||||
|
(catch Exception e
|
||||||
|
(alog/error ::buffer-failed
|
||||||
|
:exception e
|
||||||
|
:order (:sales-order/external-id o))))
|
||||||
|
(recur (rest orders) (inc count)))
|
||||||
|
count))]
|
||||||
|
(html-response [:div (format "Successfully imported %d orders." buffered-count)
|
||||||
(when (seq missing-location)
|
(when (seq missing-location)
|
||||||
[:div "Missing the following locations"
|
[:div "Missing the following locations"
|
||||||
[:ul.ul
|
[:ul.ul
|
||||||
@@ -182,7 +235,7 @@
|
|||||||
[:li ml])]])]))
|
[:li ml])]])]))
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
(alog/error ::import-error
|
(alog/error ::import-error
|
||||||
:error e)
|
:error e)
|
||||||
(html-response [:div (.getMessage e)]))))))
|
(html-response [:div (.getMessage e)]))))))
|
||||||
|
|
||||||
(defn page [{:keys [matched-route request-method] :as request}]
|
(defn page [{:keys [matched-route request-method] :as request}]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
[auto-ap.datomic :refer [conn remove-nils]]
|
[auto-ap.datomic :refer [conn remove-nils]]
|
||||||
[auto-ap.logging :as log :refer [capture-context->lc with-context-as]]
|
[auto-ap.logging :as log :refer [capture-context->lc with-context-as]]
|
||||||
[auto-ap.time :as atime]
|
[auto-ap.time :as atime]
|
||||||
|
[auto-ap.storage.parquet :as parquet]
|
||||||
[cemerick.url :as url]
|
[cemerick.url :as url]
|
||||||
[clj-http.client :as client]
|
[clj-http.client :as client]
|
||||||
[clj-time.coerce :as coerce]
|
[clj-time.coerce :as coerce]
|
||||||
@@ -27,11 +28,9 @@
|
|||||||
"Authorization" (str "Bearer " (:client/square-auth-token client))
|
"Authorization" (str "Bearer " (:client/square-auth-token client))
|
||||||
"Content-Type" "application/json"}))
|
"Content-Type" "application/json"}))
|
||||||
|
|
||||||
|
|
||||||
(defn ->square-date [d]
|
(defn ->square-date [d]
|
||||||
(f/unparse (f/formatter "YYYY-MM-dd'T'HH:mm:ssZZ") d))
|
(f/unparse (f/formatter "YYYY-MM-dd'T'HH:mm:ssZZ") d))
|
||||||
|
|
||||||
|
|
||||||
(def manifold-api-stream
|
(def manifold-api-stream
|
||||||
(let [stream (s/stream 100)]
|
(let [stream (s/stream 100)]
|
||||||
(->> stream
|
(->> stream
|
||||||
@@ -42,10 +41,10 @@
|
|||||||
(de/loop [attempt 0]
|
(de/loop [attempt 0]
|
||||||
(-> (de/chain (de/future-with (ex/execute-pool)
|
(-> (de/chain (de/future-with (ex/execute-pool)
|
||||||
#_(log/info ::request-started
|
#_(log/info ::request-started
|
||||||
:url (:url request)
|
:url (:url request)
|
||||||
:attempt attempt
|
:attempt attempt
|
||||||
:source "Square 3"
|
:source "Square 3"
|
||||||
:background-job "Square 3")
|
:background-job "Square 3")
|
||||||
(try
|
(try
|
||||||
(client/request (assoc request
|
(client/request (assoc request
|
||||||
:socket-timeout 10000
|
:socket-timeout 10000
|
||||||
@@ -104,7 +103,6 @@
|
|||||||
:exception error))
|
:exception error))
|
||||||
[]))))
|
[]))))
|
||||||
|
|
||||||
|
|
||||||
(def item-cache (atom {}))
|
(def item-cache (atom {}))
|
||||||
|
|
||||||
(defn fetch-catalog [client i v]
|
(defn fetch-catalog [client i v]
|
||||||
@@ -124,13 +122,11 @@
|
|||||||
#(do (swap! item-cache assoc i %)
|
#(do (swap! item-cache assoc i %)
|
||||||
%))))
|
%))))
|
||||||
|
|
||||||
|
|
||||||
(defn fetch-catalog-cache [client i version]
|
(defn fetch-catalog-cache [client i version]
|
||||||
(if (get @item-cache i)
|
(if (get @item-cache i)
|
||||||
(de/success-deferred (get @item-cache i))
|
(de/success-deferred (get @item-cache i))
|
||||||
(fetch-catalog client i version)))
|
(fetch-catalog client i version)))
|
||||||
|
|
||||||
|
|
||||||
(defn item->category-name-impl [client item version]
|
(defn item->category-name-impl [client item version]
|
||||||
(capture-context->lc
|
(capture-context->lc
|
||||||
(cond (:item_id (:item_variation_data item))
|
(cond (:item_id (:item_variation_data item))
|
||||||
@@ -161,7 +157,6 @@
|
|||||||
:item item)
|
:item item)
|
||||||
"Uncategorized"))))
|
"Uncategorized"))))
|
||||||
|
|
||||||
|
|
||||||
(defn item-id->category-name [client i version]
|
(defn item-id->category-name [client i version]
|
||||||
(capture-context->lc
|
(capture-context->lc
|
||||||
(-> [client i]
|
(-> [client i]
|
||||||
@@ -226,7 +221,6 @@
|
|||||||
(concat (:orders result) continued-results))))
|
(concat (:orders result) continued-results))))
|
||||||
(:orders result)))))))
|
(:orders result)))))))
|
||||||
|
|
||||||
|
|
||||||
(defn search
|
(defn search
|
||||||
([client location start end]
|
([client location start end]
|
||||||
(capture-context->lc
|
(capture-context->lc
|
||||||
@@ -250,11 +244,9 @@
|
|||||||
(concat (:orders result) continued-results))))
|
(concat (:orders result) continued-results))))
|
||||||
(:orders result))))))))
|
(:orders result))))))))
|
||||||
|
|
||||||
|
|
||||||
(defn amount->money [amt]
|
(defn amount->money [amt]
|
||||||
(* 0.01 (or (:amount amt) 0.0)))
|
(* 0.01 (or (:amount amt) 0.0)))
|
||||||
|
|
||||||
|
|
||||||
;; to get totals:
|
;; to get totals:
|
||||||
(comment
|
(comment
|
||||||
(reduce
|
(reduce
|
||||||
@@ -280,7 +272,7 @@
|
|||||||
:reference-link (str (url/url "https://squareup.com/receipt/preview" (:id t)))
|
:reference-link (str (url/url "https://squareup.com/receipt/preview" (:id t)))
|
||||||
:external-id (when (:id t)
|
:external-id (when (:id t)
|
||||||
(str "square/charge/" (:id t)))
|
(str "square/charge/" (:id t)))
|
||||||
:processor (cond
|
:processor (cond
|
||||||
(#{"OTHER" "THIRD_PARTY_CARD"} (:type t))
|
(#{"OTHER" "THIRD_PARTY_CARD"} (:type t))
|
||||||
(condp = (some-> (:note t) str/lower-case)
|
(condp = (some-> (:note t) str/lower-case)
|
||||||
"doordash" :ccp-processor/doordash
|
"doordash" :ccp-processor/doordash
|
||||||
@@ -293,7 +285,9 @@
|
|||||||
(condp = (:name (:source order))
|
(condp = (:name (:source order))
|
||||||
"GRUBHUB" :ccp-processor/grubhub
|
"GRUBHUB" :ccp-processor/grubhub
|
||||||
"UBEREATS" :ccp-processor/uber-eats
|
"UBEREATS" :ccp-processor/uber-eats
|
||||||
|
"Uber Eats" :ccp-processor/uber-eats
|
||||||
"DOORDASH" :ccp-processor/doordash
|
"DOORDASH" :ccp-processor/doordash
|
||||||
|
"DoorDash" :ccp-processor/doordash
|
||||||
"Koala" :ccp-processor/koala
|
"Koala" :ccp-processor/koala
|
||||||
"koala-production" :ccp-processor/koala
|
"koala-production" :ccp-processor/koala
|
||||||
:ccp-processor/na))
|
:ccp-processor/na))
|
||||||
@@ -349,7 +343,10 @@
|
|||||||
(s/reduce conj []))]
|
(s/reduce conj []))]
|
||||||
[(remove-nils
|
[(remove-nils
|
||||||
#:sales-order
|
#:sales-order
|
||||||
{:date (coerce/to-date (time/to-time-zone (coerce/to-date-time (:created_at order)) (time/time-zone-for-id "America/Los_Angeles")))
|
{:date (if (= "Invoices" (:name (:source order)))
|
||||||
|
(when (:closed_at order)
|
||||||
|
(coerce/to-date (time/to-time-zone (coerce/to-date-time (:closed_at order)) (time/time-zone-for-id "America/Los_Angeles"))))
|
||||||
|
(coerce/to-date (time/to-time-zone (coerce/to-date-time (:created_at order)) (time/time-zone-for-id "America/Los_Angeles"))))
|
||||||
:client (:db/id client)
|
:client (:db/id client)
|
||||||
:location (:square-location/client-location location)
|
:location (:square-location/client-location location)
|
||||||
:external-id (str "square/order/" (:client/code client) "-" (:square-location/client-location location) "-" (:id order))
|
:external-id (str "square/order/" (:client/code client) "-" (:square-location/client-location location) "-" (:id order))
|
||||||
@@ -379,6 +376,9 @@
|
|||||||
;; sometimes orders stay open in square. At least one payment
|
;; sometimes orders stay open in square. At least one payment
|
||||||
;; is needed to import, in order to avoid importing orders in-progress.
|
;; is needed to import, in order to avoid importing orders in-progress.
|
||||||
(and
|
(and
|
||||||
|
(if (= "Invoices" (:name (:source order)))
|
||||||
|
(boolean (:closed_at order))
|
||||||
|
true)
|
||||||
(or (> (count (:tenders order)) 0)
|
(or (> (count (:tenders order)) 0)
|
||||||
(seq (:returns order)))
|
(seq (:returns order)))
|
||||||
(or (= #{} (set (map #(:status (:card_details %)) (:tenders order))))
|
(or (= #{} (set (map #(:status (:card_details %)) (:tenders order))))
|
||||||
@@ -407,7 +407,6 @@
|
|||||||
:client client
|
:client client
|
||||||
:location location)))))))
|
:location location)))))))
|
||||||
|
|
||||||
|
|
||||||
(defn get-payment [client p]
|
(defn get-payment [client p]
|
||||||
(de/chain (manifold-api-call
|
(de/chain (manifold-api-call
|
||||||
{:url (str "https://connect.squareup.com/v2/payments/" p)
|
{:url (str "https://connect.squareup.com/v2/payments/" p)
|
||||||
@@ -416,7 +415,6 @@
|
|||||||
:body
|
:body
|
||||||
:payment))
|
:payment))
|
||||||
|
|
||||||
|
|
||||||
(defn continue-payout-entry-list [c l poi cursor]
|
(defn continue-payout-entry-list [c l poi cursor]
|
||||||
(capture-context->lc lc
|
(capture-context->lc lc
|
||||||
(de/chain
|
(de/chain
|
||||||
@@ -594,6 +592,57 @@
|
|||||||
(s/buffer 5)
|
(s/buffer 5)
|
||||||
(s/realize-each)
|
(s/realize-each)
|
||||||
(s/reduce conj []))))))
|
(s/reduce conj []))))))
|
||||||
|
(defn- flatten-order-to-parquet! [order]
|
||||||
|
"Flatten a sales-order into entity-type tagged maps and buffer to parquet.
|
||||||
|
Returns the sales-order external-id for logging."
|
||||||
|
(let [so-ext-id (:sales-order/external-id order)
|
||||||
|
so-date (some-> (:sales-order/date order) .toString)
|
||||||
|
client (:sales-order/client order)
|
||||||
|
client-code (when client (if (map? client)
|
||||||
|
(:client/code client)
|
||||||
|
client))]
|
||||||
|
(parquet/buffer! "sales-order"
|
||||||
|
{:entity-type "sales-order"
|
||||||
|
:external-id so-ext-id
|
||||||
|
:client-code (or client-code (:db/id client))
|
||||||
|
:location (:sales-order/location order)
|
||||||
|
:vendor (:sales-order/vendor order)
|
||||||
|
:total (:sales-order/total order)
|
||||||
|
:tax (:sales-order/tax order)
|
||||||
|
:tip (:sales-order/tip order)
|
||||||
|
:discount (:sales-order/discount order)
|
||||||
|
:service-charge (:sales-order/service-charge order)
|
||||||
|
:date so-date})
|
||||||
|
(when-let [charges (:sales-order/charges order)]
|
||||||
|
(doseq [chg charges]
|
||||||
|
(parquet/buffer! "charge"
|
||||||
|
{:entity-type "charge"
|
||||||
|
:external-id (:charge/external-id chg)
|
||||||
|
:type-name (:charge/type-name chg)
|
||||||
|
:total (:charge/total chg)
|
||||||
|
:tax (:charge/tax chg)
|
||||||
|
:tip (:charge/tip chg)
|
||||||
|
:date so-date
|
||||||
|
:processor (some-> (:charge/processor chg) name)
|
||||||
|
:sales-order-external-id so-ext-id})
|
||||||
|
(when-let [returns (:charge/returns chg)]
|
||||||
|
(doseq [rt returns]
|
||||||
|
(parquet/buffer! "sales-refund"
|
||||||
|
{:entity-type "sales-refund"
|
||||||
|
:type-name (:type-name rt)
|
||||||
|
:total (:total rt)
|
||||||
|
:sales-order-external-id so-ext-id})))))
|
||||||
|
(when-let [items (:sales-order/line-items order)]
|
||||||
|
(doseq [li items]
|
||||||
|
(parquet/buffer! "line-item"
|
||||||
|
{:entity-type "line-item"
|
||||||
|
:item-name (:order-line-item/item-name li)
|
||||||
|
:category (:order-line-item/category li)
|
||||||
|
:total (:order-line-item/total li)
|
||||||
|
:tax (:order-line-item/tax li)
|
||||||
|
:discount (:order-line-item/discount li)
|
||||||
|
:sales-order-external-id so-ext-id})))))
|
||||||
|
|
||||||
(defn upsert
|
(defn upsert
|
||||||
([client]
|
([client]
|
||||||
(apply de/zip
|
(apply de/zip
|
||||||
@@ -608,8 +657,13 @@
|
|||||||
(doseq [x (partition-all 100 results)]
|
(doseq [x (partition-all 100 results)]
|
||||||
(log/info ::loading-orders
|
(log/info ::loading-orders
|
||||||
:count (count x))
|
:count (count x))
|
||||||
@(dc/transact-async conn x))))))))
|
(doseq [order x]
|
||||||
|
(try
|
||||||
|
(flatten-order-to-parquet! order)
|
||||||
|
(catch Exception e
|
||||||
|
(log/error ::buffer-failed
|
||||||
|
:exception e
|
||||||
|
:order (:sales-order/external-id order))))))))))))
|
||||||
|
|
||||||
(defn upsert-payouts
|
(defn upsert-payouts
|
||||||
([client]
|
([client]
|
||||||
@@ -659,7 +713,6 @@
|
|||||||
|
|
||||||
(log/info ::done-loading-refunds)))))))
|
(log/info ::done-loading-refunds)))))))
|
||||||
|
|
||||||
|
|
||||||
(defn get-cash-shift [client id]
|
(defn get-cash-shift [client id]
|
||||||
(de/chain (manifold-api-call {:url (str (url/url "https://connect.squareup.com/v2/cash-drawers/shifts" id))
|
(de/chain (manifold-api-call {:url (str (url/url "https://connect.squareup.com/v2/cash-drawers/shifts" id))
|
||||||
:method :get
|
:method :get
|
||||||
@@ -818,8 +871,6 @@
|
|||||||
d1
|
d1
|
||||||
d2))
|
d2))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(defn remove-voided-orders
|
(defn remove-voided-orders
|
||||||
([client]
|
([client]
|
||||||
(apply de/zip
|
(apply de/zip
|
||||||
@@ -846,7 +897,7 @@
|
|||||||
(:sales-order/external-id o))))))
|
(:sales-order/external-id o))))))
|
||||||
(s/map (fn [[o]]
|
(s/map (fn [[o]]
|
||||||
[[:db/retractEntity [:sales-order/external-id (:sales-order/external-id o)]]]))
|
[[:db/retractEntity [:sales-order/external-id (:sales-order/external-id o)]]]))
|
||||||
|
|
||||||
(s/reduce into [])))
|
(s/reduce into [])))
|
||||||
|
|
||||||
(fn [results]
|
(fn [results]
|
||||||
@@ -854,28 +905,28 @@
|
|||||||
(doseq [x (partition-all 100 results)]
|
(doseq [x (partition-all 100 results)]
|
||||||
(log/info ::removing-orders
|
(log/info ::removing-orders
|
||||||
:count (count x))
|
:count (count x))
|
||||||
@(dc/transact-async conn x)))))
|
@(dc/transact-async conn x)
|
||||||
(de/catch (fn [e]
|
(de/catch (fn [e]
|
||||||
(log/warn ::couldnt-remove :error e)
|
(log/warn ::couldnt-remove :error e)
|
||||||
nil) ))))))
|
nil)))))))))))
|
||||||
|
|
||||||
#_(comment
|
#_(comment
|
||||||
(require 'auto-ap.time-reader)
|
(require 'auto-ap.time-reader)
|
||||||
|
|
||||||
|
@(let [[c [l]] (get-square-client-and-location "DBFS")]
|
||||||
|
(log/peek :x [c l])
|
||||||
|
(search c l #clj-time/date-time "2026-03-28" #clj-time/date-time "2026-03-29"))
|
||||||
|
|
||||||
@(let [[c [l]] (get-square-client-and-location "NGAK") ]
|
@(let [[c [l]] (get-square-client-and-location "NGAK")]
|
||||||
(log/peek :x [ c l])
|
(log/peek :x [c l])
|
||||||
|
|
||||||
(remove-voided-orders c l #clj-time/date-time "2024-04-11" #clj-time/date-time "2024-04-15"))
|
(remove-voided-orders c l #clj-time/date-time "2024-04-11" #clj-time/date-time "2024-04-15"))
|
||||||
(doseq [c (get-square-clients)]
|
(doseq [c (get-square-clients)]
|
||||||
(try
|
(try
|
||||||
@(remove-voided-orders c)
|
@(remove-voided-orders c)
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
nil)))
|
nil)))
|
||||||
|
)
|
||||||
|
|
||||||
)
|
|
||||||
|
|
||||||
(defn upsert-all [& clients]
|
(defn upsert-all [& clients]
|
||||||
(capture-context->lc
|
(capture-context->lc
|
||||||
@@ -944,8 +995,6 @@
|
|||||||
[:clients clients]
|
[:clients clients]
|
||||||
@(apply upsert-all clients)))
|
@(apply upsert-all clients)))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(comment
|
(comment
|
||||||
(defn refunds-raw-cont
|
(defn refunds-raw-cont
|
||||||
([client l cursor so-far]
|
([client l cursor so-far]
|
||||||
@@ -972,19 +1021,18 @@
|
|||||||
:headers (client-base-headers client)
|
:headers (client-base-headers client)
|
||||||
:as :json})
|
:as :json})
|
||||||
:body)))
|
:body)))
|
||||||
(->>
|
(->>
|
||||||
@(let [[c [l]] (get-square-client-and-location "NGGG")]
|
@(let [[c [l]] (get-square-client-and-location "NGGG")]
|
||||||
|
|
||||||
|
(search c l (time/now) (time/plus (time/now) (time/days -1))))
|
||||||
|
|
||||||
(search c l (time/plus (time/now))))
|
(filter (fn [r]
|
||||||
(filter (fn [r]
|
(str/starts-with? (:created_at r) "2024-03-14"))))
|
||||||
(str/starts-with? (:created_at r) "2024-03-14"))))
|
|
||||||
|
|
||||||
(def refs
|
(def refs
|
||||||
(->>
|
(->>
|
||||||
@(let [[c [l]] (get-square-client-and-location "NGGG")]
|
@(let [[c [l]] (get-square-client-and-location "NGGG")]
|
||||||
|
|
||||||
|
|
||||||
(refunds-raw-cont c l nil []))
|
(refunds-raw-cont c l nil []))
|
||||||
(filter (fn [r]
|
(filter (fn [r]
|
||||||
(str/starts-with? (:created_at r) "2024-03-14")))))
|
(str/starts-with? (:created_at r) "2024-03-14")))))
|
||||||
@@ -995,36 +1043,31 @@
|
|||||||
|
|
||||||
(map (fn [r] @(get-payment c (:payment_id r))) refs))
|
(map (fn [r] @(get-payment c (:payment_id r))) refs))
|
||||||
|
|
||||||
(get-square-client-and-location "NGGB")
|
(get-square-client-and-location "NGGB")
|
||||||
|
|
||||||
(def my-results
|
(def my-results
|
||||||
(let [[c [l]] (get-square-client-and-location "NGFA")]))
|
(let [[c [l]] (get-square-client-and-location "NGFA")]))
|
||||||
|
|
||||||
(clojure.data.csv/write-csv *out*
|
(clojure.data.csv/write-csv *out*
|
||||||
(for [c (get-square-clients)
|
(for [c (get-square-clients)
|
||||||
l (:client/square-locations c)
|
l (:client/square-locations c)
|
||||||
:when (:square-location/client-location l)
|
:when (:square-location/client-location l)
|
||||||
bad-row (try (->> @(search c l (coerce/to-date-time #inst "2024-04-01T00:00:00-07:00") (coerce/to-date-time #inst "2024-04-15T23:59:00-07:00"))
|
bad-row (try (->> @(search c l (coerce/to-date-time #inst "2024-04-01T00:00:00-07:00") (coerce/to-date-time #inst "2024-04-15T23:59:00-07:00"))
|
||||||
(filter #(not (should-import-order? %)))
|
(filter #(not (should-import-order? %)))
|
||||||
(map #(first (deref (order->sales-order c l %))))
|
(map #(first (deref (order->sales-order c l %))))
|
||||||
(filter (fn already-exists [o]
|
(filter (fn already-exists [o]
|
||||||
(when (:sales-order/external-id o)
|
(when (:sales-order/external-id o)
|
||||||
(seq (dc/q '[:find ?i
|
(seq (dc/q '[:find ?i
|
||||||
:in $ ?ei
|
:in $ ?ei
|
||||||
:where [?i :sales-order/external-id ?ei]]
|
:where [?i :sales-order/external-id ?ei]]
|
||||||
(dc/db conn)
|
(dc/db conn)
|
||||||
(:sales-order/external-id o)))))))
|
(:sales-order/external-id o)))))))
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
[]))]
|
[]))]
|
||||||
[(:client/code c) (atime/unparse-local (clj-time.coerce/to-date-time (:sales-order/date bad-row)) atime/normal-date) (:sales-order/total bad-row) (:sales-order/tax bad-row) (:sales-order/tip bad-row) (:db/id bad-row)])
|
[(:client/code c) (atime/unparse-local (clj-time.coerce/to-date-time (:sales-order/date bad-row)) atime/normal-date) (:sales-order/total bad-row) (:sales-order/tax bad-row) (:sales-order/tip bad-row) (:db/id bad-row)])
|
||||||
:separator \tab)
|
:separator \tab)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
;; =>
|
|
||||||
|
|
||||||
|
|
||||||
|
;; =>
|
||||||
|
|
||||||
(require 'auto-ap.time-reader)
|
(require 'auto-ap.time-reader)
|
||||||
|
|
||||||
@@ -1033,35 +1076,40 @@
|
|||||||
(clojure.pprint/pprint (let [[c [l]] (get-square-client-and-location "NGVT")]
|
(clojure.pprint/pprint (let [[c [l]] (get-square-client-and-location "NGVT")]
|
||||||
l
|
l
|
||||||
|
|
||||||
|
|
||||||
(def z @(search c l #clj-time/date-time "2025-02-23T00:00:00-08:00"
|
(def z @(search c l #clj-time/date-time "2025-02-23T00:00:00-08:00"
|
||||||
#clj-time/date-time "2025-02-28T00:00:00-08:00"))
|
#clj-time/date-time "2025-02-28T00:00:00-08:00"))
|
||||||
(take 10 (map #(first (deref (order->sales-order c l %))) z)))
|
(take 10 (map #(first (deref (order->sales-order c l %))) z))))
|
||||||
|
|
||||||
|
(->> z
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(->> z
|
|
||||||
(filter (fn [o]
|
(filter (fn [o]
|
||||||
(seq (filter (comp #{"OTHER"} :type) (:tenders o)))))
|
(seq (filter (comp #{"OTHER"} :type) (:tenders o)))))
|
||||||
(filter #(not (:name (:source %))))
|
(filter #(not (:name (:source %))))
|
||||||
(count)
|
(count))
|
||||||
|
|
||||||
)
|
(doseq [[code] (seq (dc/q '[:find ?code
|
||||||
(doseq [c (get-square-clients)]
|
:in $
|
||||||
(println "Upserting" (:client/name c))
|
:where [?o :sales-order/date ?d]
|
||||||
@(upsert c))
|
[(>= ?d #inst "2026-01-01")]
|
||||||
|
[?o :sales-order/source "Invoices"]
|
||||||
|
[?o :sales-order/client ?c]
|
||||||
|
[?c :client/code ?code]]
|
||||||
|
(dc/db conn)))
|
||||||
|
:let [[c [l]] (get-square-client-and-location code)]
|
||||||
|
order @(search c l #clj-time/date-time "2026-01-01T00:00:00-08:00" (time/now))
|
||||||
|
:when (= "Invoices" (:name (:source order)))
|
||||||
|
:let [[sales-order] @(order->sales-order c l order)]]
|
||||||
|
|
||||||
|
(when (should-import-order? order)
|
||||||
|
(println "DATE IS" (:sales-order/date sales-order))
|
||||||
|
(when (some-> (:sales-order/date sales-order) coerce/to-date-time (time/after? #clj-time/date-time "2026-2-16T00:00:00-08:00"))
|
||||||
|
(println "WOULD UPDATE" sales-order)
|
||||||
|
@(dc/transact auto-ap.datomic/conn [sales-order]))
|
||||||
|
#_@(dc/transact)
|
||||||
|
(println "DONE")))
|
||||||
|
|
||||||
#_(filter (comp #{"OTHER"} :type) (mapcat :tenders z))
|
#_(filter (comp #{"OTHER"} :type) (mapcat :tenders z))
|
||||||
|
|
||||||
|
|
||||||
(let [[c [l]] (get-square-client-and-location "LFHH")]
|
@(let [[c [l]] (get-square-client-and-location "NGRY")]
|
||||||
(search c l (clj-time.coerce/from-date #inst "2025-02-28") (clj-time.coerce/from-date #inst "2025-03-01"))
|
#_(search c l (clj-time.coerce/from-date #inst "2025-02-28") (clj-time.coerce/from-date #inst "2025-03-01"))
|
||||||
|
|
||||||
(:order (get-order c l "CLjQqkzVfGa82o5hEFUrGtUGO6QZY" ))
|
(order->sales-order c l (:order (get-order c l "KdvwntmfMNTKBu8NOocbxatOs18YY")))))
|
||||||
)
|
|
||||||
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
(ns auto-ap.ssr.components.date-range
|
(ns auto-ap.ssr.components.date-range
|
||||||
(:require [auto-ap.ssr.components :as com]
|
(:require [auto-ap.ssr.components :as com]
|
||||||
|
[auto-ap.ssr.components.buttons :as but]
|
||||||
|
[auto-ap.ssr.svg :as svg]
|
||||||
[auto-ap.time :as atime]
|
[auto-ap.time :as atime]
|
||||||
[clj-time.coerce :as c]
|
[clj-time.coerce :as c]
|
||||||
[clj-time.core :as t]
|
[clj-time.core :as t]
|
||||||
[clj-time.periodic :as per]))
|
[clj-time.periodic :as per]))
|
||||||
|
|
||||||
(defn date-range-field [{:keys [value id]}]
|
(defn date-range-field [{:keys [value id apply-button?]}]
|
||||||
[:div {:id id}
|
[:div {:id id}
|
||||||
(com/field {:label "Date Range"}
|
(com/field {:label "Date Range"}
|
||||||
[:div.space-y-4
|
[:div.space-y-4
|
||||||
@@ -21,11 +23,17 @@
|
|||||||
(atime/unparse-local atime/normal-date))
|
(atime/unparse-local atime/normal-date))
|
||||||
:placeholder "Date"
|
:placeholder "Date"
|
||||||
:size :small
|
:size :small
|
||||||
:class "shrink"})
|
:class "shrink date-filter-input"})
|
||||||
|
|
||||||
(com/date-input {:name "end-date"
|
(com/date-input {:name "end-date"
|
||||||
:value (some-> (:end value)
|
:value (some-> (:end value)
|
||||||
(atime/unparse-local atime/normal-date))
|
(atime/unparse-local atime/normal-date))
|
||||||
:placeholder "Date"
|
:placeholder "Date"
|
||||||
:size :small
|
:size :small
|
||||||
:class "shrink"})]])])
|
:class "shrink date-filter-input"})
|
||||||
|
(when apply-button?
|
||||||
|
(but/button- {:color :secondary
|
||||||
|
:size :small
|
||||||
|
:type "button"
|
||||||
|
"x-on:click" "$dispatch('datesApplied')"}
|
||||||
|
"Apply"))]])])
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
[auto-ap.ssr.invoice.common :refer [default-read]]
|
[auto-ap.ssr.invoice.common :refer [default-read]]
|
||||||
[auto-ap.ssr.invoice.import :as invoice-import]
|
[auto-ap.ssr.invoice.import :as invoice-import]
|
||||||
[auto-ap.ssr.invoice.new-invoice-wizard :as new-invoice-wizard :refer [location-select*]]
|
[auto-ap.ssr.invoice.new-invoice-wizard :as new-invoice-wizard :refer [location-select*]]
|
||||||
[auto-ap.ssr.pos.common :refer [date-range-field*]]
|
[auto-ap.ssr.components.date-range :as dr]
|
||||||
[auto-ap.ssr.svg :as svg]
|
[auto-ap.ssr.svg :as svg]
|
||||||
[auto-ap.ssr.utils
|
[auto-ap.ssr.utils
|
||||||
:refer [apply-middleware-to-all-handlers assert-schema
|
:refer [apply-middleware-to-all-handlers assert-schema
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
[:div {:id "exact-match-id-tag"}]))
|
[:div {:id "exact-match-id-tag"}]))
|
||||||
|
|
||||||
(defn filters [request]
|
(defn filters [request]
|
||||||
[:form#invoice-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
[:form#invoice-filters {"hx-trigger" "datesApplied, change delay:500ms from:.filter-trigger, keyup changed from:.hot-filter delay:1000ms"
|
||||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||||
::route/table)
|
::route/table)
|
||||||
"hx-target" "#entity-table"
|
"hx-target" "#entity-table"
|
||||||
@@ -92,7 +92,8 @@
|
|||||||
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
|
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
|
||||||
:value (:vendor (:query-params request))
|
:value (:vendor (:query-params request))
|
||||||
:value-fn :db/id
|
:value-fn :db/id
|
||||||
:content-fn :vendor/name}))
|
:content-fn :vendor/name
|
||||||
|
:class "filter-trigger"}))
|
||||||
(com/field {:label "Account"}
|
(com/field {:label "Account"}
|
||||||
(com/typeahead {:name "account"
|
(com/typeahead {:name "account"
|
||||||
:id "account"
|
:id "account"
|
||||||
@@ -100,8 +101,12 @@
|
|||||||
:value (:account (:query-params request))
|
:value (:account (:query-params request))
|
||||||
:value-fn :db/id
|
:value-fn :db/id
|
||||||
:content-fn #(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read (:db/id %))
|
:content-fn #(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read (:db/id %))
|
||||||
(:db/id (:client request))))}))
|
(:db/id (:client request))))
|
||||||
(date-range-field* request)
|
:class "filter-trigger"}))
|
||||||
|
(dr/date-range-field {:value {:start (:start-date (:query-params request))
|
||||||
|
:end (:end-date (:query-params request))}
|
||||||
|
:id "date-range"
|
||||||
|
:apply-button? true})
|
||||||
(com/field {:label "Check #"}
|
(com/field {:label "Check #"}
|
||||||
(com/text-input {:name "check-number"
|
(com/text-input {:name "check-number"
|
||||||
:id "check-number"
|
:id "check-number"
|
||||||
@@ -486,7 +491,10 @@
|
|||||||
:fetch-page fetch-page
|
:fetch-page fetch-page
|
||||||
:oob-render
|
:oob-render
|
||||||
(fn [request]
|
(fn [request]
|
||||||
[(assoc-in (date-range-field* request) [1 :hx-swap-oob] true)
|
[(assoc-in (dr/date-range-field {:value {:start (:start-date (:query-params request))
|
||||||
|
:end (:end-date (:query-params request))}
|
||||||
|
:id "date-range"
|
||||||
|
:apply-button? true}) [1 :hx-swap-oob] true)
|
||||||
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)])
|
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)])
|
||||||
:query-schema query-schema
|
:query-schema query-schema
|
||||||
:parse-query-params (fn [p]
|
:parse-query-params (fn [p]
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
data (into []
|
data (into []
|
||||||
(for [client-id client-ids
|
(for [client-id client-ids
|
||||||
d date
|
d date
|
||||||
[client-id account-id location debits credits balance count] (iol-ion.query/detailed-account-snapshot (dc/db conn) client-id (coerce/to-date (time/plus d (time/days 1))))
|
[client-id account-id location debits credits balance count] (iol-ion.query/detailed-account-snapshot (dc/db conn) client-id (coerce/to-date d))
|
||||||
:let [account ((or (lookup-account client-id) {}) account-id)]]
|
:let [account ((or (lookup-account client-id) {}) account-id)]]
|
||||||
{:client-id client-id
|
{:client-id client-id
|
||||||
:account-id account-id
|
:account-id account-id
|
||||||
|
|||||||
@@ -104,19 +104,18 @@
|
|||||||
:size :small})])
|
:size :small})])
|
||||||
(com/field {:label "Payment Type"}
|
(com/field {:label "Payment Type"}
|
||||||
(com/radio-card {:size :small
|
(com/radio-card {:size :small
|
||||||
:name "payment-type"
|
:name "payment-type"
|
||||||
:value (:payment-type (:query-params request))
|
:value (:payment-type (:query-params request))
|
||||||
:options [{:value ""
|
:options [{:value ""
|
||||||
:content "All"}
|
:content "All"}
|
||||||
{:value "cash"
|
{:value "cash"
|
||||||
:content "Cash"}
|
:content "Cash"}
|
||||||
{:value "check"
|
{:value "check"
|
||||||
:content "Check"}
|
:content "Check"}
|
||||||
{:value "debit"
|
{:value "debit"
|
||||||
:content "Debit"}]}))
|
:content "Debit"}]}))
|
||||||
(exact-match-id* request)]])
|
(exact-match-id* request)]])
|
||||||
|
|
||||||
|
|
||||||
(def default-read '[*
|
(def default-read '[*
|
||||||
[:payment/date :xform clj-time.coerce/from-date]
|
[:payment/date :xform clj-time.coerce/from-date]
|
||||||
{:invoice-payment/_payment [* {:invoice-payment/invoice [*]}]}
|
{:invoice-payment/_payment [* {:invoice-payment/invoice [*]}]}
|
||||||
@@ -212,7 +211,6 @@
|
|||||||
'[(iol-ion.query/dollars= ?transaction-amount ?amount)]]}
|
'[(iol-ion.query/dollars= ?transaction-amount ?amount)]]}
|
||||||
:args [(:amount query-params)]})
|
:args [(:amount query-params)]})
|
||||||
|
|
||||||
|
|
||||||
(:status route-params)
|
(:status route-params)
|
||||||
(merge-query {:query {:in ['?status]
|
(merge-query {:query {:in ['?status]
|
||||||
:where ['[?e :payment/status ?status]]}
|
:where ['[?e :payment/status ?status]]}
|
||||||
@@ -243,30 +241,30 @@
|
|||||||
refunds))
|
refunds))
|
||||||
|
|
||||||
(defn sum-visible-pending [ids]
|
(defn sum-visible-pending [ids]
|
||||||
(->>
|
(->>
|
||||||
(dc/q {:find ['?id '?o]
|
(dc/q {:find ['?id '?o]
|
||||||
:in ['$ '[?id ...]]
|
:in ['$ '[?id ...]]
|
||||||
:where ['[?id :payment/amount ?o]
|
:where ['[?id :payment/amount ?o]
|
||||||
'[?id :payment/status :payment-status/pending]]}
|
'[?id :payment/status :payment-status/pending]]}
|
||||||
(dc/db conn)
|
(dc/db conn)
|
||||||
ids)
|
ids)
|
||||||
(map last)
|
(map last)
|
||||||
(reduce
|
(reduce
|
||||||
+
|
+
|
||||||
0.0)))
|
0.0)))
|
||||||
|
|
||||||
(defn sum-client-pending [clients]
|
(defn sum-client-pending [clients]
|
||||||
(->>
|
(->>
|
||||||
(dc/q {:find '[?e ?a]
|
(dc/q {:find '[?e ?a]
|
||||||
:in '[$ [?clients ?start ?end]]
|
:in '[$ [?clients ?start ?end]]
|
||||||
:where '[[(iol-ion.query/scan-payments $ ?clients ?start ?end) [[?e _ ?sort-default] ...]]
|
:where '[[(iol-ion.query/scan-payments $ ?clients ?start ?end) [[?e _ ?sort-default] ...]]
|
||||||
[?e :payment/status :payment-status/pending]
|
[?e :payment/status :payment-status/pending]
|
||||||
[?e :payment/amount ?a]]}
|
[?e :payment/amount ?a]]}
|
||||||
(dc/db conn)
|
(dc/db conn)
|
||||||
[clients
|
[clients
|
||||||
nil
|
nil
|
||||||
nil])
|
nil])
|
||||||
|
|
||||||
(map last)
|
(map last)
|
||||||
(reduce
|
(reduce
|
||||||
+
|
+
|
||||||
@@ -277,16 +275,14 @@
|
|||||||
{ids-to-retrieve :ids matching-count :count
|
{ids-to-retrieve :ids matching-count :count
|
||||||
all-ids :all-ids} (fetch-ids db request)]
|
all-ids :all-ids} (fetch-ids db request)]
|
||||||
|
|
||||||
|
|
||||||
[(->> (hydrate-results ids-to-retrieve db request))
|
[(->> (hydrate-results ids-to-retrieve db request))
|
||||||
matching-count
|
matching-count
|
||||||
(sum-visible-pending all-ids)
|
(sum-visible-pending all-ids)
|
||||||
(sum-client-pending (extract-client-ids (:clients request)
|
(sum-client-pending (extract-client-ids (:clients request)
|
||||||
(:client request)
|
(:client request)
|
||||||
(:client-id (:query-params request))
|
(:client-id (:query-params request))
|
||||||
(when (:client-code (:query-params request))
|
(when (:client-code (:query-params request))
|
||||||
[:client/code (:client-code (:query-params request))])))
|
[:client/code (:client-code (:query-params request))])))]))
|
||||||
]))
|
|
||||||
|
|
||||||
(def query-schema (mc/schema
|
(def query-schema (mc/schema
|
||||||
[:maybe [:map {:date-range [:date-range :start-date :end-date]}
|
[:maybe [:map {:date-range [:date-range :start-date :end-date]}
|
||||||
@@ -327,7 +323,7 @@
|
|||||||
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)])
|
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)])
|
||||||
:query-schema query-schema
|
:query-schema query-schema
|
||||||
:action-buttons (fn [request]
|
:action-buttons (fn [request]
|
||||||
(let [[_ _ visible-in-float total-in-float ] (:page-results request)]
|
(let [[_ _ visible-in-float total-in-float] (:page-results request)]
|
||||||
[(com/pill {:color :primary} " Visible in float "
|
[(com/pill {:color :primary} " Visible in float "
|
||||||
(format "$%,.2f" visible-in-float))
|
(format "$%,.2f" visible-in-float))
|
||||||
(com/pill {:color :secondary} " Total in float "
|
(com/pill {:color :secondary} " Total in float "
|
||||||
@@ -354,7 +350,7 @@
|
|||||||
|
|
||||||
(= (-> request :query-params :sort first :name) "Bank account")
|
(= (-> request :query-params :sort first :name) "Bank account")
|
||||||
(-> entity :payment/bank-account :bank-account/name)
|
(-> entity :payment/bank-account :bank-account/name)
|
||||||
|
|
||||||
:else nil))
|
:else nil))
|
||||||
:title (fn [r]
|
:title (fn [r]
|
||||||
(str
|
(str
|
||||||
@@ -409,7 +405,7 @@
|
|||||||
:render (fn [{:payment/keys [date]}]
|
:render (fn [{:payment/keys [date]}]
|
||||||
(some-> date (atime/unparse-local atime/normal-date)))}
|
(some-> date (atime/unparse-local atime/normal-date)))}
|
||||||
{:key "amount"
|
{:key "amount"
|
||||||
:sort-key "amount"
|
:sort-key "amount"
|
||||||
:name "Amount"
|
:name "Amount"
|
||||||
:render (fn [{:payment/keys [amount]}]
|
:render (fn [{:payment/keys [amount]}]
|
||||||
(some->> amount (format "$%.2f")))}
|
(some->> amount (format "$%.2f")))}
|
||||||
@@ -421,10 +417,10 @@
|
|||||||
(map :invoice-payment/invoice)
|
(map :invoice-payment/invoice)
|
||||||
(filter identity)
|
(filter identity)
|
||||||
(map (fn [invoice]
|
(map (fn [invoice]
|
||||||
{:link (hu/url (bidi/path-for ssr-routes/only-routes
|
{:link (hu/url (bidi/path-for ssr-routes/only-routes
|
||||||
::invoice-route/all-page)
|
::invoice-route/all-page)
|
||||||
{:exact-match-id (:db/id invoice)})
|
{:exact-match-id (:db/id invoice)})
|
||||||
:content (str "Inv. " (:invoice/invoice-number invoice))})))
|
:content (str "Inv. " (:invoice/invoice-number invoice))})))
|
||||||
(some-> p :transaction/_payment ((fn [t]
|
(some-> p :transaction/_payment ((fn [t]
|
||||||
[{:link (hu/url (bidi/path-for client-routes/routes
|
[{:link (hu/url (bidi/path-for client-routes/routes
|
||||||
:transactions)
|
:transactions)
|
||||||
@@ -434,8 +430,6 @@
|
|||||||
|
|
||||||
(def row* (partial helper/row* grid-page))
|
(def row* (partial helper/row* grid-page))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(comment
|
(comment
|
||||||
(mc/decode query-schema {"exact-match-id" "123"} (mt/transformer main-transformer mt/strip-extra-keys-transformer))
|
(mc/decode query-schema {"exact-match-id" "123"} (mt/transformer main-transformer mt/strip-extra-keys-transformer))
|
||||||
(mc/decode query-schema {} (mt/transformer main-transformer mt/strip-extra-keys-transformer))
|
(mc/decode query-schema {} (mt/transformer main-transformer mt/strip-extra-keys-transformer))
|
||||||
@@ -445,7 +439,6 @@
|
|||||||
(mc/decode query-schema {"payment-type" "food"} (mt/transformer main-transformer mt/strip-extra-keys-transformer))
|
(mc/decode query-schema {"payment-type" "food"} (mt/transformer main-transformer mt/strip-extra-keys-transformer))
|
||||||
(mc/decode query-schema {"vendor" "87"} (mt/transformer main-transformer mt/strip-extra-keys-transformer))
|
(mc/decode query-schema {"vendor" "87"} (mt/transformer main-transformer mt/strip-extra-keys-transformer))
|
||||||
|
|
||||||
|
|
||||||
(mc/decode query-schema {"start-date" #inst "2023-12-21T08:00:00.000-00:00"} (mt/transformer main-transformer mt/strip-extra-keys-transformer)))
|
(mc/decode query-schema {"start-date" #inst "2023-12-21T08:00:00.000-00:00"} (mt/transformer main-transformer mt/strip-extra-keys-transformer)))
|
||||||
|
|
||||||
(defn delete [{check :entity :as request identity :identity}]
|
(defn delete [{check :entity :as request identity :identity}]
|
||||||
@@ -459,7 +452,7 @@
|
|||||||
#(assert-can-see-client identity (:db/id (:payment/client check))))
|
#(assert-can-see-client identity (:db/id (:payment/client check))))
|
||||||
(notify-if-locked (:db/id (:payment/client check))
|
(notify-if-locked (:db/id (:payment/client check))
|
||||||
(:payment/date check))
|
(:payment/date check))
|
||||||
(let [ removing-payments (mapcat (fn [x]
|
(let [removing-payments (mapcat (fn [x]
|
||||||
(let [invoice (:invoice-payment/invoice x)
|
(let [invoice (:invoice-payment/invoice x)
|
||||||
new-balance (+ (:invoice/outstanding-balance invoice)
|
new-balance (+ (:invoice/outstanding-balance invoice)
|
||||||
(:invoice-payment/amount x))]
|
(:invoice-payment/amount x))]
|
||||||
@@ -475,9 +468,9 @@
|
|||||||
:payment/status :payment-status/voided}]
|
:payment/status :payment-status/voided}]
|
||||||
(audit-transact (cond-> removing-payments
|
(audit-transact (cond-> removing-payments
|
||||||
true (conj updated-payment)
|
true (conj updated-payment)
|
||||||
(:transaction/_payment check) (conj [:db/retract (:db/id (first (:transaction/_payment check)))
|
(:transaction/_payment check) (conj [:db/retract (:db/id (first (:transaction/_payment check)))
|
||||||
:transaction/payment
|
:transaction/payment
|
||||||
(:db/id check)]))
|
(:db/id check)]))
|
||||||
identity)
|
identity)
|
||||||
|
|
||||||
(html-response (row* (:identity request) updated-payment {:delete-after-settle? true :class "live-removed"
|
(html-response (row* (:identity request) updated-payment {:delete-after-settle? true :class "live-removed"
|
||||||
@@ -578,7 +571,6 @@
|
|||||||
(assoc-in [:query-params :start] 0)
|
(assoc-in [:query-params :start] 0)
|
||||||
(assoc-in [:query-params :per-page] 250))))
|
(assoc-in [:query-params :per-page] 250))))
|
||||||
|
|
||||||
|
|
||||||
:else
|
:else
|
||||||
selected)
|
selected)
|
||||||
updated-count (void-payments-internal ids (:identity request))]
|
updated-count (void-payments-internal ids (:identity request))]
|
||||||
@@ -591,7 +583,7 @@
|
|||||||
|
|
||||||
(defn wrap-status-from-source [handler]
|
(defn wrap-status-from-source [handler]
|
||||||
(fn [{:keys [matched-current-page-route] :as request}]
|
(fn [{:keys [matched-current-page-route] :as request}]
|
||||||
(let [ request (cond-> request
|
(let [request (cond-> request
|
||||||
(= ::route/cleared-page matched-current-page-route) (assoc-in [:route-params :status] :payment-status/cleared)
|
(= ::route/cleared-page matched-current-page-route) (assoc-in [:route-params :status] :payment-status/cleared)
|
||||||
(= ::route/pending-page matched-current-page-route) (assoc-in [:route-params :status] :payment-status/pending)
|
(= ::route/pending-page matched-current-page-route) (assoc-in [:route-params :status] :payment-status/pending)
|
||||||
(= ::route/voided-page matched-current-page-route) (assoc-in [:route-params :status] :payment-status/voided)
|
(= ::route/voided-page matched-current-page-route) (assoc-in [:route-params :status] :payment-status/voided)
|
||||||
@@ -605,7 +597,7 @@
|
|||||||
::route/pending-page (-> (helper/page-route grid-page)
|
::route/pending-page (-> (helper/page-route grid-page)
|
||||||
(wrap-implied-route-param :status :payment-status/pending))
|
(wrap-implied-route-param :status :payment-status/pending))
|
||||||
::route/voided-page (-> (helper/page-route grid-page)
|
::route/voided-page (-> (helper/page-route grid-page)
|
||||||
(wrap-implied-route-param :status :payment-status/voided))
|
(wrap-implied-route-param :status :payment-status/voided))
|
||||||
::route/all-page (-> (helper/page-route grid-page)
|
::route/all-page (-> (helper/page-route grid-page)
|
||||||
(wrap-implied-route-param :status nil))
|
(wrap-implied-route-param :status nil))
|
||||||
|
|
||||||
@@ -618,7 +610,6 @@
|
|||||||
::route/bulk-delete (-> bulk-delete-dialog
|
::route/bulk-delete (-> bulk-delete-dialog
|
||||||
(wrap-admin))
|
(wrap-admin))
|
||||||
|
|
||||||
|
|
||||||
::route/table (helper/table-route grid-page)}
|
::route/table (helper/table-route grid-page)}
|
||||||
(fn [h]
|
(fn [h]
|
||||||
(-> h
|
(-> h
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
(ns auto-ap.ssr.pos.sales-orders
|
(ns auto-ap.ssr.pos.sales-orders
|
||||||
(:require
|
(:require
|
||||||
[auto-ap.datomic
|
[auto-ap.datomic
|
||||||
:refer [add-sorter-fields apply-pagination apply-sort-3 conn merge-query
|
:refer [add-sorter-fields apply-pagination apply-sort-3 merge-query
|
||||||
pull-many query2]]
|
pull-many query2]]
|
||||||
[auto-ap.datomic.sales-orders :as d-sales]
|
[auto-ap.datomic.sales-orders :as d-sales]
|
||||||
[auto-ap.query-params :as query-params :refer [wrap-copy-qp-pqp]]
|
[auto-ap.query-params :as query-params :refer [wrap-copy-qp-pqp]]
|
||||||
@@ -17,7 +17,6 @@
|
|||||||
[auto-ap.time :as atime]
|
[auto-ap.time :as atime]
|
||||||
[bidi.bidi :as bidi]
|
[bidi.bidi :as bidi]
|
||||||
[clj-time.coerce :as c]
|
[clj-time.coerce :as c]
|
||||||
[datomic.api :as dc]
|
|
||||||
[malli.core :as mc]))
|
[malli.core :as mc]))
|
||||||
|
|
||||||
(def query-schema (mc/schema
|
(def query-schema (mc/schema
|
||||||
@@ -172,11 +171,8 @@
|
|||||||
charges))
|
charges))
|
||||||
|
|
||||||
(defn fetch-page [request]
|
(defn fetch-page [request]
|
||||||
(let [db (dc/db conn)
|
(let [{:keys [rows count]} (d-sales/fetch-page-ssr request)]
|
||||||
{ids-to-retrieve :ids matching-count :count} (fetch-ids db request)]
|
[rows count]))
|
||||||
|
|
||||||
[(->> (hydrate-results ids-to-retrieve db request))
|
|
||||||
matching-count]))
|
|
||||||
|
|
||||||
|
|
||||||
(def grid-page
|
(def grid-page
|
||||||
@@ -200,13 +196,13 @@
|
|||||||
:title "Sales orders"
|
:title "Sales orders"
|
||||||
:entity-name "Sales orders"
|
:entity-name "Sales orders"
|
||||||
:route :pos-sales-table
|
:route :pos-sales-table
|
||||||
:action-buttons (fn [request]
|
:action-buttons (fn [request]
|
||||||
(let [{:keys [total tax]} (d-sales/summarize-orders (:ids (fetch-ids (dc/db conn) request)))]
|
(let [{:keys [total tax]} (d-sales/summarize-page-ssr request)]
|
||||||
(when (and total tax)
|
(when (and total tax)
|
||||||
[(com/pill {:color :primary}
|
[(com/pill {:color :primary}
|
||||||
(format "Total $%.2f" total))
|
(format "Total $%.2f" total))
|
||||||
(com/pill {:color :secondary}
|
(com/pill {:color :secondary}
|
||||||
(format "Tax $%.2f" tax))])))
|
(format "Tax $%.2f" tax))])))
|
||||||
:row-buttons (fn [_ e]
|
:row-buttons (fn [_ e]
|
||||||
(when (:sales-order/reference-link e)
|
(when (:sales-order/reference-link e)
|
||||||
[(com/a-icon-button {:href (:sales-order/reference-link e)}
|
[(com/a-icon-button {:href (:sales-order/reference-link e)}
|
||||||
|
|||||||
432
src/clj/auto_ap/storage/parquet.clj
Normal file
432
src/clj/auto_ap/storage/parquet.clj
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
(ns auto-ap.storage.parquet
|
||||||
|
(:require [config.core :refer [env]]
|
||||||
|
[amazonica.aws.s3 :as s3]
|
||||||
|
[clojure.java.io :as io]
|
||||||
|
[clojure.string :as str]
|
||||||
|
[clojure.data.json :as json]
|
||||||
|
[clojure.core.cache :as cache]
|
||||||
|
[com.brunobonacci.mulog :as mu])
|
||||||
|
(:import (java.sql DriverManager)
|
||||||
|
(java.time LocalDate)))
|
||||||
|
|
||||||
|
(def ^:dynamic *bucket* (:data-bucket env))
|
||||||
|
(def parquet-prefix "sales-details")
|
||||||
|
|
||||||
|
(defn s3-location [filename]
|
||||||
|
(str "s3://" *bucket* "/" filename))
|
||||||
|
|
||||||
|
(defn parquet-key [entity-type date-str]
|
||||||
|
(let [month-str (if (= (count date-str) 10)
|
||||||
|
(subs date-str 0 7)
|
||||||
|
date-str)]
|
||||||
|
(str parquet-prefix "/" entity-type "/" month-str ".parquet")))
|
||||||
|
|
||||||
|
(def db (atom nil))
|
||||||
|
|
||||||
|
(defn connect! []
|
||||||
|
(let [conn (DriverManager/getConnection "jdbc:duckdb:")
|
||||||
|
stmt (.createStatement conn)]
|
||||||
|
(.execute stmt "INSTALL httpfs; LOAD httpfs;")
|
||||||
|
(when-let [key (:aws-access-key-id env)]
|
||||||
|
(.execute stmt (str "SET s3_access_key_id='" key "'"))
|
||||||
|
(.execute stmt (str "SET s3_secret_access_key='" (:aws-secret-access-key env) "'"))
|
||||||
|
(.execute stmt (str "SET s3_region='" (or (:aws-region env) "us-east-1") "'")))
|
||||||
|
(.execute stmt "PRAGMA enable_object_cache")
|
||||||
|
(.execute stmt "SET temp_directory='/tmp/duckdb-temp'")
|
||||||
|
(.execute stmt "SET memory_limit='2GB'")
|
||||||
|
(.close stmt)
|
||||||
|
(.addShutdownHook (Runtime/getRuntime)
|
||||||
|
(Thread. #(when-let [c @db] (.close ^java.sql.Connection c))))
|
||||||
|
(reset! db conn)))
|
||||||
|
|
||||||
|
(defn disconnect! []
|
||||||
|
(locking db
|
||||||
|
(when-let [c @db]
|
||||||
|
(.close c)
|
||||||
|
(reset! db nil))))
|
||||||
|
|
||||||
|
(defmacro with-duckdb
|
||||||
|
[& body]
|
||||||
|
`(let [conn# (or @db (connect!))]
|
||||||
|
(try
|
||||||
|
(let [~'conn conn#]
|
||||||
|
~@body)
|
||||||
|
(finally
|
||||||
|
(when (and (not @db) conn#)
|
||||||
|
(.close conn#))))))
|
||||||
|
|
||||||
|
(defn execute! [sql]
|
||||||
|
(with-duckdb
|
||||||
|
(let [stmt (.createStatement conn)]
|
||||||
|
(.execute stmt sql)
|
||||||
|
nil)))
|
||||||
|
|
||||||
|
(defn- sql-snippet [sql] (subs sql 0 (min (count sql) 500)))
|
||||||
|
|
||||||
|
(defn query-scalar [sql]
|
||||||
|
(mu/trace ::query-scalar
|
||||||
|
[:sql (sql-snippet sql)]
|
||||||
|
(with-duckdb
|
||||||
|
(let [stmt (.createStatement conn)
|
||||||
|
rs (.executeQuery stmt sql)]
|
||||||
|
(when (.next rs)
|
||||||
|
(.getObject rs 1))))))
|
||||||
|
|
||||||
|
(def ^:private count-cache
|
||||||
|
(atom (-> (cache/ttl-cache-factory {} :ttl 1800000)
|
||||||
|
(cache/lru-cache-factory :threshold 256))))
|
||||||
|
|
||||||
|
(defn- cached-count [sql]
|
||||||
|
(if-let [v (find @count-cache sql)]
|
||||||
|
(do (mu/log ::count-cache :hit true :sql (sql-snippet sql)) (val v))
|
||||||
|
(do (mu/log ::count-cache :hit false :sql (sql-snippet sql))
|
||||||
|
(let [result (query-scalar sql)]
|
||||||
|
(swap! count-cache assoc sql result)
|
||||||
|
result))))
|
||||||
|
|
||||||
|
(defn query-rows [sql]
|
||||||
|
(mu/trace ::query-rows
|
||||||
|
[:sql (sql-snippet sql)]
|
||||||
|
(with-duckdb
|
||||||
|
(let [stmt (.createStatement conn)
|
||||||
|
rs (.executeQuery stmt sql)
|
||||||
|
meta (.getMetaData rs)
|
||||||
|
col-count (.getColumnCount meta)
|
||||||
|
cols (vec (for [i (range 1 (inc col-count))]
|
||||||
|
(keyword (.getColumnLabel meta i))))]
|
||||||
|
(loop [rows []]
|
||||||
|
(if (.next rs)
|
||||||
|
(recur (conj rows
|
||||||
|
(zipmap cols
|
||||||
|
(vec (for [i (range 1 (inc col-count))]
|
||||||
|
(.getObject rs i))))))
|
||||||
|
rows))))))
|
||||||
|
|
||||||
|
(defn execute-to-parquet! [sql ^String parquet-path]
|
||||||
|
(with-duckdb
|
||||||
|
(let [stmt (.createStatement conn)]
|
||||||
|
(.execute stmt
|
||||||
|
(format "COPY (%s) TO '%s' (FORMAT PARQUET, OVERWRITE_OR_IGNORE, ROW_GROUP_SIZE 10000, COMPRESSION 'zstd')"
|
||||||
|
sql parquet-path))
|
||||||
|
(io/file parquet-path))))
|
||||||
|
|
||||||
|
(defn upload-parquet! [local-parquet-file s3-key]
|
||||||
|
(s3/put-object {:bucket-name *bucket*
|
||||||
|
:key s3-key
|
||||||
|
:file local-parquet-file})
|
||||||
|
(s3-location s3-key))
|
||||||
|
|
||||||
|
(defonce *buffers* (atom {}))
|
||||||
|
|
||||||
|
(defn- wal-dir []
|
||||||
|
(io/file (System/getProperty "user.dir" "/tmp")
|
||||||
|
"parquet-wal"))
|
||||||
|
|
||||||
|
(defn- init-wal! []
|
||||||
|
(let [dir (wal-dir)]
|
||||||
|
(when-not (.exists dir)
|
||||||
|
(.mkdirs dir))))
|
||||||
|
|
||||||
|
(defn buffer! [entity-type record]
|
||||||
|
(init-wal!)
|
||||||
|
(let [seq-no (System/currentTimeMillis)
|
||||||
|
entry (assoc record :_seq-no seq-no)]
|
||||||
|
(swap! *buffers* update entity-type (fnil conj []) entry)
|
||||||
|
(try
|
||||||
|
(let [wal-file (io/file (wal-dir)
|
||||||
|
(str entity-type ".jsonl"))]
|
||||||
|
(io/make-parents wal-file)
|
||||||
|
(with-open [w (io/writer wal-file :append true)]
|
||||||
|
(.write w ^String (json/write-str {:seq-no seq-no
|
||||||
|
:record record}))
|
||||||
|
(.write w (int \newline))))
|
||||||
|
(catch Exception e
|
||||||
|
(println "[parquet/wal]" (.getMessage e))))
|
||||||
|
entry))
|
||||||
|
|
||||||
|
(defn clear-buffer! [entity-type]
|
||||||
|
(swap! *buffers* dissoc entity-type))
|
||||||
|
|
||||||
|
(defn buffer-count [entity-type]
|
||||||
|
(-> @*buffers* (get entity-type []) count))
|
||||||
|
|
||||||
|
(defn total-buf-count []
|
||||||
|
(->> @*buffers*
|
||||||
|
vals (mapcat identity) count))
|
||||||
|
|
||||||
|
(defn flush-to-parquet! [entity-type date-str]
|
||||||
|
"Flush buffered records for entity-type to monthly parquet + S3.
|
||||||
|
Reads existing monthly file (if any), merges with new records, and uploads.
|
||||||
|
Uses temp table to ensure ROW_GROUP_SIZE is respected (DuckDB ignores it
|
||||||
|
when reading directly from S3 via COPY)."
|
||||||
|
(let [records (get @*buffers* entity-type [])]
|
||||||
|
(if (empty? records)
|
||||||
|
{:status :no-records}
|
||||||
|
(let [date-str (or date-str (.toString (LocalDate/now)))
|
||||||
|
s3-key (parquet-key entity-type date-str)
|
||||||
|
s3-url (s3-location s3-key)
|
||||||
|
jsonl-file (io/file "/tmp"
|
||||||
|
(str entity-type "-" date-str ".jsonl"))
|
||||||
|
parquet-file (io/file "/tmp"
|
||||||
|
(str entity-type "-" date-str ".parquet"))
|
||||||
|
tbl (format "\"_flush_%s_%s\""
|
||||||
|
(clojure.string/replace entity-type "-" "_")
|
||||||
|
(subs date-str 0 7))]
|
||||||
|
(try
|
||||||
|
(with-open [w (io/writer jsonl-file :append true)]
|
||||||
|
(doseq [r records]
|
||||||
|
(.write w ^String (json/write-str (dissoc r :_seq-no)))
|
||||||
|
(.write w (int \newline))))
|
||||||
|
(let [existing-sql (format
|
||||||
|
"SELECT * FROM read_parquet('%s', union_by_name=true)"
|
||||||
|
s3-url)
|
||||||
|
new-sql (format
|
||||||
|
"SELECT * FROM read_json_auto('%s')"
|
||||||
|
(.getAbsolutePath jsonl-file))]
|
||||||
|
(execute! (format "CREATE OR REPLACE TABLE %s AS SELECT * FROM (%s UNION ALL %s) ORDER BY \"client-code\", date"
|
||||||
|
tbl existing-sql new-sql))
|
||||||
|
(execute! (format "COPY (SELECT * FROM %s) TO '%s' (FORMAT PARQUET, OVERWRITE_OR_IGNORE, ROW_GROUP_SIZE 10000, COMPRESSION 'zstd')"
|
||||||
|
tbl (.getAbsolutePath parquet-file)))
|
||||||
|
(execute! (format "DROP TABLE IF EXISTS %s" tbl))
|
||||||
|
(upload-parquet! parquet-file s3-key)
|
||||||
|
(clear-buffer! entity-type)
|
||||||
|
(.delete ^java.io.File jsonl-file)
|
||||||
|
(.delete ^java.io.File parquet-file)
|
||||||
|
{:key s3-key :status :ok})
|
||||||
|
(catch Exception e
|
||||||
|
(execute! (format "DROP TABLE IF EXISTS %s" tbl))
|
||||||
|
(throw (ex-info "Flush failed"
|
||||||
|
{:entity-type entity-type
|
||||||
|
:error (.getMessage e)}))))))))
|
||||||
|
|
||||||
|
(defn flush-by-date! []
|
||||||
|
"Flush all entity types for today."
|
||||||
|
(let [etypes ["sales-order" "charge"
|
||||||
|
"line-item" "sales-refund"]
|
||||||
|
today (.toString (LocalDate/now))
|
||||||
|
flushed (into #{}
|
||||||
|
(keep (fn [et]
|
||||||
|
(let [{:keys [status]}
|
||||||
|
(flush-to-parquet! et today)]
|
||||||
|
(when (= status :ok)
|
||||||
|
et))))
|
||||||
|
etypes)]
|
||||||
|
{:flushed flushed}))
|
||||||
|
|
||||||
|
(defn load-unflushed! []
|
||||||
|
"Restore unflushed records from WAL jsonl files into *buffers."
|
||||||
|
(init-wal!)
|
||||||
|
(let [etypes ["sales-order" "charge"
|
||||||
|
"line-item" "sales-refund"]
|
||||||
|
loaded (reduce-kv
|
||||||
|
(fn [acc et data]
|
||||||
|
(if-not (empty? data)
|
||||||
|
(assoc acc et
|
||||||
|
(->> (str/split-lines data)
|
||||||
|
(keep #(try
|
||||||
|
(let [entry (json/read-str %)]
|
||||||
|
(when entry
|
||||||
|
(assoc (:record entry) :_seq-no (:seq-no entry))))
|
||||||
|
(catch Exception _)))))
|
||||||
|
acc))
|
||||||
|
{}
|
||||||
|
(into {}
|
||||||
|
(keep (fn [et]
|
||||||
|
(let [f (io/file
|
||||||
|
(wal-dir)
|
||||||
|
(str et ".jsonl"))]
|
||||||
|
(when (.exists f)
|
||||||
|
[et (slurp f)]))))
|
||||||
|
etypes))]
|
||||||
|
(swap! *buffers* merge loaded)))
|
||||||
|
|
||||||
|
(defn get-unflushed-count []
|
||||||
|
(total-buf-count))
|
||||||
|
|
||||||
|
(defn unflushed-records? []
|
||||||
|
(not= 0 (total-buf-count)))
|
||||||
|
|
||||||
|
;;; DuckDB Read Layer
|
||||||
|
|
||||||
|
(defn date-seq [start end]
|
||||||
|
"Seq of YYYY-MM-DD strings between start and end inclusive."
|
||||||
|
(let [sd (LocalDate/parse start)
|
||||||
|
ed (LocalDate/parse end)]
|
||||||
|
(when (.isAfter sd ed)
|
||||||
|
(throw (ex-info "date-seq: start must be <= end" {:start start :end end})))
|
||||||
|
(let [days (int (- (.toEpochDay ed)
|
||||||
|
(.toEpochDay sd)))]
|
||||||
|
(for [i (range 0 (inc days))]
|
||||||
|
(.toString (.plusDays sd i))))))
|
||||||
|
|
||||||
|
(defn today []
|
||||||
|
(.toString (LocalDate/now)))
|
||||||
|
|
||||||
|
(def ^:private mm-dd-yyyy (java.time.format.DateTimeFormatter/ofPattern "MM/dd/yyyy"))
|
||||||
|
|
||||||
|
(defn- normalize-date-str [s]
|
||||||
|
(when s
|
||||||
|
(if (re-find #"^\d{2}/\d{2}/\d{4}" s)
|
||||||
|
(.toString (LocalDate/parse s mm-dd-yyyy))
|
||||||
|
(if (> (count s) 10) (subs s 0 10) s))))
|
||||||
|
|
||||||
|
(defn month-seq [start-date end-date]
|
||||||
|
"Seq of YYYY-MM strings between start-date and end-date inclusive."
|
||||||
|
(let [sd (LocalDate/parse (normalize-date-str start-date))
|
||||||
|
ed (LocalDate/parse (normalize-date-str end-date))]
|
||||||
|
(loop [months [] cur sd]
|
||||||
|
(if (.isAfter cur ed)
|
||||||
|
months
|
||||||
|
(recur (conj months (.toString (.withDayOfMonth cur 1)))
|
||||||
|
(.plusMonths cur 1))))))
|
||||||
|
|
||||||
|
(defn- parquet-glob [entity-type start-date end-date]
|
||||||
|
"Build explicit file list for the date range using monthly partitions.
|
||||||
|
Monthly files mean only 1-3 files for typical queries, 12 for a full year."
|
||||||
|
(let [prefix (format "s3://%s/sales-details/%s/" *bucket* entity-type)]
|
||||||
|
(vec
|
||||||
|
(map (fn [m]
|
||||||
|
(format "'%s%s.parquet'" prefix m))
|
||||||
|
(month-seq start-date end-date)))))
|
||||||
|
|
||||||
|
(defn parquet-query [entity-type start-date end-date]
|
||||||
|
"Build SQL to read monthly parquet files in date range.
|
||||||
|
Uses explicit file list (monthly = few files) + WHERE date filter.
|
||||||
|
Normalizes date formats (handles MM/dd/yyyy from UI)."
|
||||||
|
(let [sd (normalize-date-str start-date)
|
||||||
|
ed (normalize-date-str end-date)
|
||||||
|
files (parquet-glob entity-type sd ed)
|
||||||
|
base (format "SELECT * FROM read_parquet([%s], union_by_name=true)"
|
||||||
|
(str/join ", " files))
|
||||||
|
sql (format "%s WHERE date >= '%s' AND date <= '%s'"
|
||||||
|
base sd ed)]
|
||||||
|
{:sql sql
|
||||||
|
:count-sql (format "SELECT COUNT(*) FROM (%s) t" sql)}))
|
||||||
|
|
||||||
|
(defn- like-clause [col v]
|
||||||
|
(str "\"" col "\" LIKE '%" v "%'"))
|
||||||
|
|
||||||
|
(defn- build-sales-orders-where [opts]
|
||||||
|
(let [eq-clauses (keep
|
||||||
|
(fn [[key col]]
|
||||||
|
(let [v (get opts key)]
|
||||||
|
(when v
|
||||||
|
(str "\"" col "\" = '" v "'"))))
|
||||||
|
[[:client "client-code"]
|
||||||
|
[:vendor "vendor"]
|
||||||
|
[:location "location"]])
|
||||||
|
in-clauses (keep
|
||||||
|
(fn [[key col]]
|
||||||
|
(let [vs (get opts key)]
|
||||||
|
(when (seq vs)
|
||||||
|
(str "\"" col "\" IN ("
|
||||||
|
(str/join ", " (map #(str "'" % "'") vs))
|
||||||
|
")"))))
|
||||||
|
[[:client-codes "client-code"]])
|
||||||
|
like-clauses (keep
|
||||||
|
(fn [[key col]]
|
||||||
|
(let [v (get opts key)]
|
||||||
|
(when v
|
||||||
|
(like-clause col v))))
|
||||||
|
[[:payment-method "payment-methods"]
|
||||||
|
[:processor "processors"]
|
||||||
|
[:category "categories"]])
|
||||||
|
range-clauses (keep
|
||||||
|
(fn [[key col op]]
|
||||||
|
(let [v (get opts key)]
|
||||||
|
(when v
|
||||||
|
(str "\"" col "\" " op " " v))))
|
||||||
|
[[:total-gte "total" ">="]
|
||||||
|
[:total-lte "total" "<="]])
|
||||||
|
all-clauses (concat eq-clauses in-clauses like-clauses range-clauses)]
|
||||||
|
(when (seq all-clauses)
|
||||||
|
(str/join " AND " all-clauses))))
|
||||||
|
|
||||||
|
(defn get-sales-orders
|
||||||
|
([start-date end-date]
|
||||||
|
(get-sales-orders start-date end-date {}))
|
||||||
|
([start-date end-date opts]
|
||||||
|
(mu/trace ::get-sales-orders
|
||||||
|
[:start-date start-date :end-date end-date :opts opts]
|
||||||
|
(try
|
||||||
|
(let [q (parquet-query "sales-order" start-date end-date)
|
||||||
|
base-sql (:sql q)
|
||||||
|
has-where? (str/includes? base-sql " WHERE ")
|
||||||
|
sort (get opts :sort "date")
|
||||||
|
order (get opts :order "DESC")
|
||||||
|
limit (get opts :limit)
|
||||||
|
offset (get opts :offset)
|
||||||
|
extra-clauses (build-sales-orders-where opts)
|
||||||
|
full-sql (if extra-clauses
|
||||||
|
(str base-sql (if has-where? " AND " " WHERE ") extra-clauses)
|
||||||
|
base-sql)
|
||||||
|
data-sql (cond-> full-sql
|
||||||
|
sort (str " ORDER BY " sort " " (name order))
|
||||||
|
limit (str " LIMIT " limit)
|
||||||
|
offset (str " OFFSET " offset))
|
||||||
|
count-sql (format "SELECT COUNT(*) FROM (%s) t" full-sql)]
|
||||||
|
(mu/log ::get-sales-orders :data-sql data-sql :count-sql count-sql)
|
||||||
|
(let [cnt (cached-count count-sql)
|
||||||
|
rows (query-rows data-sql)]
|
||||||
|
{:rows rows
|
||||||
|
:count (or (int cnt) 0)}))
|
||||||
|
(catch Exception e
|
||||||
|
(mu/log ::get-sales-orders :error e :start-date start-date :end-date end-date :opts opts)
|
||||||
|
(throw e))))))
|
||||||
|
|
||||||
|
(def ^:private summary-cache
|
||||||
|
(atom (-> (cache/ttl-cache-factory {} :ttl 1800000)
|
||||||
|
(cache/lru-cache-factory :threshold 256))))
|
||||||
|
|
||||||
|
(defn- cached-summary [sql]
|
||||||
|
(if-let [v (find @summary-cache sql)]
|
||||||
|
(do (mu/log ::summary-cache :hit true :sql (sql-snippet sql)) v)
|
||||||
|
(do (mu/log ::summary-cache :hit false :sql (sql-snippet sql))
|
||||||
|
(let [result (let [row (first (query-rows sql))]
|
||||||
|
{:total (or (:total row) 0.0)
|
||||||
|
:tax (or (:tax row) 0.0)})]
|
||||||
|
(swap! summary-cache assoc sql result)
|
||||||
|
result))))
|
||||||
|
|
||||||
|
(defn get-sales-orders-summary
|
||||||
|
([start-date end-date]
|
||||||
|
(get-sales-orders-summary start-date end-date {}))
|
||||||
|
([start-date end-date opts]
|
||||||
|
(mu/trace ::get-sales-orders-summary
|
||||||
|
[:start-date start-date :end-date end-date :opts opts]
|
||||||
|
(try
|
||||||
|
(let [q (parquet-query "sales-order" start-date end-date)
|
||||||
|
base-sql (:sql q)
|
||||||
|
has-where? (str/includes? base-sql " WHERE ")
|
||||||
|
extra-clauses (build-sales-orders-where opts)
|
||||||
|
full-sql (if extra-clauses
|
||||||
|
(str base-sql (if has-where? " AND " " WHERE ") extra-clauses)
|
||||||
|
base-sql)
|
||||||
|
sum-sql (format "SELECT COALESCE(SUM(total), 0) as total, COALESCE(SUM(tax), 0) as tax FROM (%s) t" full-sql)]
|
||||||
|
(mu/log ::get-sales-orders-summary :sum-sql sum-sql)
|
||||||
|
(cached-summary sum-sql))
|
||||||
|
(catch Exception e
|
||||||
|
(mu/log ::get-sales-orders-summary :error e :start-date start-date :end-date end-date :opts opts)
|
||||||
|
(throw e))))))
|
||||||
|
|
||||||
|
(defn query-deduped [entity-type start-date end-date]
|
||||||
|
"Query records deduplicated by external-id (latest _seq_no wins)."
|
||||||
|
(let [q (parquet-query entity-type start-date end-date)]
|
||||||
|
(query-rows
|
||||||
|
(str (:sql q)
|
||||||
|
" QUALIFY ROW_NUMBER() OVER"
|
||||||
|
" (PARTITION BY \"external-id\""
|
||||||
|
" ORDER BY _seq_no DESC) = 1"))))
|
||||||
|
|
||||||
|
(defn query-by-entity-id [entity-type external-id
|
||||||
|
start-date end-date]
|
||||||
|
(->> (query-deduped entity-type start-date end-date)
|
||||||
|
(filter #(= (:external_id %)
|
||||||
|
(name external-id)))
|
||||||
|
first))
|
||||||
|
|
||||||
|
(defn count-records-in-parquet
|
||||||
|
[entity-type start-date end-date]
|
||||||
|
(let [q (parquet-query entity-type
|
||||||
|
start-date end-date)]
|
||||||
|
(or (int (query-scalar (:count-sql q))) 0)))
|
||||||
184
src/clj/auto_ap/storage/sales_summaries.clj
Normal file
184
src/clj/auto_ap/storage/sales_summaries.clj
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
(ns auto-ap.storage.sales-summaries
|
||||||
|
"Aggregation functions querying Parquet files on S3 via DuckDB.
|
||||||
|
Entity types: sales-order | charge | line-item | sales-refund
|
||||||
|
S3 pattern: s3://<bucket>/sales-details/<entity-type>/<YYYY-MM-DD>.parquet"
|
||||||
|
(:require [auto-ap.storage.parquet :as p]
|
||||||
|
[clojure.string :as str]))
|
||||||
|
|
||||||
|
(defn- dq [name]
|
||||||
|
(str "\"" name "\""))
|
||||||
|
|
||||||
|
(defn- sum-dbl [val]
|
||||||
|
(try
|
||||||
|
(if val (double val) 0.0)
|
||||||
|
(catch Exception _e
|
||||||
|
0.0)))
|
||||||
|
|
||||||
|
(defn- pq-files [entity-type start-date end-date]
|
||||||
|
"Vector of S3 parquet file paths for date range (monthly partitions)."
|
||||||
|
(let [months (p/month-seq start-date end-date)]
|
||||||
|
(vec
|
||||||
|
(map #(str "'s3://" p/*bucket*
|
||||||
|
"/sales-details/" entity-type "/"
|
||||||
|
% ".parquet") months))))
|
||||||
|
|
||||||
|
(defn sum-payments-by-type [client-id start-date end-date]
|
||||||
|
"Return {processor-key -> {type-name-string -> total-double}}."
|
||||||
|
(let [files (pq-files "charge" start-date end-date)]
|
||||||
|
(try
|
||||||
|
(let [sql (str "SELECT "
|
||||||
|
(dq "processor")
|
||||||
|
" AS proc, "
|
||||||
|
(dq "type-name")
|
||||||
|
" AS type_name, "
|
||||||
|
"SUM("
|
||||||
|
(dq "total")
|
||||||
|
")::DOUBLE AS total_amount "
|
||||||
|
"FROM read_parquet(["
|
||||||
|
(str/join ", " files)
|
||||||
|
"]) "
|
||||||
|
"WHERE "
|
||||||
|
(dq "client-code")
|
||||||
|
" = '" client-id "' "
|
||||||
|
"GROUP BY "
|
||||||
|
(dq "processor") ", "
|
||||||
|
(dq "type-name"))]
|
||||||
|
(let [rows (p/query-rows sql)]
|
||||||
|
(reduce (fn [acc row]
|
||||||
|
(let [proc (:proc row)
|
||||||
|
tname (str/trim (name (:type_name row)))
|
||||||
|
total (sum-dbl (:total_amount row))]
|
||||||
|
(update acc proc
|
||||||
|
(fn [inner]
|
||||||
|
(let [b (or inner {})]
|
||||||
|
(assoc b
|
||||||
|
tname
|
||||||
|
(+ (get b tname 0.0) total)))))))
|
||||||
|
{}
|
||||||
|
rows)))
|
||||||
|
(catch Exception e
|
||||||
|
(println "[sales-summaries]" (.getMessage e))
|
||||||
|
{}))))
|
||||||
|
|
||||||
|
(defn sum-discounts [client-id start-date end-date]
|
||||||
|
(let [files (pq-files "sales-order" start-date end-date)]
|
||||||
|
(try
|
||||||
|
(let [sql (str "SELECT SUM("
|
||||||
|
(dq "discount")
|
||||||
|
")::DOUBLE AS discount_total "
|
||||||
|
"FROM read_parquet(["
|
||||||
|
(str/join ", " files)
|
||||||
|
"]) "
|
||||||
|
"WHERE "
|
||||||
|
(dq "client-code")
|
||||||
|
" = '" client-id "'")]
|
||||||
|
(or (some-> (first (p/query-rows sql)) :discount_total sum-dbl) 0.0))
|
||||||
|
(catch Exception e
|
||||||
|
(println "[sales-summaries/discounts]" (.getMessage e))
|
||||||
|
0.0))))
|
||||||
|
|
||||||
|
(defn sum-refunds-by-type [client-id start-date end-date]
|
||||||
|
(let [files (pq-files "sales-refund" start-date end-date)]
|
||||||
|
(try
|
||||||
|
(let [sql (str "SELECT "
|
||||||
|
(dq "type-name")
|
||||||
|
" AS type_name, "
|
||||||
|
"SUM("
|
||||||
|
(dq "total")
|
||||||
|
")::DOUBLE AS total_amount "
|
||||||
|
"FROM read_parquet(["
|
||||||
|
(str/join ", " files)
|
||||||
|
"]) "
|
||||||
|
"WHERE "
|
||||||
|
(dq "sales-order-external-id")
|
||||||
|
" IN (SELECT "
|
||||||
|
(dq "external-id")
|
||||||
|
" FROM read_parquet(["
|
||||||
|
(str/join ", " (pq-files "sales-order" start-date end-date))
|
||||||
|
"]) WHERE "
|
||||||
|
(dq "client-code")
|
||||||
|
" = '" client-id "') "
|
||||||
|
"GROUP BY " (dq "type-name"))]
|
||||||
|
(let [rows (p/query-rows sql)]
|
||||||
|
(reduce (fn [acc row]
|
||||||
|
(let [tname (str/trim (name (:type_name row)))
|
||||||
|
total (sum-dbl (:total_amount row))]
|
||||||
|
(assoc acc tname (+ (get acc tname 0.0) total))))
|
||||||
|
{}
|
||||||
|
rows)))
|
||||||
|
(catch Exception e
|
||||||
|
(println "[sales-summaries/refunds]" (.getMessage e))
|
||||||
|
{}))))
|
||||||
|
|
||||||
|
(defn sum-taxes [client-id start-date end-date]
|
||||||
|
(let [files (pq-files "sales-order" start-date end-date)]
|
||||||
|
(try
|
||||||
|
(let [sql (str "SELECT SUM("
|
||||||
|
(dq "tax")
|
||||||
|
")::DOUBLE AS tax_total "
|
||||||
|
"FROM read_parquet(["
|
||||||
|
(str/join ", " files)
|
||||||
|
"]) "
|
||||||
|
"WHERE "
|
||||||
|
(dq "client-code")
|
||||||
|
" = '" client-id "'")]
|
||||||
|
(or (some-> (first (p/query-rows sql)) :tax_total sum-dbl) 0.0))
|
||||||
|
(catch Exception e
|
||||||
|
(println "[sales-summaries/tax]" (.getMessage e))
|
||||||
|
0.0))))
|
||||||
|
|
||||||
|
(defn sum-tips [client-id start-date end-date]
|
||||||
|
(let [files (pq-files "sales-order" start-date end-date)]
|
||||||
|
(try
|
||||||
|
(let [sql (str "SELECT SUM("
|
||||||
|
(dq "tip")
|
||||||
|
")::DOUBLE AS tip_total "
|
||||||
|
"FROM read_parquet(["
|
||||||
|
(str/join ", " files)
|
||||||
|
"]) "
|
||||||
|
"WHERE "
|
||||||
|
(dq "client-code")
|
||||||
|
" = '" client-id "'")]
|
||||||
|
(or (some-> (first (p/query-rows sql)) :tip_total sum-dbl) 0.0))
|
||||||
|
(catch Exception e
|
||||||
|
(println "[sales-summaries/tip]" (.getMessage e))
|
||||||
|
0.0))))
|
||||||
|
|
||||||
|
(defn sum-sales-by-category [client-id start-date end-date]
|
||||||
|
(let [files (pq-files "line-item" start-date end-date)]
|
||||||
|
(try
|
||||||
|
(let [sql (str "SELECT "
|
||||||
|
(dq "category")
|
||||||
|
" AS category, "
|
||||||
|
"SUM("
|
||||||
|
(dq "total")
|
||||||
|
")::DOUBLE AS total_amount, "
|
||||||
|
"SUM("
|
||||||
|
(dq "tax")
|
||||||
|
")::DOUBLE AS tax_amount, "
|
||||||
|
"SUM("
|
||||||
|
(dq "discount")
|
||||||
|
")::DOUBLE AS discount_amount "
|
||||||
|
"FROM read_parquet(["
|
||||||
|
(str/join ", " files)
|
||||||
|
"]) "
|
||||||
|
"WHERE "
|
||||||
|
(dq "sales-order-external-id")
|
||||||
|
" IN (SELECT "
|
||||||
|
(dq "external-id")
|
||||||
|
" FROM read_parquet(["
|
||||||
|
(str/join ", " (pq-files "sales-order" start-date end-date))
|
||||||
|
"]) WHERE "
|
||||||
|
(dq "client-code")
|
||||||
|
" = '" client-id "') "
|
||||||
|
"GROUP BY " (dq "category"))]
|
||||||
|
(let [rows (p/query-rows sql)]
|
||||||
|
(mapv (fn [row]
|
||||||
|
{:category (or (:category row) "Unknown")
|
||||||
|
:total (sum-dbl (:total_amount row))
|
||||||
|
:tax (sum-dbl (:tax_amount row))
|
||||||
|
:discount (sum-dbl (:discount_amount row))})
|
||||||
|
rows)))
|
||||||
|
(catch Exception e
|
||||||
|
(println "[sales-summaries/sales]" (.getMessage e))
|
||||||
|
[]))))
|
||||||
72
test/clj/auto_ap/parse/templates_test.clj
Normal file
72
test/clj/auto_ap/parse/templates_test.clj
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
(ns auto-ap.parse.templates-test
|
||||||
|
(:require [auto-ap.parse :as sut]
|
||||||
|
[clojure.test :refer [deftest is testing]]
|
||||||
|
[clojure.java.io :as io]
|
||||||
|
[clojure.string :as str]
|
||||||
|
[clj-time.core :as time]))
|
||||||
|
|
||||||
|
(deftest parse-bonanza-produce-invoice-03881260
|
||||||
|
(testing "Should parse Bonanza Produce invoice 03881260 with customer identifier including address"
|
||||||
|
(let [pdf-file (io/file "dev-resources/INVOICE - 03881260.pdf")
|
||||||
|
;; Extract text same way parse-file does
|
||||||
|
pdf-text (:out (clojure.java.shell/sh "pdftotext" "-layout" (str pdf-file) "-"))
|
||||||
|
results (sut/parse pdf-text)
|
||||||
|
result (first results)]
|
||||||
|
(is (some? results) "parse should return a result")
|
||||||
|
(is (some? result) "Template should match and return a result")
|
||||||
|
(when result
|
||||||
|
(println "DEBUG: customer-identifier =" (pr-str (:customer-identifier result)))
|
||||||
|
(println "DEBUG: account-number =" (pr-str (:account-number result)))
|
||||||
|
(is (= "Bonanza Produce" (:vendor-code result)))
|
||||||
|
(is (= "03881260" (:invoice-number result)))
|
||||||
|
;; Date is parsed as org.joda.time.DateTime - compare year/month/day
|
||||||
|
(let [d (:date result)]
|
||||||
|
(is (= 2026 (time/year d)))
|
||||||
|
(is (= 1 (time/month d)))
|
||||||
|
(is (= 20 (time/day d))))
|
||||||
|
;; Customer identifier includes name, account-number includes street address
|
||||||
|
;; Together they form the full customer identification
|
||||||
|
(is (= "NICK THE GREEK" (:customer-identifier result)))
|
||||||
|
(is (= "600 VISTA WAY" (str/trim (:account-number result))))
|
||||||
|
(is (= "NICK THE GREEK 600 VISTA WAY"
|
||||||
|
(str (:customer-identifier result) " " (str/trim (:account-number result)))))
|
||||||
|
;; Total is parsed as string, not number (per current behavior)
|
||||||
|
(is (= "23.22" (:total result)))))))
|
||||||
|
|
||||||
|
(deftest parse-bonanza-produce-statement-13595522
|
||||||
|
(testing "Should parse Bonanza Produce statement 13595522 with multiple invoices"
|
||||||
|
(let [pdf-file (io/file "dev-resources/13595522.pdf")
|
||||||
|
pdf-text (:out (clojure.java.shell/sh "pdftotext" "-layout" (str pdf-file) "-"))
|
||||||
|
results (sut/parse pdf-text)]
|
||||||
|
(is (some? results) "parse should return results")
|
||||||
|
(is (= 4 (count results)) "Should parse 4 invoices from statement")
|
||||||
|
(doseq [result results]
|
||||||
|
(is (= "Bonanza Produce" (:vendor-code result)))
|
||||||
|
(is (= "600 VISTA WAY" (:customer-identifier result))))
|
||||||
|
(is (= "03876838" (:invoice-number (nth results 0))))
|
||||||
|
(is (= "03877314" (:invoice-number (nth results 1))))
|
||||||
|
(is (= "03878619" (:invoice-number (nth results 2))))
|
||||||
|
(is (= "03879035" (:invoice-number (nth results 3))))
|
||||||
|
(is (= "891.65" (:total (nth results 0))))
|
||||||
|
(is (= "720.33" (:total (nth results 1))))
|
||||||
|
(is (= "853.16" (:total (nth results 2))))
|
||||||
|
(is (= "1066.60" (:total (nth results 3)))))))
|
||||||
|
|
||||||
|
(deftest parse-bonanza-produce-invoice-03882095
|
||||||
|
(testing "Should parse Bonanza Produce invoice 03882095 with customer identifier including address"
|
||||||
|
(let [pdf-file (io/file "dev-resources/INVOICE - 03882095.pdf")
|
||||||
|
pdf-text (:out (clojure.java.shell/sh "pdftotext" "-layout" (str pdf-file) "-"))
|
||||||
|
results (sut/parse pdf-text)
|
||||||
|
result (first results)]
|
||||||
|
(is (some? results) "parse should return a result")
|
||||||
|
(is (some? result) "Template should match and return a result")
|
||||||
|
(when result
|
||||||
|
(is (= "Bonanza Produce" (:vendor-code result)))
|
||||||
|
(is (= "03882095" (:invoice-number result)))
|
||||||
|
(let [d (:date result)]
|
||||||
|
(is (= 2026 (time/year d)))
|
||||||
|
(is (= 1 (time/month d)))
|
||||||
|
(is (= 23 (time/day d))))
|
||||||
|
(is (= "NICK THE GREEK" (:customer-identifier result)))
|
||||||
|
(is (= "600 VISTA WAY" (str/trim (:account-number result))))
|
||||||
|
(is (= "946.24" (:total result)))))))
|
||||||
30
test/clj/auto_ap/storage/parquet_test.clj
Normal file
30
test/clj/auto_ap/storage/parquet_test.clj
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
(ns auto-ap.storage.parquet-test
|
||||||
|
(:require [auto-ap.storage.parquet :as p]
|
||||||
|
[clojure.test :refer [deftest is testing use-fixtures]]))
|
||||||
|
|
||||||
|
(deftest test-query-scalar
|
||||||
|
(testing "SELECT 1 returns 1"
|
||||||
|
(is (= 1 (p/query-scalar "SELECT 1")))))
|
||||||
|
|
||||||
|
(deftest test-query-scalar-with-expression
|
||||||
|
(testing "SELECT 2 + 2 returns 4"
|
||||||
|
(is (= 4 (p/query-scalar "SELECT 2 + 2")))))
|
||||||
|
|
||||||
|
(deftest test-buffer
|
||||||
|
(testing "buffer! adds record to buffer"
|
||||||
|
(p/clear-buffer! "test-type")
|
||||||
|
(p/buffer! "test-type" {:id 1 :name "test"})
|
||||||
|
(is (= 1 (p/buffer-count "test-type")))))
|
||||||
|
|
||||||
|
(deftest test-clear-buffer
|
||||||
|
(testing "clear-buffer! empties buffer"
|
||||||
|
(p/clear-buffer! "test-type")
|
||||||
|
(p/buffer! "test-type" {:id 2})
|
||||||
|
(is (= 1 (p/buffer-count "test-type")))
|
||||||
|
(p/clear-buffer! "test-type")
|
||||||
|
(is (= 0 (p/buffer-count "test-type")))))
|
||||||
|
|
||||||
|
(deftest test-date-seq
|
||||||
|
(testing "date-seq generates correct sequence"
|
||||||
|
(let [result (p/date-seq "2024-04-01" "2024-04-03")]
|
||||||
|
(is (= ["2024-04-01" "2024-04-02" "2024-04-03"] result)))))
|
||||||
113
test/clj/auto_ap/storage/perf_test.clj
Normal file
113
test/clj/auto_ap/storage/perf_test.clj
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
(ns auto-ap.storage.perf-test
|
||||||
|
(:require [auto-ap.storage.parquet :as p]
|
||||||
|
[amazonica.aws.s3 :as s3]
|
||||||
|
[clojure.java.io :as io]
|
||||||
|
[clojure.string :as str])
|
||||||
|
(:import (java.sql DriverManager)
|
||||||
|
(java.time Instant)))
|
||||||
|
|
||||||
|
(defn timestamp []
|
||||||
|
(System/currentTimeMillis))
|
||||||
|
|
||||||
|
(defn timed [label sql-fn]
|
||||||
|
(let [start (timestamp)
|
||||||
|
result (sql-fn)
|
||||||
|
elapsed (- (timestamp) start)]
|
||||||
|
(println (format "%s: %d ms" label elapsed))
|
||||||
|
result))
|
||||||
|
|
||||||
|
(defn run-perf-tests []
|
||||||
|
(p/connect!)
|
||||||
|
(try
|
||||||
|
(let [bucket "data.dev.app.integreatconsult.com"
|
||||||
|
prefix "test-duckdb"
|
||||||
|
local-parquet "/tmp/test_data.parquet"
|
||||||
|
s3-key (str prefix "/data.parquet")]
|
||||||
|
|
||||||
|
;; Create 100k test rows
|
||||||
|
(println "\n=== Creating 100k test rows ===")
|
||||||
|
(p/execute! "DROP TABLE IF EXISTS test_data")
|
||||||
|
(p/execute! (str "
|
||||||
|
CREATE TABLE test_data AS
|
||||||
|
SELECT
|
||||||
|
i AS id,
|
||||||
|
'order_' || i AS external_id,
|
||||||
|
CASE (i % 5)
|
||||||
|
WHEN 0 THEN 'north'
|
||||||
|
WHEN 1 THEN 'south'
|
||||||
|
WHEN 2 THEN 'east'
|
||||||
|
WHEN 3 THEN 'west'
|
||||||
|
ELSE 'central'
|
||||||
|
END AS region,
|
||||||
|
CASE (i % 8)
|
||||||
|
WHEN 0 THEN 'food'
|
||||||
|
WHEN 1 THEN 'beverage'
|
||||||
|
WHEN 2 THEN 'alcohol'
|
||||||
|
WHEN 3 THEN 'catering'
|
||||||
|
WHEN 4 THEN 'retail'
|
||||||
|
WHEN 5 THEN 'dessert'
|
||||||
|
WHEN 6 THEN 'merch'
|
||||||
|
ELSE 'other'
|
||||||
|
END AS category,
|
||||||
|
ROUND(1 + ABS(RANDOM() % 10000) / 100.0, 2) AS amount,
|
||||||
|
CAST(DATE '2024-01-01' + (i % 365) * INTERVAL '1 day' AS DATE) AS sale_date,
|
||||||
|
CASE WHEN i % 20 = 0 THEN 'voided' ELSE 'active' END AS status
|
||||||
|
FROM generate_series(1, 100000) AS t(i)"))
|
||||||
|
(println "Row count:" (p/query-scalar "SELECT COUNT(*) FROM test_data"))
|
||||||
|
(println "Voided count:" (p/query-scalar "SELECT COUNT(*) FROM test_data WHERE status = 'voided'"))
|
||||||
|
(println "Amount > 3 count:" (p/query-scalar "SELECT COUNT(*) FROM test_data WHERE amount > 3"))
|
||||||
|
|
||||||
|
;; Write to local parquet
|
||||||
|
(println "\n=== Writing local parquet ===")
|
||||||
|
(timed "Write parquet" #(p/execute-to-parquet! "SELECT * FROM test_data" local-parquet))
|
||||||
|
(let [f (io/file local-parquet)]
|
||||||
|
(println "File size:" (format "%.1f MB" (/ (.length f) 1048576.0))))
|
||||||
|
|
||||||
|
;; Upload to S3
|
||||||
|
(println "\n=== Uploading to S3 ===")
|
||||||
|
(timed "S3 upload" #(p/upload-parquet! (io/file local-parquet) prefix))
|
||||||
|
(println "S3 URI:" (p/s3-location s3-key))
|
||||||
|
|
||||||
|
;; Now test reading from S3
|
||||||
|
(println "\n=== Performance Tests (reading from S3) ===")
|
||||||
|
(let [s3-uri (str "s3://" bucket "/" s3-key)]
|
||||||
|
|
||||||
|
;; Register S3 parquet as a view/table in DuckDB
|
||||||
|
(p/execute! (format "CREATE VIEW s3_test AS SELECT * FROM read_parquet('%s')" s3-uri))
|
||||||
|
(println "Total rows in S3:" (p/query-scalar "SELECT COUNT(*) FROM s3_test"))
|
||||||
|
|
||||||
|
;; Test 1: Page 1 - first 25 rows
|
||||||
|
(println "\n--- Test 1: Page 1 (LIMIT 25 OFFSET 0) ---")
|
||||||
|
(timed "First page (25 rows)" #(p/query-rows "SELECT * FROM s3_test ORDER BY id LIMIT 25"))
|
||||||
|
(println "Sample row:" (first (p/query-rows "SELECT * FROM s3_test ORDER BY id LIMIT 1")))
|
||||||
|
|
||||||
|
;; Test 2: Page 20 - rows 475-500 (OFFSET 475)
|
||||||
|
(println "\n--- Test 2: Page 20 (LIMIT 25 OFFSET 475) ---")
|
||||||
|
(timed "Page 20 (25 rows)" #(p/query-rows "SELECT * FROM s3_test ORDER BY id LIMIT 25 OFFSET 475"))
|
||||||
|
|
||||||
|
;; Test 3: Filter amount > 3 (no pagination)
|
||||||
|
(println "\n--- Test 3: Filter amount > 3 (no limit) ---")
|
||||||
|
(timed "Filter amount > 3 (all)" #(do (p/query-scalar "SELECT COUNT(*) FROM s3_test WHERE amount > 3") :done))
|
||||||
|
|
||||||
|
;; Test 4: Filter + pagination
|
||||||
|
(println "\n--- Test 4: Filter amount > 3 + LIMIT 25 ---")
|
||||||
|
(timed "Filter + paginated (25 rows)" #(p/query-rows "SELECT * FROM s3_test WHERE amount > 3 ORDER BY id LIMIT 25"))
|
||||||
|
|
||||||
|
;; Test 5: Filter + page 20
|
||||||
|
(println "\n--- Test 5: Filter amount > 3 + LIMIT 25 OFFSET 475 ---")
|
||||||
|
(timed "Filter + page 20" #(p/query-rows "SELECT * FROM s3_test WHERE amount > 3 ORDER BY id LIMIT 25 OFFSET 475"))
|
||||||
|
|
||||||
|
;; Test 6: Aggregation on S3 data
|
||||||
|
(println "\n--- Test 6: Aggregation (SUM, AVG on amount) ---")
|
||||||
|
(timed "Aggregation SUM/AVG" #(p/query-scalar "SELECT SUM(amount), AVG(amount) FROM s3_test WHERE status = 'active'"))
|
||||||
|
|
||||||
|
;; Cleanup
|
||||||
|
(p/execute! "DROP VIEW IF EXISTS s3_test")
|
||||||
|
(p/execute! "DROP TABLE IF EXISTS test_data"))
|
||||||
|
|
||||||
|
(finally
|
||||||
|
(p/disconnect!))))
|
||||||
|
|
||||||
|
(comment
|
||||||
|
(run-perf-tests)
|
||||||
|
(println "\n=== Done ==="))
|
||||||
Reference in New Issue
Block a user