Sagar Patel commited on
Commit ·
aef5cd6
1
Parent(s): 28b2a53
Add seller depth and action polish
Browse files- README.md +23 -10
- docs/demo-video-script.md +6 -2
- docs/field-notes.md +3 -1
- docs/social-post.md +3 -0
- docs/submission-checklist.md +6 -0
- tests/test_parser.py +50 -0
- tests/test_settings.py +30 -0
- tests/test_ui_audio_flow.py +44 -2
- voiceledger/ledger/__init__.py +4 -0
- voiceledger/ledger/database.py +2 -0
- voiceledger/ledger/settings.py +123 -0
- voiceledger/parser/rules.py +78 -4
- voiceledger/reports/__init__.py +3 -0
- voiceledger/reports/actions.py +83 -0
- voiceledger/reports/pdf_report.py +21 -13
- voiceledger/reports/whatsapp_summary.py +10 -8
- voiceledger/ui/gradio_app.py +362 -44
- voiceledger/ui/theme.py +73 -6
README.md
CHANGED
|
@@ -18,18 +18,21 @@ VoiceLedger is a voice-first bookkeeping app for informal sellers, street vendor
|
|
| 18 |
The submission version is feature-frozen around the complete demo loop:
|
| 19 |
|
| 20 |
- Record a transaction with your microphone and transcribe it with faster-whisper.
|
|
|
|
| 21 |
- Type or paste a transaction note.
|
| 22 |
- Bulk import multiple pasted notes for review and editing.
|
| 23 |
- Parse it with Modal-hosted NVIDIA Nemotron when configured, with local rules as a deterministic fallback.
|
|
|
|
| 24 |
- Review a human-friendly transaction card with warning badges before saving.
|
| 25 |
- Save the structured transaction to SQLite.
|
| 26 |
- See a “Saved just now” receipt with bookkeeping side effects.
|
| 27 |
- View the ledger, customer credit book, and inventory in a Gradio interface.
|
| 28 |
-
- Monitor business insights
|
| 29 |
- Use a mobile-first, business-style Gradio interface with custom styling.
|
| 30 |
- Run a Daily Closeout that prepares PDF, WhatsApp summary, and CSV exports together.
|
| 31 |
- Download a Daily Summary PDF report.
|
| 32 |
- Generate a WhatsApp-ready daily business summary.
|
|
|
|
| 33 |
- Offload speech transcription and LLM parsing to optional Modal endpoints.
|
| 34 |
- Edit or delete saved transactions while keeping customer credit and inventory balances consistent.
|
| 35 |
- Export the ledger as CSV for spreadsheet sharing.
|
|
@@ -58,6 +61,8 @@ Sold 12 mangoes, 20 each
|
|
| 58 |
Paid 500 for supplies
|
| 59 |
Amit owes 100
|
| 60 |
Bought 50 mangoes
|
|
|
|
|
|
|
| 61 |
```
|
| 62 |
|
| 63 |
The same examples are available in `sample_data/demo_transactions.txt`.
|
|
@@ -73,24 +78,29 @@ The same examples are available in `sample_data/demo_transactions.txt`.
|
|
| 73 |
| `Bought 50 mangoes` | Inventory purchase, quantity `50`, item `mangoes` |
|
| 74 |
| `Amit owes 100` | Customer credit, customer `Amit`, amount `100` |
|
| 75 |
| `Amit paid 50` | Customer payment, customer `Amit`, amount `50` |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
## Demo Script
|
| 78 |
|
| 79 |
-
1. Use
|
| 80 |
-
2.
|
| 81 |
-
3.
|
| 82 |
-
4.
|
| 83 |
-
5.
|
| 84 |
-
6. Open `
|
| 85 |
-
7.
|
| 86 |
-
8.
|
|
|
|
| 87 |
|
| 88 |
## Screenshot and GIF Moments
|
| 89 |
|
| 90 |
Capture these three moments for the Space README, demo video, or social post:
|
| 91 |
|
| 92 |
- `Record Text & Voice`: Today’s Work quick actions, review card, warning badges, and save receipt.
|
| 93 |
-
- `Dashboard`: metrics, sales/expense timeline, top-selling item, low-stock table, and outstanding credit.
|
| 94 |
- `Reports & PDF` plus `Ledger`: Daily Closeout, PDF/WhatsApp export, CSV download, and ledger correction.
|
| 95 |
- `Submission Story`: AI pipeline strip and “Why small models fit” card for the Modal/Nemotron story.
|
| 96 |
|
|
@@ -163,6 +173,9 @@ curl -X POST https://sagarpat3199--voiceledger-api.modal.run/parse \
|
|
| 163 |
- The dashboard shows daily sales, expenses, profit, outstanding credit, top sellers, and low-stock alerts from saved data.
|
| 164 |
- Customer and inventory detail views show transaction history for one customer or item.
|
| 165 |
- Smart review warnings flag low confidence, missing fields, duplicate risk, and negative stock before save.
|
|
|
|
|
|
|
|
|
|
| 166 |
- WhatsApp summaries provide a short copyable daily recap for sharing.
|
| 167 |
- Bulk import splits pasted notes by line, parses each line, supports review edits, and saves all reviewed transactions.
|
| 168 |
- Modal integration lives in `backend/`; if endpoint URLs are not configured, local fallback stays active.
|
|
|
|
| 18 |
The submission version is feature-frozen around the complete demo loop:
|
| 19 |
|
| 20 |
- Record a transaction with your microphone and transcribe it with faster-whisper.
|
| 21 |
+
- Configure seller context: business name, currency label, low-stock threshold, and language style.
|
| 22 |
- Type or paste a transaction note.
|
| 23 |
- Bulk import multiple pasted notes for review and editing.
|
| 24 |
- Parse it with Modal-hosted NVIDIA Nemotron when configured, with local rules as a deterministic fallback.
|
| 25 |
+
- Parse common English, Hinglish, Hindi-lite, and Gujarati-lite seller phrases with deterministic fallback rules.
|
| 26 |
- Review a human-friendly transaction card with warning badges before saving.
|
| 27 |
- Save the structured transaction to SQLite.
|
| 28 |
- See a “Saved just now” receipt with bookkeeping side effects.
|
| 29 |
- View the ledger, customer credit book, and inventory in a Gradio interface.
|
| 30 |
+
- Monitor business insights, a Command Center, a seller-day activity timeline, and a daily sales/expense chart.
|
| 31 |
- Use a mobile-first, business-style Gradio interface with custom styling.
|
| 32 |
- Run a Daily Closeout that prepares PDF, WhatsApp summary, and CSV exports together.
|
| 33 |
- Download a Daily Summary PDF report.
|
| 34 |
- Generate a WhatsApp-ready daily business summary.
|
| 35 |
+
- Generate customer follow-up reminders and an inventory reorder list.
|
| 36 |
- Offload speech transcription and LLM parsing to optional Modal endpoints.
|
| 37 |
- Edit or delete saved transactions while keeping customer credit and inventory balances consistent.
|
| 38 |
- Export the ledger as CSV for spreadsheet sharing.
|
|
|
|
| 61 |
Paid 500 for supplies
|
| 62 |
Amit owes 100
|
| 63 |
Bought 50 mangoes
|
| 64 |
+
Amit ne 100 dene hai
|
| 65 |
+
50 mango kharida
|
| 66 |
```
|
| 67 |
|
| 68 |
The same examples are available in `sample_data/demo_transactions.txt`.
|
|
|
|
| 78 |
| `Bought 50 mangoes` | Inventory purchase, quantity `50`, item `mangoes` |
|
| 79 |
| `Amit owes 100` | Customer credit, customer `Amit`, amount `100` |
|
| 80 |
| `Amit paid 50` | Customer payment, customer `Amit`, amount `50` |
|
| 81 |
+
| `Amit ne 100 dene hai` | Customer credit, customer `Amit`, amount `100` |
|
| 82 |
+
| `Amit ne 50 diya` | Customer payment, customer `Amit`, amount `50` |
|
| 83 |
+
| `50 mango kharida` | Inventory purchase, quantity `50`, item `mango` |
|
| 84 |
+
| `50 mango lidha` | Inventory purchase, quantity `50`, item `mango` |
|
| 85 |
|
| 86 |
## Demo Script
|
| 87 |
|
| 88 |
+
1. Use `Seller Setup` to show business name, currency, low-stock threshold, and language style.
|
| 89 |
+
2. Use the `Hackathon Demo Launchpad` on the first screen to seed demo transactions.
|
| 90 |
+
3. Open `Record Text & Voice`, speak or type `Sold 12 mangoes, 20 each`, parse it, and review the human-readable transaction card.
|
| 91 |
+
4. Show the parse status and warning badges: Modal/NVIDIA Nemotron when available, local fallback when needed.
|
| 92 |
+
5. Save the transaction and show the Command Center plus the receipt with stock, customer, or amount side effects.
|
| 93 |
+
6. Open `Dashboard`, `Customer Credit`, and `Inventory` to show timeline charts, customer detail, follow-up message, inventory detail, reorder list, and automatic bookkeeping updates.
|
| 94 |
+
7. Open `Ledger`, load a transaction by id, update or delete it, then show refreshed balances.
|
| 95 |
+
8. Run Daily Closeout from `Reports & PDF`, then download CSV/PDF and generate the WhatsApp summary.
|
| 96 |
+
9. Click `Check Demo Health` from the launchpad, or open `Demo Health`, to show Modal health, deployed backend version, Nemotron status, database, PDF, and endpoint checks.
|
| 97 |
|
| 98 |
## Screenshot and GIF Moments
|
| 99 |
|
| 100 |
Capture these three moments for the Space README, demo video, or social post:
|
| 101 |
|
| 102 |
- `Record Text & Voice`: Today’s Work quick actions, review card, warning badges, and save receipt.
|
| 103 |
+
- `Dashboard`: Command Center, metrics, seller-day activity timeline, sales/expense timeline, top-selling item, low-stock table, and outstanding credit.
|
| 104 |
- `Reports & PDF` plus `Ledger`: Daily Closeout, PDF/WhatsApp export, CSV download, and ledger correction.
|
| 105 |
- `Submission Story`: AI pipeline strip and “Why small models fit” card for the Modal/Nemotron story.
|
| 106 |
|
|
|
|
| 173 |
- The dashboard shows daily sales, expenses, profit, outstanding credit, top sellers, and low-stock alerts from saved data.
|
| 174 |
- Customer and inventory detail views show transaction history for one customer or item.
|
| 175 |
- Smart review warnings flag low confidence, missing fields, duplicate risk, and negative stock before save.
|
| 176 |
+
- Seller setup persists business name, currency label, low-stock threshold, and language style in SQLite.
|
| 177 |
+
- Local fallback rules include common English, Hinglish, Hindi-lite, and Gujarati-lite seller notes.
|
| 178 |
+
- Customer follow-up and inventory reorder helpers generate WhatsApp-ready action messages.
|
| 179 |
- WhatsApp summaries provide a short copyable daily recap for sharing.
|
| 180 |
- Bulk import splits pasted notes by line, parses each line, supports review edits, and saves all reviewed transactions.
|
| 181 |
- Modal integration lives in `backend/`; if endpoint URLs are not configured, local fallback stays active.
|
docs/demo-video-script.md
CHANGED
|
@@ -16,6 +16,8 @@ On the first screen, show the `Judge Demo Flow` panel:
|
|
| 16 |
|
| 17 |
Click `Seed Demo Transactions`.
|
| 18 |
|
|
|
|
|
|
|
| 19 |
Open `Submission Story` and show the AI pipeline strip plus the “Why small models fit” card.
|
| 20 |
|
| 21 |
Click `Check Demo Health` from the launchpad, or open `Demo Health`, and show:
|
|
@@ -45,6 +47,7 @@ Click parse. Show:
|
|
| 45 |
- Human-friendly transaction review card
|
| 46 |
- Warning badges for low confidence, missing fields, duplicate risk, or negative stock when present
|
| 47 |
- Parse source/status
|
|
|
|
| 48 |
- “Saved just now” receipt with stock, customer, or amount side effects
|
| 49 |
|
| 50 |
## 0:45-1:05 — Bookkeeping Updates
|
|
@@ -52,8 +55,8 @@ Click parse. Show:
|
|
| 52 |
Use the `Sections` navigation to open:
|
| 53 |
|
| 54 |
- `Dashboard` to show sales/profit/credit and the sales/expense timeline
|
| 55 |
-
- `Customer Credit` to show dues
|
| 56 |
-
- `Inventory` to show stock
|
| 57 |
- `Ledger` to show saved transactions
|
| 58 |
|
| 59 |
Say: “The model handles messy input, but the app owns the accounting state.”
|
|
@@ -69,6 +72,7 @@ Open `Reports & PDF`, run `Daily Closeout`, then generate the PDF, WhatsApp summ
|
|
| 69 |
Suggested screenshot/GIF moments:
|
| 70 |
|
| 71 |
- Record flow: Today’s Work actions, review card, parse source, warning badges, save receipt.
|
|
|
|
| 72 |
- Submission Story: AI pipeline and small-model fit card.
|
| 73 |
- Dashboard: sales, expenses, profit, credit, timeline, top item, low-stock inventory.
|
| 74 |
- Reports/Ledger: Daily Closeout, PDF download, WhatsApp summary, CSV export.
|
|
|
|
| 16 |
|
| 17 |
Click `Seed Demo Transactions`.
|
| 18 |
|
| 19 |
+
Show `Seller Setup` and mention that business name, currency, low-stock threshold, and language style are saved in SQLite.
|
| 20 |
+
|
| 21 |
Open `Submission Story` and show the AI pipeline strip plus the “Why small models fit” card.
|
| 22 |
|
| 23 |
Click `Check Demo Health` from the launchpad, or open `Demo Health`, and show:
|
|
|
|
| 47 |
- Human-friendly transaction review card
|
| 48 |
- Warning badges for low confidence, missing fields, duplicate risk, or negative stock when present
|
| 49 |
- Parse source/status
|
| 50 |
+
- Command Center update
|
| 51 |
- “Saved just now” receipt with stock, customer, or amount side effects
|
| 52 |
|
| 53 |
## 0:45-1:05 — Bookkeeping Updates
|
|
|
|
| 55 |
Use the `Sections` navigation to open:
|
| 56 |
|
| 57 |
- `Dashboard` to show sales/profit/credit and the sales/expense timeline
|
| 58 |
+
- `Customer Credit` to show dues, a selected customer detail view, and a follow-up message
|
| 59 |
+
- `Inventory` to show stock, a selected item detail view, and the reorder list
|
| 60 |
- `Ledger` to show saved transactions
|
| 61 |
|
| 62 |
Say: “The model handles messy input, but the app owns the accounting state.”
|
|
|
|
| 72 |
Suggested screenshot/GIF moments:
|
| 73 |
|
| 74 |
- Record flow: Today’s Work actions, review card, parse source, warning badges, save receipt.
|
| 75 |
+
- Seller setup: currency/threshold/language style and Command Center.
|
| 76 |
- Submission Story: AI pipeline and small-model fit card.
|
| 77 |
- Dashboard: sales, expenses, profit, credit, timeline, top item, low-stock inventory.
|
| 78 |
- Reports/Ledger: Daily Closeout, PDF download, WhatsApp summary, CSV export.
|
docs/field-notes.md
CHANGED
|
@@ -47,8 +47,10 @@ The hackathon version is intentionally frozen around the complete bookkeeping lo
|
|
| 47 |
|
| 48 |
- Voice and text capture.
|
| 49 |
- Modal/Nemotron parsing with local rule fallback.
|
|
|
|
|
|
|
| 50 |
- Human-friendly review cards, warning badges, receipts, and save feedback.
|
| 51 |
-
-
|
| 52 |
- Demo Health for backend observability.
|
| 53 |
- Submission Story for the AI pipeline and small-model fit rationale.
|
| 54 |
|
|
|
|
| 47 |
|
| 48 |
- Voice and text capture.
|
| 49 |
- Modal/Nemotron parsing with local rule fallback.
|
| 50 |
+
- Seller setup for business name, currency, low-stock threshold, and language style.
|
| 51 |
+
- Hinglish/Hindi-lite/Gujarati-lite fallback examples for common seller notes.
|
| 52 |
- Human-friendly review cards, warning badges, receipts, and save feedback.
|
| 53 |
+
- Command Center, dashboard timeline, seller-day timeline, customer follow-up, inventory reorder list, ledger correction, Daily Closeout, PDF, CSV, and WhatsApp exports.
|
| 54 |
- Demo Health for backend observability.
|
| 55 |
- Submission Story for the AI pipeline and small-model fit rationale.
|
| 56 |
|
docs/social-post.md
CHANGED
|
@@ -18,11 +18,14 @@ VoiceLedger turns those notes into a working ledger with:
|
|
| 18 |
- local rule fallback for reliability
|
| 19 |
- human-friendly review cards and smart warning badges
|
| 20 |
- save receipts that explain stock, credit, and amount side effects
|
|
|
|
|
|
|
| 21 |
- SQLite ledger
|
| 22 |
- customer credit tracking
|
| 23 |
- inventory tracking
|
| 24 |
- dashboard insights with a daily timeline
|
| 25 |
- customer and inventory detail drilldowns
|
|
|
|
| 26 |
- PDF, CSV, and WhatsApp summaries
|
| 27 |
- one-click Daily Closeout
|
| 28 |
- a visible AI pipeline and small-model fit explanation for judges
|
|
|
|
| 18 |
- local rule fallback for reliability
|
| 19 |
- human-friendly review cards and smart warning badges
|
| 20 |
- save receipts that explain stock, credit, and amount side effects
|
| 21 |
+
- seller setup for currency, stock threshold, and language style
|
| 22 |
+
- English/Hinglish/Hindi-lite/Gujarati-lite local fallback examples
|
| 23 |
- SQLite ledger
|
| 24 |
- customer credit tracking
|
| 25 |
- inventory tracking
|
| 26 |
- dashboard insights with a daily timeline
|
| 27 |
- customer and inventory detail drilldowns
|
| 28 |
+
- customer follow-up and inventory reorder messages
|
| 29 |
- PDF, CSV, and WhatsApp summaries
|
| 30 |
- one-click Daily Closeout
|
| 31 |
- a visible AI pipeline and small-model fit explanation for judges
|
docs/submission-checklist.md
CHANGED
|
@@ -16,17 +16,23 @@
|
|
| 16 |
- `Submission Story` shows the “Why small models fit” card.
|
| 17 |
- `Demo Health` shows Modal reachable and backend version.
|
| 18 |
- `Demo Health` shows NVIDIA Nemotron parser status.
|
|
|
|
| 19 |
- Text parse works with `Sold 12 mangoes, 20 each`.
|
|
|
|
| 20 |
- Voice parse works and transcript appears.
|
| 21 |
- Review card shows type, item, quantity, price, amount, customer, source, and confidence.
|
| 22 |
- Smart warnings appear for low confidence, missing fields, duplicate risk, and negative stock.
|
| 23 |
- Save shows a receipt with transaction type, amount, and side effects.
|
|
|
|
| 24 |
- Save updates Dashboard and Ledger.
|
| 25 |
- Dashboard timeline loads from saved transactions.
|
|
|
|
| 26 |
- Customer Credit section reflects `Amit owes 100` and `Amit paid 40`.
|
| 27 |
- Customer detail lookup shows customer transaction history and status.
|
|
|
|
| 28 |
- Inventory reflects purchases and sales.
|
| 29 |
- Inventory detail lookup shows bought, sold, current stock, and low-stock status.
|
|
|
|
| 30 |
- Ledger edit/delete works and derived balances rebuild.
|
| 31 |
- CSV export downloads from Ledger.
|
| 32 |
- Daily Closeout generates PDF, CSV, WhatsApp summary, and a status line.
|
|
|
|
| 16 |
- `Submission Story` shows the “Why small models fit” card.
|
| 17 |
- `Demo Health` shows Modal reachable and backend version.
|
| 18 |
- `Demo Health` shows NVIDIA Nemotron parser status.
|
| 19 |
+
- Seller Setup saves business name, currency label, low-stock threshold, and language style.
|
| 20 |
- Text parse works with `Sold 12 mangoes, 20 each`.
|
| 21 |
+
- Local fallback parses `Amit ne 100 dene hai`, `Amit ne 50 diya`, and `50 mango kharida`.
|
| 22 |
- Voice parse works and transcript appears.
|
| 23 |
- Review card shows type, item, quantity, price, amount, customer, source, and confidence.
|
| 24 |
- Smart warnings appear for low confidence, missing fields, duplicate risk, and negative stock.
|
| 25 |
- Save shows a receipt with transaction type, amount, and side effects.
|
| 26 |
+
- Command Center updates after save and seed.
|
| 27 |
- Save updates Dashboard and Ledger.
|
| 28 |
- Dashboard timeline loads from saved transactions.
|
| 29 |
+
- Seller-day timeline shows recent saved transactions.
|
| 30 |
- Customer Credit section reflects `Amit owes 100` and `Amit paid 40`.
|
| 31 |
- Customer detail lookup shows customer transaction history and status.
|
| 32 |
+
- Customer follow-up generates a WhatsApp reminder.
|
| 33 |
- Inventory reflects purchases and sales.
|
| 34 |
- Inventory detail lookup shows bought, sold, current stock, and low-stock status.
|
| 35 |
+
- Inventory reorder list uses the configured threshold.
|
| 36 |
- Ledger edit/delete works and derived balances rebuild.
|
| 37 |
- CSV export downloads from Ledger.
|
| 38 |
- Daily Closeout generates PDF, CSV, WhatsApp summary, and a status line.
|
tests/test_parser.py
CHANGED
|
@@ -22,6 +22,16 @@ def test_parse_shorthand_sale() -> None:
|
|
| 22 |
assert transaction.amount == 240
|
| 23 |
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
def test_parse_expense() -> None:
|
| 26 |
transaction = parse_transaction("Paid 500 for supplies")
|
| 27 |
|
|
@@ -39,6 +49,14 @@ def test_parse_shorthand_expense() -> None:
|
|
| 39 |
assert transaction.amount == 300
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
def test_parse_inventory_purchase() -> None:
|
| 43 |
transaction = parse_transaction("Bought 50 mangoes")
|
| 44 |
|
|
@@ -48,6 +66,22 @@ def test_parse_inventory_purchase() -> None:
|
|
| 48 |
assert transaction.payment_status == "paid"
|
| 49 |
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
def test_parse_sale_without_unit_price_for_inventory() -> None:
|
| 52 |
transaction = parse_transaction("Sold 12 mangoes")
|
| 53 |
|
|
@@ -66,6 +100,14 @@ def test_parse_customer_credit() -> None:
|
|
| 66 |
assert transaction.payment_status == "credit"
|
| 67 |
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
def test_parse_customer_payment() -> None:
|
| 70 |
transaction = parse_transaction("Amit paid 50")
|
| 71 |
|
|
@@ -75,6 +117,14 @@ def test_parse_customer_payment() -> None:
|
|
| 75 |
assert transaction.payment_status == "paid"
|
| 76 |
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
def test_unknown_note_is_preserved() -> None:
|
| 79 |
transaction = parse_transaction("Need to check yesterday")
|
| 80 |
|
|
|
|
| 22 |
assert transaction.amount == 240
|
| 23 |
|
| 24 |
|
| 25 |
+
def test_parse_quantity_first_sale() -> None:
|
| 26 |
+
transaction = parse_transaction("12 mango 20 each")
|
| 27 |
+
|
| 28 |
+
assert transaction.transaction_type == "sale"
|
| 29 |
+
assert transaction.item == "mango"
|
| 30 |
+
assert transaction.quantity == 12
|
| 31 |
+
assert transaction.unit_price == 20
|
| 32 |
+
assert transaction.amount == 240
|
| 33 |
+
|
| 34 |
+
|
| 35 |
def test_parse_expense() -> None:
|
| 36 |
transaction = parse_transaction("Paid 500 for supplies")
|
| 37 |
|
|
|
|
| 49 |
assert transaction.amount == 300
|
| 50 |
|
| 51 |
|
| 52 |
+
def test_parse_hinglish_expense() -> None:
|
| 53 |
+
transaction = parse_transaction("rent 300 diya")
|
| 54 |
+
|
| 55 |
+
assert transaction.transaction_type == "expense"
|
| 56 |
+
assert transaction.item == "rent"
|
| 57 |
+
assert transaction.amount == 300
|
| 58 |
+
|
| 59 |
+
|
| 60 |
def test_parse_inventory_purchase() -> None:
|
| 61 |
transaction = parse_transaction("Bought 50 mangoes")
|
| 62 |
|
|
|
|
| 66 |
assert transaction.payment_status == "paid"
|
| 67 |
|
| 68 |
|
| 69 |
+
def test_parse_quantity_first_inventory_purchase() -> None:
|
| 70 |
+
transaction = parse_transaction("50 mango kharida")
|
| 71 |
+
|
| 72 |
+
assert transaction.transaction_type == "inventory_purchase"
|
| 73 |
+
assert transaction.quantity == 50
|
| 74 |
+
assert transaction.item == "mango"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_parse_gujarati_lite_inventory_purchase() -> None:
|
| 78 |
+
transaction = parse_transaction("50 mango lidha")
|
| 79 |
+
|
| 80 |
+
assert transaction.transaction_type == "inventory_purchase"
|
| 81 |
+
assert transaction.quantity == 50
|
| 82 |
+
assert transaction.item == "mango"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
def test_parse_sale_without_unit_price_for_inventory() -> None:
|
| 86 |
transaction = parse_transaction("Sold 12 mangoes")
|
| 87 |
|
|
|
|
| 100 |
assert transaction.payment_status == "credit"
|
| 101 |
|
| 102 |
|
| 103 |
+
def test_parse_hinglish_customer_credit() -> None:
|
| 104 |
+
transaction = parse_transaction("Amit ne 100 dene hai")
|
| 105 |
+
|
| 106 |
+
assert transaction.transaction_type == "customer_credit"
|
| 107 |
+
assert transaction.customer == "Amit"
|
| 108 |
+
assert transaction.amount == 100
|
| 109 |
+
|
| 110 |
+
|
| 111 |
def test_parse_customer_payment() -> None:
|
| 112 |
transaction = parse_transaction("Amit paid 50")
|
| 113 |
|
|
|
|
| 117 |
assert transaction.payment_status == "paid"
|
| 118 |
|
| 119 |
|
| 120 |
+
def test_parse_hinglish_customer_payment() -> None:
|
| 121 |
+
transaction = parse_transaction("Amit ne 50 diya")
|
| 122 |
+
|
| 123 |
+
assert transaction.transaction_type == "customer_payment"
|
| 124 |
+
assert transaction.customer == "Amit"
|
| 125 |
+
assert transaction.amount == 50
|
| 126 |
+
|
| 127 |
+
|
| 128 |
def test_unknown_note_is_preserved() -> None:
|
| 129 |
transaction = parse_transaction("Need to check yesterday")
|
| 130 |
|
tests/test_settings.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
from voiceledger.ledger.settings import get_business_settings, get_low_stock_threshold, update_business_settings
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_business_settings_defaults_load_when_empty(tmp_path: Path) -> None:
|
| 7 |
+
db_path = tmp_path / "voiceledger.sqlite3"
|
| 8 |
+
|
| 9 |
+
settings = get_business_settings(db_path)
|
| 10 |
+
|
| 11 |
+
assert settings["business_name"] == "VoiceLedger Seller"
|
| 12 |
+
assert settings["currency_symbol"] == "₹"
|
| 13 |
+
assert get_low_stock_threshold(db_path) == 5
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_business_settings_update_values(tmp_path: Path) -> None:
|
| 17 |
+
db_path = tmp_path / "voiceledger.sqlite3"
|
| 18 |
+
|
| 19 |
+
settings = update_business_settings(
|
| 20 |
+
business_name="Mango Cart",
|
| 21 |
+
currency_symbol="Rs ",
|
| 22 |
+
low_stock_threshold=8,
|
| 23 |
+
language_style="English + Hinglish",
|
| 24 |
+
db_path=db_path,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
assert settings["business_name"] == "Mango Cart"
|
| 28 |
+
assert settings["currency_symbol"] == "Rs"
|
| 29 |
+
assert settings["low_stock_threshold"] == "8.0"
|
| 30 |
+
assert get_low_stock_threshold(db_path) == 8
|
tests/test_ui_audio_flow.py
CHANGED
|
@@ -70,7 +70,7 @@ def test_parse_note_surfaces_modal_source(monkeypatch) -> None:
|
|
| 70 |
assert structured["transaction_type"] == "expense"
|
| 71 |
assert state == structured
|
| 72 |
assert "NVIDIA Nemotron" in status
|
| 73 |
-
assert "
|
| 74 |
|
| 75 |
|
| 76 |
def test_high_contrast_demo_panels_are_rendered() -> None:
|
|
@@ -133,6 +133,16 @@ def test_review_warnings_flag_missing_and_low_confidence() -> None:
|
|
| 133 |
assert "Low confidence" in warnings
|
| 134 |
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
def test_receipt_card_summarizes_saved_sale(tmp_path) -> None:
|
| 137 |
transaction = gradio_app.local_parse_transaction("Sold 12 mangoes, 20 each")
|
| 138 |
|
|
@@ -152,5 +162,37 @@ def test_daily_closeout_returns_exports(tmp_path) -> None:
|
|
| 152 |
assert "Daily Closeout Ready" in summary
|
| 153 |
assert pdf_path is not None
|
| 154 |
assert csv_path is not None
|
| 155 |
-
assert "VoiceLedger Daily Summary" in whatsapp
|
| 156 |
assert "Daily closeout complete" in status
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
assert structured["transaction_type"] == "expense"
|
| 71 |
assert state == structured
|
| 72 |
assert "NVIDIA Nemotron" in status
|
| 73 |
+
assert "Safe to save" in review_card
|
| 74 |
|
| 75 |
|
| 76 |
def test_high_contrast_demo_panels_are_rendered() -> None:
|
|
|
|
| 133 |
assert "Low confidence" in warnings
|
| 134 |
|
| 135 |
|
| 136 |
+
def test_review_card_surfaces_needs_review_for_missing_fields() -> None:
|
| 137 |
+
transaction = gradio_app.Transaction(transaction_type="customer_credit", confidence=0.8)
|
| 138 |
+
|
| 139 |
+
review_card = gradio_app._review_card(transaction, "Parsed locally.", gradio_app._review_warnings(transaction, None))
|
| 140 |
+
|
| 141 |
+
assert "Needs review" in review_card
|
| 142 |
+
assert "Missing amount" in review_card
|
| 143 |
+
assert "Missing customer" in review_card
|
| 144 |
+
|
| 145 |
+
|
| 146 |
def test_receipt_card_summarizes_saved_sale(tmp_path) -> None:
|
| 147 |
transaction = gradio_app.local_parse_transaction("Sold 12 mangoes, 20 each")
|
| 148 |
|
|
|
|
| 162 |
assert "Daily Closeout Ready" in summary
|
| 163 |
assert pdf_path is not None
|
| 164 |
assert csv_path is not None
|
| 165 |
+
assert "VoiceLedger Seller Daily Summary" in whatsapp
|
| 166 |
assert "Daily closeout complete" in status
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def test_command_center_and_seller_setup_use_settings(tmp_path) -> None:
|
| 170 |
+
db_path = tmp_path / "voiceledger.sqlite3"
|
| 171 |
+
gradio_app.update_business_settings(
|
| 172 |
+
business_name="Mango Cart",
|
| 173 |
+
currency_symbol="Rs ",
|
| 174 |
+
low_stock_threshold=3,
|
| 175 |
+
language_style="English + Hinglish",
|
| 176 |
+
db_path=db_path,
|
| 177 |
+
)
|
| 178 |
+
gradio_app.add_transaction(gradio_app.local_parse_transaction("Sold 12 mangoes, 20 each"), db_path)
|
| 179 |
+
|
| 180 |
+
command_center = gradio_app._command_center(db_path)
|
| 181 |
+
|
| 182 |
+
assert "Mango Cart Command Center" in command_center
|
| 183 |
+
assert "Rs240" in command_center
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def test_customer_followup_and_reorder_helpers(tmp_path) -> None:
|
| 187 |
+
db_path = tmp_path / "voiceledger.sqlite3"
|
| 188 |
+
gradio_app.add_transaction(gradio_app.local_parse_transaction("Amit owes 100"), db_path)
|
| 189 |
+
gradio_app.add_transaction(gradio_app.local_parse_transaction("Amit paid 40"), db_path)
|
| 190 |
+
gradio_app.add_transaction(gradio_app.local_parse_transaction("Bought 3 onions"), db_path)
|
| 191 |
+
|
| 192 |
+
followup = gradio_app.generate_customer_followup("Amit", db_path)
|
| 193 |
+
reorder, message = gradio_app.generate_reorder_list(db_path)
|
| 194 |
+
|
| 195 |
+
assert "Amit" in followup
|
| 196 |
+
assert "₹60" in followup
|
| 197 |
+
assert "onions" in set(reorder["item"])
|
| 198 |
+
assert "Onions" in message
|
voiceledger/ledger/__init__.py
CHANGED
|
@@ -11,6 +11,7 @@ from voiceledger.ledger.analytics import (
|
|
| 11 |
from voiceledger.ledger.customers import add_credit, get_customer_balances, record_payment
|
| 12 |
from voiceledger.ledger.database import add_transaction, get_transactions, initialize_database
|
| 13 |
from voiceledger.ledger.inventory import add_stock, get_inventory, remove_stock
|
|
|
|
| 14 |
|
| 15 |
__all__ = [
|
| 16 |
"initialize_database",
|
|
@@ -22,6 +23,9 @@ __all__ = [
|
|
| 22 |
"add_stock",
|
| 23 |
"remove_stock",
|
| 24 |
"get_inventory",
|
|
|
|
|
|
|
|
|
|
| 25 |
"calculate_daily_sales",
|
| 26 |
"calculate_daily_expenses",
|
| 27 |
"calculate_net_profit",
|
|
|
|
| 11 |
from voiceledger.ledger.customers import add_credit, get_customer_balances, record_payment
|
| 12 |
from voiceledger.ledger.database import add_transaction, get_transactions, initialize_database
|
| 13 |
from voiceledger.ledger.inventory import add_stock, get_inventory, remove_stock
|
| 14 |
+
from voiceledger.ledger.settings import get_business_settings, get_low_stock_threshold, update_business_settings
|
| 15 |
|
| 16 |
__all__ = [
|
| 17 |
"initialize_database",
|
|
|
|
| 23 |
"add_stock",
|
| 24 |
"remove_stock",
|
| 25 |
"get_inventory",
|
| 26 |
+
"get_business_settings",
|
| 27 |
+
"update_business_settings",
|
| 28 |
+
"get_low_stock_threshold",
|
| 29 |
"calculate_daily_sales",
|
| 30 |
"calculate_daily_expenses",
|
| 31 |
"calculate_net_profit",
|
voiceledger/ledger/database.py
CHANGED
|
@@ -13,6 +13,7 @@ import pandas as pd
|
|
| 13 |
from voiceledger.config import get_database_path
|
| 14 |
from voiceledger.ledger.customers import add_credit, initialize_customers_table, record_payment
|
| 15 |
from voiceledger.ledger.inventory import add_stock, initialize_inventory_table, remove_stock
|
|
|
|
| 16 |
from voiceledger.parser.schema import Transaction
|
| 17 |
|
| 18 |
|
|
@@ -58,6 +59,7 @@ def initialize_database(db_path: str | Path | None = None) -> Path:
|
|
| 58 |
|
| 59 |
initialize_customers_table(path)
|
| 60 |
initialize_inventory_table(path)
|
|
|
|
| 61 |
return path
|
| 62 |
|
| 63 |
|
|
|
|
| 13 |
from voiceledger.config import get_database_path
|
| 14 |
from voiceledger.ledger.customers import add_credit, initialize_customers_table, record_payment
|
| 15 |
from voiceledger.ledger.inventory import add_stock, initialize_inventory_table, remove_stock
|
| 16 |
+
from voiceledger.ledger.settings import initialize_business_settings_table
|
| 17 |
from voiceledger.parser.schema import Transaction
|
| 18 |
|
| 19 |
|
|
|
|
| 59 |
|
| 60 |
initialize_customers_table(path)
|
| 61 |
initialize_inventory_table(path)
|
| 62 |
+
initialize_business_settings_table(path)
|
| 63 |
return path
|
| 64 |
|
| 65 |
|
voiceledger/ledger/settings.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Business settings persistence for VoiceLedger."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import sqlite3
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from voiceledger.config import get_database_path
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
SETTINGS_SCHEMA_SQL = """
|
| 12 |
+
CREATE TABLE IF NOT EXISTS business_settings (
|
| 13 |
+
key TEXT PRIMARY KEY,
|
| 14 |
+
value TEXT NOT NULL
|
| 15 |
+
);
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
DEFAULT_BUSINESS_SETTINGS = {
|
| 19 |
+
"business_name": "VoiceLedger Seller",
|
| 20 |
+
"currency_symbol": "₹",
|
| 21 |
+
"low_stock_threshold": "5",
|
| 22 |
+
"language_style": "English + Hinglish",
|
| 23 |
+
"field_test_who": "A local informal seller who tracks sales, stock, customer dues, and profit from short notes.",
|
| 24 |
+
"field_test_tried": "Voice notes, typed notes, customer credit, inventory updates, dashboard review, and exports.",
|
| 25 |
+
"field_test_changed": "Added review warnings, save receipts, edit/delete, CSV export, and clearer demo health.",
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def initialize_business_settings_table(db_path: str | Path | None = None) -> Path:
|
| 30 |
+
"""Create the business settings table and seed default values."""
|
| 31 |
+
path = _resolve_db_path(db_path)
|
| 32 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 33 |
+
|
| 34 |
+
with sqlite3.connect(path) as connection:
|
| 35 |
+
connection.execute(SETTINGS_SCHEMA_SQL)
|
| 36 |
+
connection.executemany(
|
| 37 |
+
"""
|
| 38 |
+
INSERT OR IGNORE INTO business_settings (key, value)
|
| 39 |
+
VALUES (?, ?)
|
| 40 |
+
""",
|
| 41 |
+
DEFAULT_BUSINESS_SETTINGS.items(),
|
| 42 |
+
)
|
| 43 |
+
connection.commit()
|
| 44 |
+
|
| 45 |
+
return path
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def get_business_settings(db_path: str | Path | None = None) -> dict[str, str]:
|
| 49 |
+
"""Return business settings merged with defaults."""
|
| 50 |
+
path = initialize_business_settings_table(db_path)
|
| 51 |
+
with sqlite3.connect(path) as connection:
|
| 52 |
+
rows = connection.execute("SELECT key, value FROM business_settings").fetchall()
|
| 53 |
+
|
| 54 |
+
settings = dict(DEFAULT_BUSINESS_SETTINGS)
|
| 55 |
+
settings.update({str(key): str(value) for key, value in rows})
|
| 56 |
+
return settings
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def update_business_settings(
|
| 60 |
+
business_name: str | None = None,
|
| 61 |
+
currency_symbol: str | None = None,
|
| 62 |
+
low_stock_threshold: float | int | str | None = None,
|
| 63 |
+
language_style: str | None = None,
|
| 64 |
+
db_path: str | Path | None = None,
|
| 65 |
+
**extra_settings: str | None,
|
| 66 |
+
) -> dict[str, str]:
|
| 67 |
+
"""Update business settings and return the merged settings."""
|
| 68 |
+
path = initialize_business_settings_table(db_path)
|
| 69 |
+
updates: dict[str, str] = {}
|
| 70 |
+
if business_name is not None:
|
| 71 |
+
updates["business_name"] = _clean_text(business_name, DEFAULT_BUSINESS_SETTINGS["business_name"])
|
| 72 |
+
if currency_symbol is not None:
|
| 73 |
+
updates["currency_symbol"] = _clean_text(currency_symbol, DEFAULT_BUSINESS_SETTINGS["currency_symbol"])
|
| 74 |
+
if low_stock_threshold is not None:
|
| 75 |
+
updates["low_stock_threshold"] = str(_coerce_threshold(low_stock_threshold))
|
| 76 |
+
if language_style is not None:
|
| 77 |
+
updates["language_style"] = _clean_text(language_style, DEFAULT_BUSINESS_SETTINGS["language_style"])
|
| 78 |
+
|
| 79 |
+
for key, value in extra_settings.items():
|
| 80 |
+
if key in DEFAULT_BUSINESS_SETTINGS and value is not None:
|
| 81 |
+
updates[key] = _clean_text(value, DEFAULT_BUSINESS_SETTINGS[key])
|
| 82 |
+
|
| 83 |
+
if updates:
|
| 84 |
+
with sqlite3.connect(path) as connection:
|
| 85 |
+
connection.executemany(
|
| 86 |
+
"""
|
| 87 |
+
INSERT INTO business_settings (key, value)
|
| 88 |
+
VALUES (?, ?)
|
| 89 |
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
| 90 |
+
""",
|
| 91 |
+
updates.items(),
|
| 92 |
+
)
|
| 93 |
+
connection.commit()
|
| 94 |
+
|
| 95 |
+
return get_business_settings(path)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def get_low_stock_threshold(db_path: str | Path | None = None) -> float:
|
| 99 |
+
"""Return the configured low-stock threshold as a float."""
|
| 100 |
+
settings = get_business_settings(db_path)
|
| 101 |
+
return _coerce_threshold(settings.get("low_stock_threshold", DEFAULT_BUSINESS_SETTINGS["low_stock_threshold"]))
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _coerce_threshold(value: float | int | str) -> float:
|
| 105 |
+
"""Return a non-negative low-stock threshold."""
|
| 106 |
+
try:
|
| 107 |
+
threshold = float(value)
|
| 108 |
+
except (TypeError, ValueError):
|
| 109 |
+
threshold = float(DEFAULT_BUSINESS_SETTINGS["low_stock_threshold"])
|
| 110 |
+
return max(threshold, 0.0)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _clean_text(value: str, default: str) -> str:
|
| 114 |
+
"""Normalize a setting string."""
|
| 115 |
+
cleaned = " ".join(str(value or "").split()).strip()
|
| 116 |
+
return cleaned or default
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _resolve_db_path(db_path: str | Path | None) -> Path:
|
| 120 |
+
"""Resolve an explicit or configured database path."""
|
| 121 |
+
if db_path is None:
|
| 122 |
+
return get_database_path()
|
| 123 |
+
return Path(db_path).expanduser()
|
voiceledger/parser/rules.py
CHANGED
|
@@ -32,11 +32,23 @@ SHORTHAND_SALE_PATTERN = re.compile(
|
|
| 32 |
rf"(?P<unit_price>\d+(?:\.\d+)?)\s*$",
|
| 33 |
re.IGNORECASE,
|
| 34 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
SHORTHAND_EXPENSE_PATTERN = re.compile(
|
| 36 |
rf"^\s*(?P<item>[a-zA-Z][a-zA-Z\s-]*?)\s+"
|
| 37 |
rf"(?P<amount>\d+(?:\.\d+)?)\s*$",
|
| 38 |
re.IGNORECASE,
|
| 39 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
INVENTORY_PURCHASE_PATTERN = re.compile(
|
| 41 |
rf"\b(?:bought|purchased|buy|stocked|restocked)\s+"
|
| 42 |
rf"(?P<quantity>\d+(?:\.\d+)?)\s+"
|
|
@@ -44,18 +56,36 @@ INVENTORY_PURCHASE_PATTERN = re.compile(
|
|
| 44 |
rf"(?:\s+(?:for|at)\s+(?P<amount>\d+(?:\.\d+)?))?\b",
|
| 45 |
re.IGNORECASE,
|
| 46 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
CREDIT_PATTERN = re.compile(
|
| 48 |
rf"^\s*(?P<customer>[a-zA-Z][a-zA-Z\s-]*?)\s+"
|
| 49 |
rf"(?:owes|owe|will\s+pay|to\s+pay)\s+"
|
| 50 |
rf"(?P<amount>\d+(?:\.\d+)?)\b",
|
| 51 |
re.IGNORECASE,
|
| 52 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
CUSTOMER_PAYMENT_PATTERN = re.compile(
|
| 54 |
rf"^\s*(?P<customer>[a-zA-Z][a-zA-Z\s-]*?)\s+"
|
| 55 |
rf"(?:paid|pays|settled|repaid)\s+"
|
| 56 |
rf"(?P<amount>\d+(?:\.\d+)?)\b",
|
| 57 |
re.IGNORECASE,
|
| 58 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
|
| 61 |
class RuleParser(Parser):
|
|
@@ -96,10 +126,18 @@ def parse_transaction(note: str) -> Transaction:
|
|
| 96 |
if shorthand_sale:
|
| 97 |
return shorthand_sale
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
expense = _parse_expense(cleaned_note)
|
| 100 |
if expense:
|
| 101 |
return expense
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
shorthand_expense = _parse_shorthand_expense(cleaned_note)
|
| 104 |
if shorthand_expense:
|
| 105 |
return shorthand_expense
|
|
@@ -171,6 +209,26 @@ def _parse_shorthand_sale(note: str) -> Transaction | None:
|
|
| 171 |
)
|
| 172 |
|
| 173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
def _parse_shorthand_expense(note: str) -> Transaction | None:
|
| 175 |
"""Parse notes like 'rent 300'."""
|
| 176 |
match = SHORTHAND_EXPENSE_PATTERN.search(note)
|
|
@@ -187,9 +245,25 @@ def _parse_shorthand_expense(note: str) -> Transaction | None:
|
|
| 187 |
)
|
| 188 |
|
| 189 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
def _parse_inventory_purchase(note: str) -> Transaction | None:
|
| 191 |
"""Parse notes like 'Bought 50 mangoes'."""
|
| 192 |
-
match = INVENTORY_PURCHASE_PATTERN.search(note)
|
| 193 |
if not match:
|
| 194 |
return None
|
| 195 |
|
|
@@ -197,7 +271,7 @@ def _parse_inventory_purchase(note: str) -> Transaction | None:
|
|
| 197 |
transaction_type="inventory_purchase",
|
| 198 |
item=_clean_item(match.group("item")),
|
| 199 |
quantity=_to_float(match.group("quantity")),
|
| 200 |
-
amount=_to_float(match.
|
| 201 |
payment_status="paid",
|
| 202 |
notes=note,
|
| 203 |
confidence=0.86,
|
|
@@ -206,7 +280,7 @@ def _parse_inventory_purchase(note: str) -> Transaction | None:
|
|
| 206 |
|
| 207 |
def _parse_customer_credit(note: str) -> Transaction | None:
|
| 208 |
"""Parse notes like 'Amit owes 100'."""
|
| 209 |
-
match = CREDIT_PATTERN.search(note)
|
| 210 |
if not match:
|
| 211 |
return None
|
| 212 |
|
|
@@ -222,7 +296,7 @@ def _parse_customer_credit(note: str) -> Transaction | None:
|
|
| 222 |
|
| 223 |
def _parse_customer_payment(note: str) -> Transaction | None:
|
| 224 |
"""Parse notes like 'Amit paid 50'."""
|
| 225 |
-
match = CUSTOMER_PAYMENT_PATTERN.search(note)
|
| 226 |
if not match:
|
| 227 |
return None
|
| 228 |
|
|
|
|
| 32 |
rf"(?P<unit_price>\d+(?:\.\d+)?)\s*$",
|
| 33 |
re.IGNORECASE,
|
| 34 |
)
|
| 35 |
+
QUANTITY_FIRST_SALE_PATTERN = re.compile(
|
| 36 |
+
rf"^\s*(?P<quantity>\d+(?:\.\d+)?)\s+"
|
| 37 |
+
rf"(?P<item>[a-zA-Z][a-zA-Z\s-]*?)\s+"
|
| 38 |
+
rf"(?P<unit_price>\d+(?:\.\d+)?)\s*(?:each|per\s+\w+)?\s*$",
|
| 39 |
+
re.IGNORECASE,
|
| 40 |
+
)
|
| 41 |
SHORTHAND_EXPENSE_PATTERN = re.compile(
|
| 42 |
rf"^\s*(?P<item>[a-zA-Z][a-zA-Z\s-]*?)\s+"
|
| 43 |
rf"(?P<amount>\d+(?:\.\d+)?)\s*$",
|
| 44 |
re.IGNORECASE,
|
| 45 |
)
|
| 46 |
+
HINGLISH_EXPENSE_PATTERN = re.compile(
|
| 47 |
+
rf"^\s*(?P<item>[a-zA-Z][a-zA-Z\s-]*?)\s+"
|
| 48 |
+
rf"(?P<amount>\d+(?:\.\d+)?)\s+"
|
| 49 |
+
rf"(?:diya|dia|paid|pay|chukaya)\s*$",
|
| 50 |
+
re.IGNORECASE,
|
| 51 |
+
)
|
| 52 |
INVENTORY_PURCHASE_PATTERN = re.compile(
|
| 53 |
rf"\b(?:bought|purchased|buy|stocked|restocked)\s+"
|
| 54 |
rf"(?P<quantity>\d+(?:\.\d+)?)\s+"
|
|
|
|
| 56 |
rf"(?:\s+(?:for|at)\s+(?P<amount>\d+(?:\.\d+)?))?\b",
|
| 57 |
re.IGNORECASE,
|
| 58 |
)
|
| 59 |
+
QUANTITY_FIRST_INVENTORY_PATTERN = re.compile(
|
| 60 |
+
rf"^\s*(?P<quantity>\d+(?:\.\d+)?)\s+"
|
| 61 |
+
rf"(?P<item>[a-zA-Z][a-zA-Z\s-]*?)\s+"
|
| 62 |
+
rf"(?:kharida|kharidi|khareeda|lidha|liya|li|bought|purchased)\s*$",
|
| 63 |
+
re.IGNORECASE,
|
| 64 |
+
)
|
| 65 |
CREDIT_PATTERN = re.compile(
|
| 66 |
rf"^\s*(?P<customer>[a-zA-Z][a-zA-Z\s-]*?)\s+"
|
| 67 |
rf"(?:owes|owe|will\s+pay|to\s+pay)\s+"
|
| 68 |
rf"(?P<amount>\d+(?:\.\d+)?)\b",
|
| 69 |
re.IGNORECASE,
|
| 70 |
)
|
| 71 |
+
HINGLISH_CREDIT_PATTERN = re.compile(
|
| 72 |
+
rf"^\s*(?P<customer>[a-zA-Z][a-zA-Z\s-]*?)\s+"
|
| 73 |
+
rf"(?:ne\s+)?(?P<amount>\d+(?:\.\d+)?)\s+"
|
| 74 |
+
rf"(?:dene\s+hai|dena\s+hai|aapva\s+che|apvana\s+che|owes)\b",
|
| 75 |
+
re.IGNORECASE,
|
| 76 |
+
)
|
| 77 |
CUSTOMER_PAYMENT_PATTERN = re.compile(
|
| 78 |
rf"^\s*(?P<customer>[a-zA-Z][a-zA-Z\s-]*?)\s+"
|
| 79 |
rf"(?:paid|pays|settled|repaid)\s+"
|
| 80 |
rf"(?P<amount>\d+(?:\.\d+)?)\b",
|
| 81 |
re.IGNORECASE,
|
| 82 |
)
|
| 83 |
+
HINGLISH_PAYMENT_PATTERN = re.compile(
|
| 84 |
+
rf"^\s*(?P<customer>[a-zA-Z][a-zA-Z\s-]*?)\s+"
|
| 85 |
+
rf"ne\s+(?P<amount>\d+(?:\.\d+)?)\s+"
|
| 86 |
+
rf"(?:diya|dia|aapya|apya|paid|chukaya)\b",
|
| 87 |
+
re.IGNORECASE,
|
| 88 |
+
)
|
| 89 |
|
| 90 |
|
| 91 |
class RuleParser(Parser):
|
|
|
|
| 126 |
if shorthand_sale:
|
| 127 |
return shorthand_sale
|
| 128 |
|
| 129 |
+
quantity_first_sale = _parse_quantity_first_sale(cleaned_note)
|
| 130 |
+
if quantity_first_sale:
|
| 131 |
+
return quantity_first_sale
|
| 132 |
+
|
| 133 |
expense = _parse_expense(cleaned_note)
|
| 134 |
if expense:
|
| 135 |
return expense
|
| 136 |
|
| 137 |
+
hinglish_expense = _parse_hinglish_expense(cleaned_note)
|
| 138 |
+
if hinglish_expense:
|
| 139 |
+
return hinglish_expense
|
| 140 |
+
|
| 141 |
shorthand_expense = _parse_shorthand_expense(cleaned_note)
|
| 142 |
if shorthand_expense:
|
| 143 |
return shorthand_expense
|
|
|
|
| 209 |
)
|
| 210 |
|
| 211 |
|
| 212 |
+
def _parse_quantity_first_sale(note: str) -> Transaction | None:
|
| 213 |
+
"""Parse notes like '12 mango 20 each'."""
|
| 214 |
+
match = QUANTITY_FIRST_SALE_PATTERN.search(note)
|
| 215 |
+
if not match:
|
| 216 |
+
return None
|
| 217 |
+
|
| 218 |
+
quantity = _to_float(match.group("quantity"))
|
| 219 |
+
unit_price = _to_float(match.group("unit_price"))
|
| 220 |
+
return Transaction(
|
| 221 |
+
transaction_type="sale",
|
| 222 |
+
item=_clean_item(match.group("item")),
|
| 223 |
+
quantity=quantity,
|
| 224 |
+
unit_price=unit_price,
|
| 225 |
+
amount=round(quantity * unit_price, 2) if quantity is not None and unit_price is not None else None,
|
| 226 |
+
payment_status="paid",
|
| 227 |
+
notes=note,
|
| 228 |
+
confidence=0.8,
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
|
| 232 |
def _parse_shorthand_expense(note: str) -> Transaction | None:
|
| 233 |
"""Parse notes like 'rent 300'."""
|
| 234 |
match = SHORTHAND_EXPENSE_PATTERN.search(note)
|
|
|
|
| 245 |
)
|
| 246 |
|
| 247 |
|
| 248 |
+
def _parse_hinglish_expense(note: str) -> Transaction | None:
|
| 249 |
+
"""Parse notes like 'rent 300 diya'."""
|
| 250 |
+
match = HINGLISH_EXPENSE_PATTERN.search(note)
|
| 251 |
+
if not match:
|
| 252 |
+
return None
|
| 253 |
+
|
| 254 |
+
return Transaction(
|
| 255 |
+
transaction_type="expense",
|
| 256 |
+
item=_clean_item(match.group("item")),
|
| 257 |
+
amount=_to_float(match.group("amount")),
|
| 258 |
+
payment_status="paid",
|
| 259 |
+
notes=note,
|
| 260 |
+
confidence=0.76,
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
|
| 264 |
def _parse_inventory_purchase(note: str) -> Transaction | None:
|
| 265 |
"""Parse notes like 'Bought 50 mangoes'."""
|
| 266 |
+
match = INVENTORY_PURCHASE_PATTERN.search(note) or QUANTITY_FIRST_INVENTORY_PATTERN.search(note)
|
| 267 |
if not match:
|
| 268 |
return None
|
| 269 |
|
|
|
|
| 271 |
transaction_type="inventory_purchase",
|
| 272 |
item=_clean_item(match.group("item")),
|
| 273 |
quantity=_to_float(match.group("quantity")),
|
| 274 |
+
amount=_to_float(match.groupdict().get("amount")),
|
| 275 |
payment_status="paid",
|
| 276 |
notes=note,
|
| 277 |
confidence=0.86,
|
|
|
|
| 280 |
|
| 281 |
def _parse_customer_credit(note: str) -> Transaction | None:
|
| 282 |
"""Parse notes like 'Amit owes 100'."""
|
| 283 |
+
match = CREDIT_PATTERN.search(note) or HINGLISH_CREDIT_PATTERN.search(note)
|
| 284 |
if not match:
|
| 285 |
return None
|
| 286 |
|
|
|
|
| 296 |
|
| 297 |
def _parse_customer_payment(note: str) -> Transaction | None:
|
| 298 |
"""Parse notes like 'Amit paid 50'."""
|
| 299 |
+
match = CUSTOMER_PAYMENT_PATTERN.search(note) or HINGLISH_PAYMENT_PATTERN.search(note)
|
| 300 |
if not match:
|
| 301 |
return None
|
| 302 |
|
voiceledger/reports/__init__.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""Reporting helpers for VoiceLedger."""
|
| 2 |
|
|
|
|
| 3 |
from voiceledger.reports.pdf_report import build_daily_summary, generate_daily_summary_pdf
|
| 4 |
from voiceledger.reports.summary import summarize_transactions
|
| 5 |
from voiceledger.reports.whatsapp_summary import generate_whatsapp_summary
|
|
@@ -9,4 +10,6 @@ __all__ = [
|
|
| 9 |
"build_daily_summary",
|
| 10 |
"generate_daily_summary_pdf",
|
| 11 |
"generate_whatsapp_summary",
|
|
|
|
|
|
|
| 12 |
]
|
|
|
|
| 1 |
"""Reporting helpers for VoiceLedger."""
|
| 2 |
|
| 3 |
+
from voiceledger.reports.actions import generate_customer_followup, generate_reorder_list
|
| 4 |
from voiceledger.reports.pdf_report import build_daily_summary, generate_daily_summary_pdf
|
| 5 |
from voiceledger.reports.summary import summarize_transactions
|
| 6 |
from voiceledger.reports.whatsapp_summary import generate_whatsapp_summary
|
|
|
|
| 10 |
"build_daily_summary",
|
| 11 |
"generate_daily_summary_pdf",
|
| 12 |
"generate_whatsapp_summary",
|
| 13 |
+
"generate_customer_followup",
|
| 14 |
+
"generate_reorder_list",
|
| 15 |
]
|
voiceledger/reports/actions.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Action-oriented seller messages for follow-up and restocking."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import pandas as pd
|
| 8 |
+
|
| 9 |
+
from voiceledger.ledger.customers import get_customer_balances
|
| 10 |
+
from voiceledger.ledger.inventory import get_inventory
|
| 11 |
+
from voiceledger.ledger.settings import get_business_settings, get_low_stock_threshold
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
REORDER_COLUMNS = ["item", "current_stock", "suggested_action"]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def generate_customer_followup(customer_name: str, db_path: str | Path | None = None) -> str:
|
| 18 |
+
"""Generate a short WhatsApp reminder for one customer's outstanding balance."""
|
| 19 |
+
name = " ".join((customer_name or "").split()).strip()
|
| 20 |
+
if not name:
|
| 21 |
+
return "Select a customer to generate a follow-up message."
|
| 22 |
+
|
| 23 |
+
settings = get_business_settings(db_path)
|
| 24 |
+
currency = settings["currency_symbol"]
|
| 25 |
+
balances = get_customer_balances(db_path)
|
| 26 |
+
if balances.empty:
|
| 27 |
+
return f"No outstanding balance found for {name}."
|
| 28 |
+
|
| 29 |
+
matches = balances[balances["customer"].astype(str).str.lower() == name.lower()]
|
| 30 |
+
if matches.empty:
|
| 31 |
+
return f"No outstanding balance found for {name}."
|
| 32 |
+
|
| 33 |
+
balance = float(matches.iloc[0]["outstanding_balance"])
|
| 34 |
+
if balance <= 0:
|
| 35 |
+
return f"Hi {matches.iloc[0]['customer']}, your balance is clear. Thank you."
|
| 36 |
+
|
| 37 |
+
return f"Hi {matches.iloc[0]['customer']}, your balance is {_format_money(balance, currency)}. Please pay when possible."
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def generate_reorder_list(db_path: str | Path | None = None) -> tuple[pd.DataFrame, str]:
|
| 41 |
+
"""Return low-stock rows and a WhatsApp-ready restock message."""
|
| 42 |
+
settings = get_business_settings(db_path)
|
| 43 |
+
threshold = get_low_stock_threshold(db_path)
|
| 44 |
+
inventory = get_inventory(db_path)
|
| 45 |
+
if inventory.empty:
|
| 46 |
+
return pd.DataFrame(columns=REORDER_COLUMNS), "No inventory recorded yet."
|
| 47 |
+
|
| 48 |
+
stock = inventory.copy()
|
| 49 |
+
stock["current_stock"] = pd.to_numeric(stock["current_stock"], errors="coerce").fillna(0)
|
| 50 |
+
low_stock = stock[stock["current_stock"] < threshold].sort_values(["current_stock", "item"]).copy()
|
| 51 |
+
if low_stock.empty:
|
| 52 |
+
return pd.DataFrame(columns=REORDER_COLUMNS), f"All stock is above the low-stock threshold of {_format_quantity(threshold)}."
|
| 53 |
+
|
| 54 |
+
low_stock["suggested_action"] = low_stock.apply(
|
| 55 |
+
lambda row: f"Restock {row['item']} soon; current stock is {_format_quantity(row['current_stock'])}.",
|
| 56 |
+
axis=1,
|
| 57 |
+
)
|
| 58 |
+
message_lines = [
|
| 59 |
+
f"{settings['business_name']} Reorder List",
|
| 60 |
+
f"Low-stock threshold: {_format_quantity(threshold)}",
|
| 61 |
+
"",
|
| 62 |
+
]
|
| 63 |
+
message_lines.extend(
|
| 64 |
+
f"- {row['item'].title()}: {_format_quantity(row['current_stock'])} left"
|
| 65 |
+
for _, row in low_stock.head(8).iterrows()
|
| 66 |
+
)
|
| 67 |
+
return low_stock[REORDER_COLUMNS].reset_index(drop=True), "\n".join(message_lines)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _format_quantity(value: object) -> str:
|
| 71 |
+
"""Format quantity without unnecessary decimals."""
|
| 72 |
+
quantity = float(value)
|
| 73 |
+
if quantity.is_integer():
|
| 74 |
+
return str(int(quantity))
|
| 75 |
+
return f"{quantity:.2f}"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _format_money(value: float, currency_symbol: str) -> str:
|
| 79 |
+
"""Format money with the configured currency symbol."""
|
| 80 |
+
amount = float(value)
|
| 81 |
+
if amount.is_integer():
|
| 82 |
+
return f"{currency_symbol}{int(amount)}"
|
| 83 |
+
return f"{currency_symbol}{amount:,.2f}"
|
voiceledger/reports/pdf_report.py
CHANGED
|
@@ -51,6 +51,8 @@ def generate_daily_summary_pdf(
|
|
| 51 |
db_path: str | Path | None = None,
|
| 52 |
output_dir: str | Path | None = None,
|
| 53 |
report_date: date | None = None,
|
|
|
|
|
|
|
| 54 |
) -> Path:
|
| 55 |
"""Generate a Daily Summary PDF and return its filesystem path."""
|
| 56 |
try:
|
|
@@ -65,8 +67,8 @@ def generate_daily_summary_pdf(
|
|
| 65 |
pdf.set_auto_page_break(auto=True, margin=15)
|
| 66 |
pdf.add_page()
|
| 67 |
|
| 68 |
-
_add_title(pdf, summary.report_date)
|
| 69 |
-
_add_totals(pdf, summary)
|
| 70 |
_add_inventory_summary(pdf, summary.inventory)
|
| 71 |
|
| 72 |
pdf.output(str(output_path))
|
|
@@ -106,31 +108,31 @@ def _build_output_path(output_dir: str | Path | None, report_date: date) -> Path
|
|
| 106 |
return directory / f"voiceledger-daily-summary-{report_date.isoformat()}-{timestamp}.pdf"
|
| 107 |
|
| 108 |
|
| 109 |
-
def _add_title(pdf: object, report_date: date) -> None:
|
| 110 |
"""Add report title and date."""
|
| 111 |
pdf.set_font("Helvetica", "B", 18)
|
| 112 |
-
pdf.cell(0, 12, "
|
| 113 |
pdf.set_font("Helvetica", "", 11)
|
| 114 |
pdf.cell(0, 8, f"Date: {report_date.isoformat()}", new_x="LMARGIN", new_y="NEXT")
|
| 115 |
pdf.ln(4)
|
| 116 |
|
| 117 |
|
| 118 |
-
def _add_totals(pdf: object, summary: DailySummary) -> None:
|
| 119 |
"""Add the financial summary section."""
|
| 120 |
pdf.set_font("Helvetica", "B", 13)
|
| 121 |
pdf.cell(0, 9, "Financial Summary", new_x="LMARGIN", new_y="NEXT")
|
| 122 |
pdf.set_font("Helvetica", "", 11)
|
| 123 |
-
_add_metric_row(pdf, "Total Sales", summary.total_sales)
|
| 124 |
-
_add_metric_row(pdf, "Total Expenses", summary.total_expenses)
|
| 125 |
-
_add_metric_row(pdf, "Net Profit", summary.net_profit)
|
| 126 |
-
_add_metric_row(pdf, "Customer Credit Outstanding", summary.customer_credit_outstanding)
|
| 127 |
pdf.ln(5)
|
| 128 |
|
| 129 |
|
| 130 |
-
def _add_metric_row(pdf: object, label: str, value: float) -> None:
|
| 131 |
"""Add one label/value row to the PDF."""
|
| 132 |
pdf.cell(85, 8, label)
|
| 133 |
-
pdf.cell(0, 8, _format_money(value), new_x="LMARGIN", new_y="NEXT")
|
| 134 |
|
| 135 |
|
| 136 |
def _add_inventory_summary(pdf: object, inventory: pd.DataFrame) -> None:
|
|
@@ -151,9 +153,10 @@ def _add_inventory_summary(pdf: object, inventory: pd.DataFrame) -> None:
|
|
| 151 |
pdf.cell(40, 8, _format_quantity(row["current_stock"]), border=1, new_x="LMARGIN", new_y="NEXT")
|
| 152 |
|
| 153 |
|
| 154 |
-
def _format_money(value: float) -> str:
|
| 155 |
"""Format a monetary amount for PDF output."""
|
| 156 |
-
|
|
|
|
| 157 |
|
| 158 |
|
| 159 |
def _format_quantity(value: object) -> str:
|
|
@@ -162,3 +165,8 @@ def _format_quantity(value: object) -> str:
|
|
| 162 |
if quantity.is_integer():
|
| 163 |
return str(int(quantity))
|
| 164 |
return f"{quantity:,.2f}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
db_path: str | Path | None = None,
|
| 52 |
output_dir: str | Path | None = None,
|
| 53 |
report_date: date | None = None,
|
| 54 |
+
business_name: str = "VoiceLedger",
|
| 55 |
+
currency_symbol: str = "",
|
| 56 |
) -> Path:
|
| 57 |
"""Generate a Daily Summary PDF and return its filesystem path."""
|
| 58 |
try:
|
|
|
|
| 67 |
pdf.set_auto_page_break(auto=True, margin=15)
|
| 68 |
pdf.add_page()
|
| 69 |
|
| 70 |
+
_add_title(pdf, summary.report_date, business_name)
|
| 71 |
+
_add_totals(pdf, summary, currency_symbol)
|
| 72 |
_add_inventory_summary(pdf, summary.inventory)
|
| 73 |
|
| 74 |
pdf.output(str(output_path))
|
|
|
|
| 108 |
return directory / f"voiceledger-daily-summary-{report_date.isoformat()}-{timestamp}.pdf"
|
| 109 |
|
| 110 |
|
| 111 |
+
def _add_title(pdf: object, report_date: date, business_name: str) -> None:
|
| 112 |
"""Add report title and date."""
|
| 113 |
pdf.set_font("Helvetica", "B", 18)
|
| 114 |
+
pdf.cell(0, 12, f"{_pdf_safe_text(business_name)} Daily Summary", new_x="LMARGIN", new_y="NEXT")
|
| 115 |
pdf.set_font("Helvetica", "", 11)
|
| 116 |
pdf.cell(0, 8, f"Date: {report_date.isoformat()}", new_x="LMARGIN", new_y="NEXT")
|
| 117 |
pdf.ln(4)
|
| 118 |
|
| 119 |
|
| 120 |
+
def _add_totals(pdf: object, summary: DailySummary, currency_symbol: str) -> None:
|
| 121 |
"""Add the financial summary section."""
|
| 122 |
pdf.set_font("Helvetica", "B", 13)
|
| 123 |
pdf.cell(0, 9, "Financial Summary", new_x="LMARGIN", new_y="NEXT")
|
| 124 |
pdf.set_font("Helvetica", "", 11)
|
| 125 |
+
_add_metric_row(pdf, "Total Sales", summary.total_sales, currency_symbol)
|
| 126 |
+
_add_metric_row(pdf, "Total Expenses", summary.total_expenses, currency_symbol)
|
| 127 |
+
_add_metric_row(pdf, "Net Profit", summary.net_profit, currency_symbol)
|
| 128 |
+
_add_metric_row(pdf, "Customer Credit Outstanding", summary.customer_credit_outstanding, currency_symbol)
|
| 129 |
pdf.ln(5)
|
| 130 |
|
| 131 |
|
| 132 |
+
def _add_metric_row(pdf: object, label: str, value: float, currency_symbol: str) -> None:
|
| 133 |
"""Add one label/value row to the PDF."""
|
| 134 |
pdf.cell(85, 8, label)
|
| 135 |
+
pdf.cell(0, 8, _format_money(value, currency_symbol), new_x="LMARGIN", new_y="NEXT")
|
| 136 |
|
| 137 |
|
| 138 |
def _add_inventory_summary(pdf: object, inventory: pd.DataFrame) -> None:
|
|
|
|
| 153 |
pdf.cell(40, 8, _format_quantity(row["current_stock"]), border=1, new_x="LMARGIN", new_y="NEXT")
|
| 154 |
|
| 155 |
|
| 156 |
+
def _format_money(value: float, currency_symbol: str) -> str:
|
| 157 |
"""Format a monetary amount for PDF output."""
|
| 158 |
+
symbol = _pdf_safe_text(currency_symbol)
|
| 159 |
+
return f"{symbol}{value:,.2f}" if symbol else f"{value:,.2f}"
|
| 160 |
|
| 161 |
|
| 162 |
def _format_quantity(value: object) -> str:
|
|
|
|
| 165 |
if quantity.is_integer():
|
| 166 |
return str(int(quantity))
|
| 167 |
return f"{quantity:,.2f}"
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _pdf_safe_text(value: str) -> str:
|
| 171 |
+
"""Return text safe for the default fpdf Helvetica font."""
|
| 172 |
+
return str(value or "").replace("₹", "Rs").encode("latin-1", "ignore").decode("latin-1")
|
voiceledger/reports/whatsapp_summary.py
CHANGED
|
@@ -21,6 +21,8 @@ def generate_whatsapp_summary(
|
|
| 21 |
db_path: str | Path | None = None,
|
| 22 |
report_date: date | None = None,
|
| 23 |
low_stock_threshold: float = 5,
|
|
|
|
|
|
|
| 24 |
) -> str:
|
| 25 |
"""Generate a concise daily summary suitable for WhatsApp sharing."""
|
| 26 |
sales = calculate_daily_sales(db_path=db_path, report_date=report_date)
|
|
@@ -32,13 +34,13 @@ def generate_whatsapp_summary(
|
|
| 32 |
|
| 33 |
return "\n".join(
|
| 34 |
[
|
| 35 |
-
"
|
| 36 |
"",
|
| 37 |
-
f"Sales: {_format_money(sales)}",
|
| 38 |
-
f"Expenses: {_format_money(expenses)}",
|
| 39 |
-
f"Profit: {_format_money(profit)}",
|
| 40 |
"",
|
| 41 |
-
f"Outstanding Credit: {_format_money(credit)}",
|
| 42 |
"",
|
| 43 |
f"Top Product: {top_product}",
|
| 44 |
f"Low Stock: {low_stock}",
|
|
@@ -60,9 +62,9 @@ def _low_stock_names(low_stock: pd.DataFrame) -> str:
|
|
| 60 |
return ", ".join(str(item).title() for item in low_stock["item"].head(3))
|
| 61 |
|
| 62 |
|
| 63 |
-
def _format_money(value: float) -> str:
|
| 64 |
"""Format an amount using a rupee symbol and no decimals for whole values."""
|
| 65 |
amount = float(value)
|
| 66 |
if amount.is_integer():
|
| 67 |
-
return f"
|
| 68 |
-
return f"
|
|
|
|
| 21 |
db_path: str | Path | None = None,
|
| 22 |
report_date: date | None = None,
|
| 23 |
low_stock_threshold: float = 5,
|
| 24 |
+
business_name: str = "VoiceLedger",
|
| 25 |
+
currency_symbol: str = "₹",
|
| 26 |
) -> str:
|
| 27 |
"""Generate a concise daily summary suitable for WhatsApp sharing."""
|
| 28 |
sales = calculate_daily_sales(db_path=db_path, report_date=report_date)
|
|
|
|
| 34 |
|
| 35 |
return "\n".join(
|
| 36 |
[
|
| 37 |
+
f"{business_name} Daily Summary",
|
| 38 |
"",
|
| 39 |
+
f"Sales: {_format_money(sales, currency_symbol)}",
|
| 40 |
+
f"Expenses: {_format_money(expenses, currency_symbol)}",
|
| 41 |
+
f"Profit: {_format_money(profit, currency_symbol)}",
|
| 42 |
"",
|
| 43 |
+
f"Outstanding Credit: {_format_money(credit, currency_symbol)}",
|
| 44 |
"",
|
| 45 |
f"Top Product: {top_product}",
|
| 46 |
f"Low Stock: {low_stock}",
|
|
|
|
| 62 |
return ", ".join(str(item).title() for item in low_stock["item"].head(3))
|
| 63 |
|
| 64 |
|
| 65 |
+
def _format_money(value: float, currency_symbol: str) -> str:
|
| 66 |
"""Format an amount using a rupee symbol and no decimals for whole values."""
|
| 67 |
amount = float(value)
|
| 68 |
if amount.is_integer():
|
| 69 |
+
return f"{currency_symbol}{int(amount)}"
|
| 70 |
+
return f"{currency_symbol}{amount:,.2f}"
|
voiceledger/ui/gradio_app.py
CHANGED
|
@@ -38,9 +38,11 @@ from voiceledger.ledger.database import (
|
|
| 38 |
update_transaction,
|
| 39 |
)
|
| 40 |
from voiceledger.ledger.inventory import get_inventory
|
|
|
|
| 41 |
from voiceledger.parser.bulk import REVIEW_COLUMNS, parse_bulk_notes, review_table_to_transactions
|
| 42 |
from voiceledger.parser.rules import parse_transaction as local_parse_transaction
|
| 43 |
from voiceledger.parser.schema import Transaction
|
|
|
|
| 44 |
from voiceledger.reports.pdf_report import generate_daily_summary_pdf
|
| 45 |
from voiceledger.reports.whatsapp_summary import generate_whatsapp_summary
|
| 46 |
from voiceledger.speech.transcribe import TranscriptionError, transcribe_audio as local_transcribe_audio
|
|
@@ -83,6 +85,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
|
|
| 83 |
)
|
| 84 |
|
| 85 |
parsed_state = gr.State(value=None)
|
|
|
|
| 86 |
|
| 87 |
with gr.Group(elem_classes="vl-app-nav"):
|
| 88 |
gr.HTML("<strong>Sections</strong>")
|
|
@@ -105,6 +108,35 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
|
|
| 105 |
"Use the Sections buttons above to open each workflow page. VoiceLedger is locked for the hackathon demo: core bookkeeping is frozen, with Modal/Nemotron active and local fallback ready.",
|
| 106 |
)
|
| 107 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
gr.HTML(_judge_demo_panel())
|
| 109 |
gr.HTML(_today_work_panel())
|
| 110 |
with gr.Row():
|
|
@@ -234,6 +266,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
|
|
| 234 |
wrap=True,
|
| 235 |
elem_classes="vl-panel",
|
| 236 |
)
|
|
|
|
| 237 |
refresh_dashboard_button.click(
|
| 238 |
fn=lambda: _get_dashboard_data(db_path),
|
| 239 |
inputs=None,
|
|
@@ -246,6 +279,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
|
|
| 246 |
timeline_output,
|
| 247 |
top_items_output,
|
| 248 |
low_stock_output,
|
|
|
|
| 249 |
],
|
| 250 |
)
|
| 251 |
demo.load(
|
|
@@ -260,6 +294,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
|
|
| 260 |
timeline_output,
|
| 261 |
top_items_output,
|
| 262 |
low_stock_output,
|
|
|
|
| 263 |
],
|
| 264 |
)
|
| 265 |
|
|
@@ -300,6 +335,28 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
|
|
| 300 |
gr.HTML(_submission_story_panel())
|
| 301 |
gr.HTML(_ai_pipeline_strip())
|
| 302 |
gr.HTML(_small_model_fit_card())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
seed_demo_button = gr.Button("Seed Demo Transactions", variant="primary")
|
| 304 |
seed_demo_status = gr.Markdown(elem_classes="vl-status")
|
| 305 |
|
|
@@ -382,6 +439,20 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
|
|
| 382 |
inputs=customer_detail_name,
|
| 383 |
outputs=[customer_detail_summary, customer_detail_output],
|
| 384 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
|
| 386 |
with gr.Column(visible=False, elem_classes="vl-page-section") as inventory_page:
|
| 387 |
gr.HTML('<div id="vl-page-inventory" class="vl-page-anchor"></div>')
|
|
@@ -426,6 +497,26 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
|
|
| 426 |
inputs=inventory_detail_item,
|
| 427 |
outputs=[inventory_detail_summary, inventory_detail_output],
|
| 428 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
|
| 430 |
with gr.Column(visible=False, elem_classes="vl-page-section") as reports_page:
|
| 431 |
gr.HTML('<div id="vl-page-reports" class="vl-page-anchor"></div>')
|
|
@@ -480,10 +571,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
|
|
| 480 |
"""
|
| 481 |
)
|
| 482 |
generate_whatsapp_button.click(
|
| 483 |
-
fn=lambda:
|
| 484 |
-
db_path=db_path,
|
| 485 |
-
low_stock_threshold=LOW_STOCK_THRESHOLD,
|
| 486 |
-
),
|
| 487 |
inputs=None,
|
| 488 |
outputs=whatsapp_summary_output,
|
| 489 |
)
|
|
@@ -586,6 +674,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
|
|
| 586 |
outputs=[
|
| 587 |
status_output,
|
| 588 |
receipt_output,
|
|
|
|
| 589 |
total_sales_output,
|
| 590 |
total_expenses_output,
|
| 591 |
net_profit_output,
|
|
@@ -594,6 +683,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
|
|
| 594 |
timeline_output,
|
| 595 |
top_items_output,
|
| 596 |
low_stock_output,
|
|
|
|
| 597 |
ledger_output,
|
| 598 |
customer_balances_output,
|
| 599 |
inventory_output,
|
|
@@ -627,6 +717,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
|
|
| 627 |
],
|
| 628 |
outputs=[
|
| 629 |
edit_status_output,
|
|
|
|
| 630 |
total_sales_output,
|
| 631 |
total_expenses_output,
|
| 632 |
net_profit_output,
|
|
@@ -635,6 +726,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
|
|
| 635 |
timeline_output,
|
| 636 |
top_items_output,
|
| 637 |
low_stock_output,
|
|
|
|
| 638 |
ledger_output,
|
| 639 |
customer_balances_output,
|
| 640 |
inventory_output,
|
|
@@ -645,6 +737,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
|
|
| 645 |
inputs=edit_transaction_id,
|
| 646 |
outputs=[
|
| 647 |
edit_status_output,
|
|
|
|
| 648 |
total_sales_output,
|
| 649 |
total_expenses_output,
|
| 650 |
net_profit_output,
|
|
@@ -653,6 +746,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
|
|
| 653 |
timeline_output,
|
| 654 |
top_items_output,
|
| 655 |
low_stock_output,
|
|
|
|
| 656 |
ledger_output,
|
| 657 |
customer_balances_output,
|
| 658 |
inventory_output,
|
|
@@ -663,6 +757,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
|
|
| 663 |
inputs=None,
|
| 664 |
outputs=[
|
| 665 |
seed_demo_status,
|
|
|
|
| 666 |
total_sales_output,
|
| 667 |
total_expenses_output,
|
| 668 |
net_profit_output,
|
|
@@ -671,6 +766,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
|
|
| 671 |
timeline_output,
|
| 672 |
top_items_output,
|
| 673 |
low_stock_output,
|
|
|
|
| 674 |
ledger_output,
|
| 675 |
customer_balances_output,
|
| 676 |
inventory_output,
|
|
@@ -681,6 +777,7 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
|
|
| 681 |
inputs=None,
|
| 682 |
outputs=[
|
| 683 |
record_demo_status,
|
|
|
|
| 684 |
total_sales_output,
|
| 685 |
total_expenses_output,
|
| 686 |
net_profit_output,
|
|
@@ -689,11 +786,41 @@ def create_app(db_path: str | Path | None = None) -> gr.Blocks:
|
|
| 689 |
timeline_output,
|
| 690 |
top_items_output,
|
| 691 |
low_stock_output,
|
|
|
|
| 692 |
ledger_output,
|
| 693 |
customer_balances_output,
|
| 694 |
inventory_output,
|
| 695 |
],
|
| 696 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 697 |
record_health_button.click(
|
| 698 |
fn=lambda: _get_system_check(db_path),
|
| 699 |
inputs=None,
|
|
@@ -940,16 +1067,16 @@ def _save_transaction(transaction_payload: dict[str, Any] | None, db_path: str |
|
|
| 940 |
def _save_transaction_and_refresh(
|
| 941 |
transaction_payload: dict[str, Any] | None,
|
| 942 |
db_path: str | Path | None,
|
| 943 |
-
) -> tuple[str, str, str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.io.formats.style.Styler]:
|
| 944 |
"""Save a transaction and refresh the demo-critical data views."""
|
| 945 |
if not transaction_payload:
|
| 946 |
-
return ("Parse a transaction before saving.", _empty_receipt_card(), *_refresh_core_views(db_path))
|
| 947 |
|
| 948 |
transaction = Transaction(**transaction_payload)
|
| 949 |
transaction_id = add_transaction(transaction, db_path)
|
| 950 |
status = f"Saved transaction #{transaction_id}: {_transaction_summary(transaction)}."
|
| 951 |
receipt = _receipt_card(transaction, transaction_id, db_path)
|
| 952 |
-
return (status, receipt, *_refresh_core_views(db_path))
|
| 953 |
|
| 954 |
|
| 955 |
def _load_transaction_for_edit(
|
|
@@ -1013,11 +1140,11 @@ def _update_transaction_and_refresh(
|
|
| 1013 |
notes: str | None,
|
| 1014 |
confidence: float | None,
|
| 1015 |
db_path: str | Path | None,
|
| 1016 |
-
) -> tuple[str, str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.io.formats.style.Styler]:
|
| 1017 |
"""Update a transaction and refresh all dependent views."""
|
| 1018 |
parsed_id = _coerce_transaction_id(transaction_id)
|
| 1019 |
if parsed_id is None:
|
| 1020 |
-
return ("Enter a transaction id before updating.", *_refresh_core_views(db_path))
|
| 1021 |
|
| 1022 |
try:
|
| 1023 |
transaction = _transaction_from_edit_fields(
|
|
@@ -1032,27 +1159,27 @@ def _update_transaction_and_refresh(
|
|
| 1032 |
confidence=confidence,
|
| 1033 |
)
|
| 1034 |
except Exception as exc:
|
| 1035 |
-
return (f"Could not update transaction: {exc}", *_refresh_core_views(db_path))
|
| 1036 |
|
| 1037 |
updated = update_transaction(parsed_id, transaction, db_path)
|
| 1038 |
if not updated:
|
| 1039 |
-
return (f"Transaction #{parsed_id} was not found.", *_refresh_core_views(db_path))
|
| 1040 |
-
return (f"Updated transaction #{parsed_id}: {_transaction_summary(transaction)}.", *_refresh_core_views(db_path))
|
| 1041 |
|
| 1042 |
|
| 1043 |
def _delete_transaction_and_refresh(
|
| 1044 |
transaction_id: float | int | None,
|
| 1045 |
db_path: str | Path | None,
|
| 1046 |
-
) -> tuple[str, str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.io.formats.style.Styler]:
|
| 1047 |
"""Delete a transaction and refresh all dependent views."""
|
| 1048 |
parsed_id = _coerce_transaction_id(transaction_id)
|
| 1049 |
if parsed_id is None:
|
| 1050 |
-
return ("Enter a transaction id before deleting.", *_refresh_core_views(db_path))
|
| 1051 |
|
| 1052 |
deleted = delete_transaction(parsed_id, db_path)
|
| 1053 |
if not deleted:
|
| 1054 |
-
return (f"Transaction #{parsed_id} was not found.", *_refresh_core_views(db_path))
|
| 1055 |
-
return (f"Deleted transaction #{parsed_id} and refreshed balances.", *_refresh_core_views(db_path))
|
| 1056 |
|
| 1057 |
|
| 1058 |
def _export_ledger_csv(db_path: str | Path | None) -> tuple[str, str]:
|
|
@@ -1063,18 +1190,19 @@ def _export_ledger_csv(db_path: str | Path | None) -> tuple[str, str]:
|
|
| 1063 |
|
| 1064 |
def _seed_demo_transactions_and_refresh(
|
| 1065 |
db_path: str | Path | None,
|
| 1066 |
-
) -> tuple[str, str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.io.formats.style.Styler]:
|
| 1067 |
"""Seed realistic demo transactions and refresh all dependent views."""
|
| 1068 |
saved_ids = [add_transaction(local_parse_transaction(note), db_path) for note in DEMO_NOTES]
|
| 1069 |
return (
|
| 1070 |
f"Seeded {len(saved_ids)} demo transactions. Last transaction id: #{saved_ids[-1]}.",
|
|
|
|
| 1071 |
*_refresh_core_views(db_path),
|
| 1072 |
)
|
| 1073 |
|
| 1074 |
|
| 1075 |
def _refresh_core_views(
|
| 1076 |
db_path: str | Path | None,
|
| 1077 |
-
) -> tuple[str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.io.formats.style.Styler]:
|
| 1078 |
"""Return dashboard, ledger, customer, and inventory refresh values."""
|
| 1079 |
return (
|
| 1080 |
*_get_dashboard_data(db_path),
|
|
@@ -1109,34 +1237,158 @@ def _save_bulk_transactions(review_table: Any, db_path: str | Path | None) -> st
|
|
| 1109 |
return f"Saved {len(saved_ids)} transactions. Last transaction id: #{saved_ids[-1]}."
|
| 1110 |
|
| 1111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1112 |
def _get_dashboard_data(
|
| 1113 |
db_path: str | Path | None,
|
| 1114 |
-
) -> tuple[str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
| 1115 |
"""Return business insight values for the Dashboard section."""
|
|
|
|
|
|
|
|
|
|
| 1116 |
sales = calculate_daily_sales(db_path)
|
| 1117 |
expenses = calculate_daily_expenses(db_path)
|
| 1118 |
profit = calculate_net_profit(db_path)
|
| 1119 |
credit = outstanding_credit(db_path)
|
| 1120 |
top_items = top_selling_items(db_path)
|
| 1121 |
-
low_stock = low_stock_items(db_path, threshold=
|
| 1122 |
timeline = _daily_timeline(db_path)
|
|
|
|
| 1123 |
top_item = "No sales recorded today"
|
| 1124 |
if not top_items.empty:
|
| 1125 |
first_item = top_items.iloc[0]
|
| 1126 |
top_item = f"{first_item['item']} ({_format_quantity(first_item['quantity_sold'])} sold)"
|
| 1127 |
|
| 1128 |
return (
|
| 1129 |
-
_metric_card("Total Sales Today", _format_money(sales), "Recorded sales"),
|
| 1130 |
-
_metric_card("Total Expenses Today", _format_money(expenses), "Purchases and costs"),
|
| 1131 |
-
_metric_card("Net Profit", _format_money(profit), "Sales minus expenses", profit=profit),
|
| 1132 |
-
_metric_card("Outstanding Credit", _format_money(credit), "Customer dues"),
|
| 1133 |
top_item,
|
| 1134 |
timeline,
|
| 1135 |
top_items,
|
| 1136 |
low_stock,
|
|
|
|
| 1137 |
)
|
| 1138 |
|
| 1139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1140 |
def _daily_timeline(db_path: str | Path | None) -> pd.DataFrame:
|
| 1141 |
"""Return chart-ready daily sales and expense rows."""
|
| 1142 |
transactions = get_transactions(db_path)
|
|
@@ -1165,7 +1417,8 @@ def _daily_timeline(db_path: str | Path | None) -> pd.DataFrame:
|
|
| 1165 |
def _get_inventory_display(db_path: str | Path | None) -> pd.io.formats.style.Styler:
|
| 1166 |
"""Return inventory with low-stock rows highlighted for Gradio display."""
|
| 1167 |
inventory = get_inventory(db_path)
|
| 1168 |
-
|
|
|
|
| 1169 |
|
| 1170 |
|
| 1171 |
def _get_customer_detail(customer_name: str | None, db_path: str | Path | None) -> tuple[str, pd.DataFrame]:
|
|
@@ -1191,10 +1444,11 @@ def _get_customer_detail(customer_name: str | None, db_path: str | Path | None)
|
|
| 1191 |
status = "owes"
|
| 1192 |
elif balance < 0:
|
| 1193 |
status = "overpaid"
|
|
|
|
| 1194 |
summary = f"""
|
| 1195 |
<section class="vl-detail-card">
|
| 1196 |
<h2>{escape(name)} · {status}</h2>
|
| 1197 |
-
<p>Outstanding balance: <strong>{_format_money(balance)}</strong>. Credit: {_format_money(float(credit))}. Payments: {_format_money(float(paid))}.</p>
|
| 1198 |
</section>
|
| 1199 |
"""
|
| 1200 |
return summary, matches[columns].reset_index(drop=True)
|
|
@@ -1218,11 +1472,12 @@ def _get_inventory_detail(item: str | None, db_path: str | Path | None) -> tuple
|
|
| 1218 |
bought = pd.to_numeric(matches.loc[matches["transaction_type"] == "inventory_purchase", "quantity"], errors="coerce").fillna(0).sum()
|
| 1219 |
sold = pd.to_numeric(matches.loc[matches["transaction_type"] == "sale", "quantity"], errors="coerce").fillna(0).sum()
|
| 1220 |
stock = round(float(bought - sold), 2)
|
| 1221 |
-
|
|
|
|
| 1222 |
summary = f"""
|
| 1223 |
<section class="vl-detail-card">
|
| 1224 |
<h2>{escape(item_name)} · {status}</h2>
|
| 1225 |
-
<p>Bought: {_format_quantity(bought)}. Sold: {_format_quantity(sold)}. Current stock: <strong>{_format_quantity(stock)}</strong>.</p>
|
| 1226 |
</section>
|
| 1227 |
"""
|
| 1228 |
return summary, matches[columns].reset_index(drop=True)
|
|
@@ -1230,8 +1485,13 @@ def _get_inventory_detail(item: str | None, db_path: str | Path | None) -> tuple
|
|
| 1230 |
|
| 1231 |
def _generate_daily_summary_report(db_path: str | Path | None) -> tuple[str | None, str]:
|
| 1232 |
"""Generate the Daily Summary PDF for download in Gradio."""
|
|
|
|
| 1233 |
try:
|
| 1234 |
-
report_path = generate_daily_summary_pdf(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1235 |
except Exception as exc:
|
| 1236 |
return None, f"Could not generate report: {exc}"
|
| 1237 |
return str(report_path), "Daily Summary PDF is ready."
|
|
@@ -1239,16 +1499,25 @@ def _generate_daily_summary_report(db_path: str | Path | None) -> tuple[str | No
|
|
| 1239 |
|
| 1240 |
def _run_daily_closeout(db_path: str | Path | None) -> tuple[str, str | None, str | None, str, str]:
|
| 1241 |
"""Generate the daily closeout summary and exports."""
|
|
|
|
|
|
|
|
|
|
| 1242 |
sales = calculate_daily_sales(db_path)
|
| 1243 |
expenses = calculate_daily_expenses(db_path)
|
| 1244 |
profit = calculate_net_profit(db_path)
|
| 1245 |
credit = outstanding_credit(db_path)
|
| 1246 |
-
low_stock = low_stock_items(db_path, threshold=
|
| 1247 |
|
| 1248 |
pdf_path: str | None
|
| 1249 |
csv_path: str | None
|
| 1250 |
try:
|
| 1251 |
-
pdf_path = str(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1252 |
except Exception:
|
| 1253 |
pdf_path = None
|
| 1254 |
try:
|
|
@@ -1256,15 +1525,20 @@ def _run_daily_closeout(db_path: str | Path | None) -> tuple[str, str | None, st
|
|
| 1256 |
except Exception:
|
| 1257 |
csv_path = None
|
| 1258 |
|
| 1259 |
-
whatsapp = generate_whatsapp_summary(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1260 |
summary = f"""
|
| 1261 |
<section class="vl-closeout-card">
|
| 1262 |
<h2>Daily Closeout Ready</h2>
|
| 1263 |
<div class="vl-closeout-grid">
|
| 1264 |
-
<span><strong>Sales</strong>{_format_money(sales)}</span>
|
| 1265 |
-
<span><strong>Expenses</strong>{_format_money(expenses)}</span>
|
| 1266 |
-
<span><strong>Profit</strong>{_format_money(profit)}</span>
|
| 1267 |
-
<span><strong>Credit</strong>{_format_money(credit)}</span>
|
| 1268 |
</div>
|
| 1269 |
<p>{len(low_stock)} low-stock item(s). PDF, WhatsApp summary, and CSV are prepared below.</p>
|
| 1270 |
</section>
|
|
@@ -1356,18 +1630,30 @@ def _metric_card(label: str, value: str, note: str, profit: float | None = None)
|
|
| 1356 |
"""
|
| 1357 |
|
| 1358 |
|
| 1359 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1360 |
"""Format money for dashboard cards."""
|
|
|
|
| 1361 |
amount = float(value)
|
| 1362 |
if amount.is_integer():
|
| 1363 |
-
return f"
|
| 1364 |
-
return f"
|
| 1365 |
|
| 1366 |
|
| 1367 |
-
def _highlight_low_stock(row: pd.Series) -> list[str]:
|
| 1368 |
"""Highlight rows where stock is below the configured threshold."""
|
| 1369 |
current_stock = row.get("current_stock")
|
| 1370 |
-
if current_stock is not None and float(current_stock) <
|
| 1371 |
return ["background-color: #fff3cd; color: #5f370e"] * len(row)
|
| 1372 |
return [""] * len(row)
|
| 1373 |
|
|
@@ -1414,9 +1700,12 @@ def _empty_detail_card(title: str, body: str = "Select an item to see details.")
|
|
| 1414 |
|
| 1415 |
def _review_card(transaction: Transaction, source_message: str, warnings: list[str]) -> str:
|
| 1416 |
"""Render a human-friendly transaction review card."""
|
| 1417 |
-
|
| 1418 |
-
|
| 1419 |
-
|
|
|
|
|
|
|
|
|
|
| 1420 |
|
| 1421 |
fields = [
|
| 1422 |
("Type", transaction.transaction_type),
|
|
@@ -1448,6 +1737,8 @@ def _review_warnings(transaction: Transaction, db_path: str | Path | None) -> li
|
|
| 1448 |
warnings: list[str] = []
|
| 1449 |
if transaction.transaction_type == "unknown":
|
| 1450 |
warnings.append("Unknown type")
|
|
|
|
|
|
|
| 1451 |
if transaction.amount is None and transaction.transaction_type in {"sale", "expense", "customer_credit", "customer_payment"}:
|
| 1452 |
warnings.append("Missing amount")
|
| 1453 |
if transaction.transaction_type in {"customer_credit", "customer_payment"} and not transaction.customer:
|
|
@@ -1461,6 +1752,23 @@ def _review_warnings(transaction: Transaction, db_path: str | Path | None) -> li
|
|
| 1461 |
return warnings
|
| 1462 |
|
| 1463 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1464 |
def _would_make_stock_negative(transaction: Transaction, db_path: str | Path | None) -> bool:
|
| 1465 |
"""Return whether saving this sale would take inventory below zero."""
|
| 1466 |
if transaction.transaction_type != "sale" or not transaction.item or transaction.quantity is None:
|
|
@@ -1532,6 +1840,7 @@ def _status_message(
|
|
| 1532 |
) -> str:
|
| 1533 |
"""Return a human-readable parsing status."""
|
| 1534 |
parts = []
|
|
|
|
| 1535 |
if prefix:
|
| 1536 |
parts.append(prefix)
|
| 1537 |
parts.append(source_message)
|
|
@@ -1547,6 +1856,15 @@ def _status_message(
|
|
| 1547 |
return " ".join(parts)
|
| 1548 |
|
| 1549 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1550 |
def _transcription_status(result: modal_api.TranscriptionResult) -> str:
|
| 1551 |
"""Return a concise transcription source status."""
|
| 1552 |
status = result.message
|
|
|
|
| 38 |
update_transaction,
|
| 39 |
)
|
| 40 |
from voiceledger.ledger.inventory import get_inventory
|
| 41 |
+
from voiceledger.ledger.settings import get_business_settings, get_low_stock_threshold, update_business_settings
|
| 42 |
from voiceledger.parser.bulk import REVIEW_COLUMNS, parse_bulk_notes, review_table_to_transactions
|
| 43 |
from voiceledger.parser.rules import parse_transaction as local_parse_transaction
|
| 44 |
from voiceledger.parser.schema import Transaction
|
| 45 |
+
from voiceledger.reports.actions import generate_customer_followup, generate_reorder_list
|
| 46 |
from voiceledger.reports.pdf_report import generate_daily_summary_pdf
|
| 47 |
from voiceledger.reports.whatsapp_summary import generate_whatsapp_summary
|
| 48 |
from voiceledger.speech.transcribe import TranscriptionError, transcribe_audio as local_transcribe_audio
|
|
|
|
| 85 |
)
|
| 86 |
|
| 87 |
parsed_state = gr.State(value=None)
|
| 88 |
+
initial_settings = get_business_settings(db_path)
|
| 89 |
|
| 90 |
with gr.Group(elem_classes="vl-app-nav"):
|
| 91 |
gr.HTML("<strong>Sections</strong>")
|
|
|
|
| 108 |
"Use the Sections buttons above to open each workflow page. VoiceLedger is locked for the hackathon demo: core bookkeeping is frozen, with Modal/Nemotron active and local fallback ready.",
|
| 109 |
)
|
| 110 |
)
|
| 111 |
+
command_center_output = gr.HTML(_command_center(db_path))
|
| 112 |
+
gr.HTML(_section_heading("Seller Setup"))
|
| 113 |
+
with gr.Row():
|
| 114 |
+
business_name_input = gr.Textbox(
|
| 115 |
+
label="Business name",
|
| 116 |
+
value=initial_settings["business_name"],
|
| 117 |
+
elem_classes="vl-panel",
|
| 118 |
+
)
|
| 119 |
+
currency_symbol_input = gr.Textbox(
|
| 120 |
+
label="Currency label",
|
| 121 |
+
value=initial_settings["currency_symbol"],
|
| 122 |
+
elem_classes="vl-panel",
|
| 123 |
+
)
|
| 124 |
+
with gr.Row():
|
| 125 |
+
low_stock_threshold_input = gr.Number(
|
| 126 |
+
label="Low-stock threshold",
|
| 127 |
+
value=float(initial_settings["low_stock_threshold"]),
|
| 128 |
+
elem_classes="vl-panel",
|
| 129 |
+
)
|
| 130 |
+
language_style_input = gr.Dropdown(
|
| 131 |
+
choices=["English", "English + Hinglish", "English + Gujarati-lite"],
|
| 132 |
+
label="Primary language style",
|
| 133 |
+
value=initial_settings["language_style"],
|
| 134 |
+
)
|
| 135 |
+
save_settings_button = gr.Button("Save Seller Setup")
|
| 136 |
+
settings_status_output = gr.Markdown(
|
| 137 |
+
value=_seller_setup_status(initial_settings),
|
| 138 |
+
elem_classes="vl-status",
|
| 139 |
+
)
|
| 140 |
gr.HTML(_judge_demo_panel())
|
| 141 |
gr.HTML(_today_work_panel())
|
| 142 |
with gr.Row():
|
|
|
|
| 266 |
wrap=True,
|
| 267 |
elem_classes="vl-panel",
|
| 268 |
)
|
| 269 |
+
seller_day_output = gr.HTML(_seller_day_timeline(db_path))
|
| 270 |
refresh_dashboard_button.click(
|
| 271 |
fn=lambda: _get_dashboard_data(db_path),
|
| 272 |
inputs=None,
|
|
|
|
| 279 |
timeline_output,
|
| 280 |
top_items_output,
|
| 281 |
low_stock_output,
|
| 282 |
+
seller_day_output,
|
| 283 |
],
|
| 284 |
)
|
| 285 |
demo.load(
|
|
|
|
| 294 |
timeline_output,
|
| 295 |
top_items_output,
|
| 296 |
low_stock_output,
|
| 297 |
+
seller_day_output,
|
| 298 |
],
|
| 299 |
)
|
| 300 |
|
|
|
|
| 335 |
gr.HTML(_submission_story_panel())
|
| 336 |
gr.HTML(_ai_pipeline_strip())
|
| 337 |
gr.HTML(_small_model_fit_card())
|
| 338 |
+
gr.HTML(_section_heading("Field Test Notes"))
|
| 339 |
+
with gr.Row():
|
| 340 |
+
field_who_input = gr.Textbox(
|
| 341 |
+
label="Who this is for",
|
| 342 |
+
value=initial_settings["field_test_who"],
|
| 343 |
+
lines=3,
|
| 344 |
+
elem_classes="vl-panel",
|
| 345 |
+
)
|
| 346 |
+
field_tried_input = gr.Textbox(
|
| 347 |
+
label="What they tried",
|
| 348 |
+
value=initial_settings["field_test_tried"],
|
| 349 |
+
lines=3,
|
| 350 |
+
elem_classes="vl-panel",
|
| 351 |
+
)
|
| 352 |
+
field_changed_input = gr.Textbox(
|
| 353 |
+
label="What changed after feedback",
|
| 354 |
+
value=initial_settings["field_test_changed"],
|
| 355 |
+
lines=3,
|
| 356 |
+
elem_classes="vl-panel",
|
| 357 |
+
)
|
| 358 |
+
save_field_notes_button = gr.Button("Save Field Test Notes")
|
| 359 |
+
field_notes_status_output = gr.Markdown(elem_classes="vl-status")
|
| 360 |
seed_demo_button = gr.Button("Seed Demo Transactions", variant="primary")
|
| 361 |
seed_demo_status = gr.Markdown(elem_classes="vl-status")
|
| 362 |
|
|
|
|
| 439 |
inputs=customer_detail_name,
|
| 440 |
outputs=[customer_detail_summary, customer_detail_output],
|
| 441 |
)
|
| 442 |
+
gr.HTML(_section_heading("Customer Follow-up"))
|
| 443 |
+
followup_customer_name = gr.Textbox(label="Customer for WhatsApp reminder", placeholder="Amit")
|
| 444 |
+
followup_button = gr.Button("Generate Follow-up Message")
|
| 445 |
+
followup_output = gr.Textbox(
|
| 446 |
+
label="WhatsApp follow-up",
|
| 447 |
+
lines=4,
|
| 448 |
+
interactive=False,
|
| 449 |
+
elem_classes="vl-panel",
|
| 450 |
+
)
|
| 451 |
+
followup_button.click(
|
| 452 |
+
fn=lambda customer_name: generate_customer_followup(customer_name, db_path),
|
| 453 |
+
inputs=followup_customer_name,
|
| 454 |
+
outputs=followup_output,
|
| 455 |
+
)
|
| 456 |
|
| 457 |
with gr.Column(visible=False, elem_classes="vl-page-section") as inventory_page:
|
| 458 |
gr.HTML('<div id="vl-page-inventory" class="vl-page-anchor"></div>')
|
|
|
|
| 497 |
inputs=inventory_detail_item,
|
| 498 |
outputs=[inventory_detail_summary, inventory_detail_output],
|
| 499 |
)
|
| 500 |
+
gr.HTML(_section_heading("Inventory Reorder List"))
|
| 501 |
+
reorder_button = gr.Button("Generate Reorder List")
|
| 502 |
+
reorder_output = gr.Dataframe(
|
| 503 |
+
headers=["item", "current_stock", "suggested_action"],
|
| 504 |
+
label="Low-stock reorder list",
|
| 505 |
+
interactive=False,
|
| 506 |
+
wrap=True,
|
| 507 |
+
elem_classes="vl-panel",
|
| 508 |
+
)
|
| 509 |
+
reorder_message_output = gr.Textbox(
|
| 510 |
+
label="WhatsApp restock message",
|
| 511 |
+
lines=8,
|
| 512 |
+
interactive=False,
|
| 513 |
+
elem_classes="vl-panel",
|
| 514 |
+
)
|
| 515 |
+
reorder_button.click(
|
| 516 |
+
fn=lambda: generate_reorder_list(db_path),
|
| 517 |
+
inputs=None,
|
| 518 |
+
outputs=[reorder_output, reorder_message_output],
|
| 519 |
+
)
|
| 520 |
|
| 521 |
with gr.Column(visible=False, elem_classes="vl-page-section") as reports_page:
|
| 522 |
gr.HTML('<div id="vl-page-reports" class="vl-page-anchor"></div>')
|
|
|
|
| 571 |
"""
|
| 572 |
)
|
| 573 |
generate_whatsapp_button.click(
|
| 574 |
+
fn=lambda: _generate_whatsapp_summary(db_path),
|
|
|
|
|
|
|
|
|
|
| 575 |
inputs=None,
|
| 576 |
outputs=whatsapp_summary_output,
|
| 577 |
)
|
|
|
|
| 674 |
outputs=[
|
| 675 |
status_output,
|
| 676 |
receipt_output,
|
| 677 |
+
command_center_output,
|
| 678 |
total_sales_output,
|
| 679 |
total_expenses_output,
|
| 680 |
net_profit_output,
|
|
|
|
| 683 |
timeline_output,
|
| 684 |
top_items_output,
|
| 685 |
low_stock_output,
|
| 686 |
+
seller_day_output,
|
| 687 |
ledger_output,
|
| 688 |
customer_balances_output,
|
| 689 |
inventory_output,
|
|
|
|
| 717 |
],
|
| 718 |
outputs=[
|
| 719 |
edit_status_output,
|
| 720 |
+
command_center_output,
|
| 721 |
total_sales_output,
|
| 722 |
total_expenses_output,
|
| 723 |
net_profit_output,
|
|
|
|
| 726 |
timeline_output,
|
| 727 |
top_items_output,
|
| 728 |
low_stock_output,
|
| 729 |
+
seller_day_output,
|
| 730 |
ledger_output,
|
| 731 |
customer_balances_output,
|
| 732 |
inventory_output,
|
|
|
|
| 737 |
inputs=edit_transaction_id,
|
| 738 |
outputs=[
|
| 739 |
edit_status_output,
|
| 740 |
+
command_center_output,
|
| 741 |
total_sales_output,
|
| 742 |
total_expenses_output,
|
| 743 |
net_profit_output,
|
|
|
|
| 746 |
timeline_output,
|
| 747 |
top_items_output,
|
| 748 |
low_stock_output,
|
| 749 |
+
seller_day_output,
|
| 750 |
ledger_output,
|
| 751 |
customer_balances_output,
|
| 752 |
inventory_output,
|
|
|
|
| 757 |
inputs=None,
|
| 758 |
outputs=[
|
| 759 |
seed_demo_status,
|
| 760 |
+
command_center_output,
|
| 761 |
total_sales_output,
|
| 762 |
total_expenses_output,
|
| 763 |
net_profit_output,
|
|
|
|
| 766 |
timeline_output,
|
| 767 |
top_items_output,
|
| 768 |
low_stock_output,
|
| 769 |
+
seller_day_output,
|
| 770 |
ledger_output,
|
| 771 |
customer_balances_output,
|
| 772 |
inventory_output,
|
|
|
|
| 777 |
inputs=None,
|
| 778 |
outputs=[
|
| 779 |
record_demo_status,
|
| 780 |
+
command_center_output,
|
| 781 |
total_sales_output,
|
| 782 |
total_expenses_output,
|
| 783 |
net_profit_output,
|
|
|
|
| 786 |
timeline_output,
|
| 787 |
top_items_output,
|
| 788 |
low_stock_output,
|
| 789 |
+
seller_day_output,
|
| 790 |
ledger_output,
|
| 791 |
customer_balances_output,
|
| 792 |
inventory_output,
|
| 793 |
],
|
| 794 |
)
|
| 795 |
+
save_settings_button.click(
|
| 796 |
+
fn=lambda business_name, currency_symbol, low_stock_threshold, language_style: _save_seller_setup_and_refresh(
|
| 797 |
+
business_name,
|
| 798 |
+
currency_symbol,
|
| 799 |
+
low_stock_threshold,
|
| 800 |
+
language_style,
|
| 801 |
+
db_path,
|
| 802 |
+
),
|
| 803 |
+
inputs=[business_name_input, currency_symbol_input, low_stock_threshold_input, language_style_input],
|
| 804 |
+
outputs=[
|
| 805 |
+
settings_status_output,
|
| 806 |
+
command_center_output,
|
| 807 |
+
total_sales_output,
|
| 808 |
+
total_expenses_output,
|
| 809 |
+
net_profit_output,
|
| 810 |
+
outstanding_credit_output,
|
| 811 |
+
top_selling_item_output,
|
| 812 |
+
timeline_output,
|
| 813 |
+
top_items_output,
|
| 814 |
+
low_stock_output,
|
| 815 |
+
seller_day_output,
|
| 816 |
+
inventory_output,
|
| 817 |
+
],
|
| 818 |
+
)
|
| 819 |
+
save_field_notes_button.click(
|
| 820 |
+
fn=lambda who, tried, changed: _save_field_notes(who, tried, changed, db_path),
|
| 821 |
+
inputs=[field_who_input, field_tried_input, field_changed_input],
|
| 822 |
+
outputs=field_notes_status_output,
|
| 823 |
+
)
|
| 824 |
record_health_button.click(
|
| 825 |
fn=lambda: _get_system_check(db_path),
|
| 826 |
inputs=None,
|
|
|
|
| 1067 |
def _save_transaction_and_refresh(
|
| 1068 |
transaction_payload: dict[str, Any] | None,
|
| 1069 |
db_path: str | Path | None,
|
| 1070 |
+
) -> tuple[str, str, str, str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, str, pd.DataFrame, pd.DataFrame, pd.io.formats.style.Styler]:
|
| 1071 |
"""Save a transaction and refresh the demo-critical data views."""
|
| 1072 |
if not transaction_payload:
|
| 1073 |
+
return ("Parse a transaction before saving.", _empty_receipt_card(), _command_center(db_path), *_refresh_core_views(db_path))
|
| 1074 |
|
| 1075 |
transaction = Transaction(**transaction_payload)
|
| 1076 |
transaction_id = add_transaction(transaction, db_path)
|
| 1077 |
status = f"Saved transaction #{transaction_id}: {_transaction_summary(transaction)}."
|
| 1078 |
receipt = _receipt_card(transaction, transaction_id, db_path)
|
| 1079 |
+
return (status, receipt, _command_center(db_path), *_refresh_core_views(db_path))
|
| 1080 |
|
| 1081 |
|
| 1082 |
def _load_transaction_for_edit(
|
|
|
|
| 1140 |
notes: str | None,
|
| 1141 |
confidence: float | None,
|
| 1142 |
db_path: str | Path | None,
|
| 1143 |
+
) -> tuple[str, str, str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, str, pd.DataFrame, pd.DataFrame, pd.io.formats.style.Styler]:
|
| 1144 |
"""Update a transaction and refresh all dependent views."""
|
| 1145 |
parsed_id = _coerce_transaction_id(transaction_id)
|
| 1146 |
if parsed_id is None:
|
| 1147 |
+
return ("Enter a transaction id before updating.", _command_center(db_path), *_refresh_core_views(db_path))
|
| 1148 |
|
| 1149 |
try:
|
| 1150 |
transaction = _transaction_from_edit_fields(
|
|
|
|
| 1159 |
confidence=confidence,
|
| 1160 |
)
|
| 1161 |
except Exception as exc:
|
| 1162 |
+
return (f"Could not update transaction: {exc}", _command_center(db_path), *_refresh_core_views(db_path))
|
| 1163 |
|
| 1164 |
updated = update_transaction(parsed_id, transaction, db_path)
|
| 1165 |
if not updated:
|
| 1166 |
+
return (f"Transaction #{parsed_id} was not found.", _command_center(db_path), *_refresh_core_views(db_path))
|
| 1167 |
+
return (f"Updated transaction #{parsed_id}: {_transaction_summary(transaction)}.", _command_center(db_path), *_refresh_core_views(db_path))
|
| 1168 |
|
| 1169 |
|
| 1170 |
def _delete_transaction_and_refresh(
|
| 1171 |
transaction_id: float | int | None,
|
| 1172 |
db_path: str | Path | None,
|
| 1173 |
+
) -> tuple[str, str, str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, str, pd.DataFrame, pd.DataFrame, pd.io.formats.style.Styler]:
|
| 1174 |
"""Delete a transaction and refresh all dependent views."""
|
| 1175 |
parsed_id = _coerce_transaction_id(transaction_id)
|
| 1176 |
if parsed_id is None:
|
| 1177 |
+
return ("Enter a transaction id before deleting.", _command_center(db_path), *_refresh_core_views(db_path))
|
| 1178 |
|
| 1179 |
deleted = delete_transaction(parsed_id, db_path)
|
| 1180 |
if not deleted:
|
| 1181 |
+
return (f"Transaction #{parsed_id} was not found.", _command_center(db_path), *_refresh_core_views(db_path))
|
| 1182 |
+
return (f"Deleted transaction #{parsed_id} and refreshed balances.", _command_center(db_path), *_refresh_core_views(db_path))
|
| 1183 |
|
| 1184 |
|
| 1185 |
def _export_ledger_csv(db_path: str | Path | None) -> tuple[str, str]:
|
|
|
|
| 1190 |
|
| 1191 |
def _seed_demo_transactions_and_refresh(
|
| 1192 |
db_path: str | Path | None,
|
| 1193 |
+
) -> tuple[str, str, str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, str, pd.DataFrame, pd.DataFrame, pd.io.formats.style.Styler]:
|
| 1194 |
"""Seed realistic demo transactions and refresh all dependent views."""
|
| 1195 |
saved_ids = [add_transaction(local_parse_transaction(note), db_path) for note in DEMO_NOTES]
|
| 1196 |
return (
|
| 1197 |
f"Seeded {len(saved_ids)} demo transactions. Last transaction id: #{saved_ids[-1]}.",
|
| 1198 |
+
_command_center(db_path),
|
| 1199 |
*_refresh_core_views(db_path),
|
| 1200 |
)
|
| 1201 |
|
| 1202 |
|
| 1203 |
def _refresh_core_views(
|
| 1204 |
db_path: str | Path | None,
|
| 1205 |
+
) -> tuple[str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, str, pd.DataFrame, pd.DataFrame, pd.io.formats.style.Styler]:
|
| 1206 |
"""Return dashboard, ledger, customer, and inventory refresh values."""
|
| 1207 |
return (
|
| 1208 |
*_get_dashboard_data(db_path),
|
|
|
|
| 1237 |
return f"Saved {len(saved_ids)} transactions. Last transaction id: #{saved_ids[-1]}."
|
| 1238 |
|
| 1239 |
|
| 1240 |
+
def _save_seller_setup_and_refresh(
|
| 1241 |
+
business_name: str,
|
| 1242 |
+
currency_symbol: str,
|
| 1243 |
+
low_stock_threshold: float | None,
|
| 1244 |
+
language_style: str,
|
| 1245 |
+
db_path: str | Path | None,
|
| 1246 |
+
) -> tuple[str, str, str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, str, pd.io.formats.style.Styler]:
|
| 1247 |
+
"""Save seller setup and refresh context-sensitive dashboard views."""
|
| 1248 |
+
settings = update_business_settings(
|
| 1249 |
+
business_name=business_name,
|
| 1250 |
+
currency_symbol=currency_symbol,
|
| 1251 |
+
low_stock_threshold=low_stock_threshold,
|
| 1252 |
+
language_style=language_style,
|
| 1253 |
+
db_path=db_path,
|
| 1254 |
+
)
|
| 1255 |
+
return (
|
| 1256 |
+
_seller_setup_status(settings),
|
| 1257 |
+
_command_center(db_path),
|
| 1258 |
+
*_get_dashboard_data(db_path),
|
| 1259 |
+
_get_inventory_display(db_path),
|
| 1260 |
+
)
|
| 1261 |
+
|
| 1262 |
+
|
| 1263 |
+
def _save_field_notes(who: str, tried: str, changed: str, db_path: str | Path | None) -> str:
|
| 1264 |
+
"""Persist anonymized field-test notes."""
|
| 1265 |
+
update_business_settings(
|
| 1266 |
+
db_path=db_path,
|
| 1267 |
+
field_test_who=who,
|
| 1268 |
+
field_test_tried=tried,
|
| 1269 |
+
field_test_changed=changed,
|
| 1270 |
+
)
|
| 1271 |
+
return "Saved anonymized field-test notes."
|
| 1272 |
+
|
| 1273 |
+
|
| 1274 |
def _get_dashboard_data(
|
| 1275 |
db_path: str | Path | None,
|
| 1276 |
+
) -> tuple[str, str, str, str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame, str]:
|
| 1277 |
"""Return business insight values for the Dashboard section."""
|
| 1278 |
+
settings = get_business_settings(db_path)
|
| 1279 |
+
currency_symbol = settings["currency_symbol"]
|
| 1280 |
+
threshold = get_low_stock_threshold(db_path)
|
| 1281 |
sales = calculate_daily_sales(db_path)
|
| 1282 |
expenses = calculate_daily_expenses(db_path)
|
| 1283 |
profit = calculate_net_profit(db_path)
|
| 1284 |
credit = outstanding_credit(db_path)
|
| 1285 |
top_items = top_selling_items(db_path)
|
| 1286 |
+
low_stock = low_stock_items(db_path, threshold=threshold)
|
| 1287 |
timeline = _daily_timeline(db_path)
|
| 1288 |
+
seller_day = _seller_day_timeline(db_path)
|
| 1289 |
top_item = "No sales recorded today"
|
| 1290 |
if not top_items.empty:
|
| 1291 |
first_item = top_items.iloc[0]
|
| 1292 |
top_item = f"{first_item['item']} ({_format_quantity(first_item['quantity_sold'])} sold)"
|
| 1293 |
|
| 1294 |
return (
|
| 1295 |
+
_metric_card("Total Sales Today", _format_money(sales, currency_symbol), "Recorded sales"),
|
| 1296 |
+
_metric_card("Total Expenses Today", _format_money(expenses, currency_symbol), "Purchases and costs"),
|
| 1297 |
+
_metric_card("Net Profit", _format_money(profit, currency_symbol), "Sales minus expenses", profit=profit),
|
| 1298 |
+
_metric_card("Outstanding Credit", _format_money(credit, currency_symbol), "Customer dues"),
|
| 1299 |
top_item,
|
| 1300 |
timeline,
|
| 1301 |
top_items,
|
| 1302 |
low_stock,
|
| 1303 |
+
seller_day,
|
| 1304 |
)
|
| 1305 |
|
| 1306 |
|
| 1307 |
+
def _command_center(db_path: str | Path | None) -> str:
|
| 1308 |
+
"""Return the top-of-record-page seller command center."""
|
| 1309 |
+
settings = get_business_settings(db_path)
|
| 1310 |
+
currency = settings["currency_symbol"]
|
| 1311 |
+
threshold = get_low_stock_threshold(db_path)
|
| 1312 |
+
sales = calculate_daily_sales(db_path)
|
| 1313 |
+
credit = outstanding_credit(db_path)
|
| 1314 |
+
low_stock_count = len(low_stock_items(db_path, threshold=threshold))
|
| 1315 |
+
last_transaction = _last_transaction_label(db_path)
|
| 1316 |
+
return f"""
|
| 1317 |
+
<section class="vl-command-center">
|
| 1318 |
+
<h2>{escape(settings['business_name'])} Command Center</h2>
|
| 1319 |
+
<div>
|
| 1320 |
+
<span><strong>Sales today</strong>{escape(_format_money(sales, currency))}</span>
|
| 1321 |
+
<span><strong>Outstanding credit</strong>{escape(_format_money(credit, currency))}</span>
|
| 1322 |
+
<span><strong>Low-stock items</strong>{low_stock_count}</span>
|
| 1323 |
+
<span><strong>Last saved</strong>{escape(last_transaction)}</span>
|
| 1324 |
+
</div>
|
| 1325 |
+
</section>
|
| 1326 |
+
"""
|
| 1327 |
+
|
| 1328 |
+
|
| 1329 |
+
def _seller_setup_status(settings: dict[str, str]) -> str:
|
| 1330 |
+
"""Return a concise seller setup status line."""
|
| 1331 |
+
return (
|
| 1332 |
+
f"Seller setup: {settings['business_name']} · currency {settings['currency_symbol']} · "
|
| 1333 |
+
f"low stock below {settings['low_stock_threshold']} · {settings['language_style']}."
|
| 1334 |
+
)
|
| 1335 |
+
|
| 1336 |
+
|
| 1337 |
+
def _last_transaction_label(db_path: str | Path | None) -> str:
|
| 1338 |
+
"""Return a concise label for the newest transaction."""
|
| 1339 |
+
ledger = get_transactions(db_path)
|
| 1340 |
+
if ledger.empty:
|
| 1341 |
+
return "None yet"
|
| 1342 |
+
row = ledger.iloc[0]
|
| 1343 |
+
amount = row.get("amount")
|
| 1344 |
+
item_or_customer = row.get("item") or row.get("customer") or "transaction"
|
| 1345 |
+
if pd.notna(amount):
|
| 1346 |
+
return f"{row['transaction_type']} · {item_or_customer} · {_format_money(float(amount), get_business_settings(db_path)['currency_symbol'])}"
|
| 1347 |
+
return f"{row['transaction_type']} · {item_or_customer}"
|
| 1348 |
+
|
| 1349 |
+
|
| 1350 |
+
def _seller_day_timeline(db_path: str | Path | None) -> str:
|
| 1351 |
+
"""Return a visual timeline of recent seller activity."""
|
| 1352 |
+
ledger = get_transactions(db_path)
|
| 1353 |
+
if ledger.empty:
|
| 1354 |
+
return _empty_detail_card("Seller day timeline", "No saved transactions yet.")
|
| 1355 |
+
|
| 1356 |
+
rows = []
|
| 1357 |
+
currency = get_business_settings(db_path)["currency_symbol"]
|
| 1358 |
+
for _, row in ledger.head(8).iterrows():
|
| 1359 |
+
label = _transaction_type_label(str(row["transaction_type"]))
|
| 1360 |
+
subject = row.get("item") or row.get("customer") or "ledger entry"
|
| 1361 |
+
amount = row.get("amount")
|
| 1362 |
+
amount_text = ""
|
| 1363 |
+
if pd.notna(amount):
|
| 1364 |
+
amount_text = f" · {_format_money(float(amount), currency)}"
|
| 1365 |
+
rows.append(
|
| 1366 |
+
f"""
|
| 1367 |
+
<li>
|
| 1368 |
+
<strong>{escape(label)}</strong>
|
| 1369 |
+
<span>{escape(str(subject))}{escape(amount_text)}</span>
|
| 1370 |
+
</li>
|
| 1371 |
+
"""
|
| 1372 |
+
)
|
| 1373 |
+
return f"""
|
| 1374 |
+
<section class="vl-seller-timeline">
|
| 1375 |
+
<h2>Seller day timeline</h2>
|
| 1376 |
+
<ol>{''.join(rows)}</ol>
|
| 1377 |
+
</section>
|
| 1378 |
+
"""
|
| 1379 |
+
|
| 1380 |
+
|
| 1381 |
+
def _transaction_type_label(transaction_type: str) -> str:
|
| 1382 |
+
"""Return a compact human label for a transaction type."""
|
| 1383 |
+
return {
|
| 1384 |
+
"sale": "Sale",
|
| 1385 |
+
"expense": "Expense",
|
| 1386 |
+
"inventory_purchase": "Stock purchase",
|
| 1387 |
+
"customer_credit": "Customer owes",
|
| 1388 |
+
"customer_payment": "Customer paid",
|
| 1389 |
+
}.get(transaction_type, "Needs review")
|
| 1390 |
+
|
| 1391 |
+
|
| 1392 |
def _daily_timeline(db_path: str | Path | None) -> pd.DataFrame:
|
| 1393 |
"""Return chart-ready daily sales and expense rows."""
|
| 1394 |
transactions = get_transactions(db_path)
|
|
|
|
| 1417 |
def _get_inventory_display(db_path: str | Path | None) -> pd.io.formats.style.Styler:
|
| 1418 |
"""Return inventory with low-stock rows highlighted for Gradio display."""
|
| 1419 |
inventory = get_inventory(db_path)
|
| 1420 |
+
threshold = get_low_stock_threshold(db_path)
|
| 1421 |
+
return inventory.style.apply(lambda row: _highlight_low_stock(row, threshold), axis=1)
|
| 1422 |
|
| 1423 |
|
| 1424 |
def _get_customer_detail(customer_name: str | None, db_path: str | Path | None) -> tuple[str, pd.DataFrame]:
|
|
|
|
| 1444 |
status = "owes"
|
| 1445 |
elif balance < 0:
|
| 1446 |
status = "overpaid"
|
| 1447 |
+
currency = get_business_settings(db_path)["currency_symbol"]
|
| 1448 |
summary = f"""
|
| 1449 |
<section class="vl-detail-card">
|
| 1450 |
<h2>{escape(name)} · {status}</h2>
|
| 1451 |
+
<p>Outstanding balance: <strong>{_format_money(balance, currency)}</strong>. Credit: {_format_money(float(credit), currency)}. Payments: {_format_money(float(paid), currency)}.</p>
|
| 1452 |
</section>
|
| 1453 |
"""
|
| 1454 |
return summary, matches[columns].reset_index(drop=True)
|
|
|
|
| 1472 |
bought = pd.to_numeric(matches.loc[matches["transaction_type"] == "inventory_purchase", "quantity"], errors="coerce").fillna(0).sum()
|
| 1473 |
sold = pd.to_numeric(matches.loc[matches["transaction_type"] == "sale", "quantity"], errors="coerce").fillna(0).sum()
|
| 1474 |
stock = round(float(bought - sold), 2)
|
| 1475 |
+
threshold = get_low_stock_threshold(db_path)
|
| 1476 |
+
status = "low stock" if stock < threshold else "in stock"
|
| 1477 |
summary = f"""
|
| 1478 |
<section class="vl-detail-card">
|
| 1479 |
<h2>{escape(item_name)} · {status}</h2>
|
| 1480 |
+
<p>Bought: {_format_quantity(bought)}. Sold: {_format_quantity(sold)}. Current stock: <strong>{_format_quantity(stock)}</strong>. Low-stock threshold: {_format_quantity(threshold)}.</p>
|
| 1481 |
</section>
|
| 1482 |
"""
|
| 1483 |
return summary, matches[columns].reset_index(drop=True)
|
|
|
|
| 1485 |
|
| 1486 |
def _generate_daily_summary_report(db_path: str | Path | None) -> tuple[str | None, str]:
|
| 1487 |
"""Generate the Daily Summary PDF for download in Gradio."""
|
| 1488 |
+
settings = get_business_settings(db_path)
|
| 1489 |
try:
|
| 1490 |
+
report_path = generate_daily_summary_pdf(
|
| 1491 |
+
db_path=db_path,
|
| 1492 |
+
business_name=settings["business_name"],
|
| 1493 |
+
currency_symbol=settings["currency_symbol"],
|
| 1494 |
+
)
|
| 1495 |
except Exception as exc:
|
| 1496 |
return None, f"Could not generate report: {exc}"
|
| 1497 |
return str(report_path), "Daily Summary PDF is ready."
|
|
|
|
| 1499 |
|
| 1500 |
def _run_daily_closeout(db_path: str | Path | None) -> tuple[str, str | None, str | None, str, str]:
|
| 1501 |
"""Generate the daily closeout summary and exports."""
|
| 1502 |
+
settings = get_business_settings(db_path)
|
| 1503 |
+
currency = settings["currency_symbol"]
|
| 1504 |
+
threshold = get_low_stock_threshold(db_path)
|
| 1505 |
sales = calculate_daily_sales(db_path)
|
| 1506 |
expenses = calculate_daily_expenses(db_path)
|
| 1507 |
profit = calculate_net_profit(db_path)
|
| 1508 |
credit = outstanding_credit(db_path)
|
| 1509 |
+
low_stock = low_stock_items(db_path, threshold=threshold)
|
| 1510 |
|
| 1511 |
pdf_path: str | None
|
| 1512 |
csv_path: str | None
|
| 1513 |
try:
|
| 1514 |
+
pdf_path = str(
|
| 1515 |
+
generate_daily_summary_pdf(
|
| 1516 |
+
db_path=db_path,
|
| 1517 |
+
business_name=settings["business_name"],
|
| 1518 |
+
currency_symbol=currency,
|
| 1519 |
+
)
|
| 1520 |
+
)
|
| 1521 |
except Exception:
|
| 1522 |
pdf_path = None
|
| 1523 |
try:
|
|
|
|
| 1525 |
except Exception:
|
| 1526 |
csv_path = None
|
| 1527 |
|
| 1528 |
+
whatsapp = generate_whatsapp_summary(
|
| 1529 |
+
db_path=db_path,
|
| 1530 |
+
low_stock_threshold=threshold,
|
| 1531 |
+
business_name=settings["business_name"],
|
| 1532 |
+
currency_symbol=currency,
|
| 1533 |
+
)
|
| 1534 |
summary = f"""
|
| 1535 |
<section class="vl-closeout-card">
|
| 1536 |
<h2>Daily Closeout Ready</h2>
|
| 1537 |
<div class="vl-closeout-grid">
|
| 1538 |
+
<span><strong>Sales</strong>{_format_money(sales, currency)}</span>
|
| 1539 |
+
<span><strong>Expenses</strong>{_format_money(expenses, currency)}</span>
|
| 1540 |
+
<span><strong>Profit</strong>{_format_money(profit, currency)}</span>
|
| 1541 |
+
<span><strong>Credit</strong>{_format_money(credit, currency)}</span>
|
| 1542 |
</div>
|
| 1543 |
<p>{len(low_stock)} low-stock item(s). PDF, WhatsApp summary, and CSV are prepared below.</p>
|
| 1544 |
</section>
|
|
|
|
| 1630 |
"""
|
| 1631 |
|
| 1632 |
|
| 1633 |
+
def _generate_whatsapp_summary(db_path: str | Path | None) -> str:
|
| 1634 |
+
"""Generate a WhatsApp summary using seller settings."""
|
| 1635 |
+
settings = get_business_settings(db_path)
|
| 1636 |
+
return generate_whatsapp_summary(
|
| 1637 |
+
db_path=db_path,
|
| 1638 |
+
low_stock_threshold=get_low_stock_threshold(db_path),
|
| 1639 |
+
business_name=settings["business_name"],
|
| 1640 |
+
currency_symbol=settings["currency_symbol"],
|
| 1641 |
+
)
|
| 1642 |
+
|
| 1643 |
+
|
| 1644 |
+
def _format_money(value: float, currency_symbol: str | None = None) -> str:
|
| 1645 |
"""Format money for dashboard cards."""
|
| 1646 |
+
symbol = currency_symbol if currency_symbol is not None else get_business_settings()["currency_symbol"]
|
| 1647 |
amount = float(value)
|
| 1648 |
if amount.is_integer():
|
| 1649 |
+
return f"{symbol}{int(amount):,}"
|
| 1650 |
+
return f"{symbol}{amount:,.2f}"
|
| 1651 |
|
| 1652 |
|
| 1653 |
+
def _highlight_low_stock(row: pd.Series, threshold: float = LOW_STOCK_THRESHOLD) -> list[str]:
|
| 1654 |
"""Highlight rows where stock is below the configured threshold."""
|
| 1655 |
current_stock = row.get("current_stock")
|
| 1656 |
+
if current_stock is not None and float(current_stock) < threshold:
|
| 1657 |
return ["background-color: #fff3cd; color: #5f370e"] * len(row)
|
| 1658 |
return [""] * len(row)
|
| 1659 |
|
|
|
|
| 1700 |
|
| 1701 |
def _review_card(transaction: Transaction, source_message: str, warnings: list[str]) -> str:
|
| 1702 |
"""Render a human-friendly transaction review card."""
|
| 1703 |
+
readiness = _review_readiness(transaction, warnings)
|
| 1704 |
+
warning_markup = "".join(
|
| 1705 |
+
f'<span class="{_warning_badge_class(warning)}">{escape(warning)}</span>'
|
| 1706 |
+
for warning in warnings
|
| 1707 |
+
)
|
| 1708 |
+
warning_markup = f"{readiness}{warning_markup}"
|
| 1709 |
|
| 1710 |
fields = [
|
| 1711 |
("Type", transaction.transaction_type),
|
|
|
|
| 1737 |
warnings: list[str] = []
|
| 1738 |
if transaction.transaction_type == "unknown":
|
| 1739 |
warnings.append("Unknown type")
|
| 1740 |
+
if transaction.transaction_type in {"sale", "inventory_purchase"} and not transaction.item:
|
| 1741 |
+
warnings.append("Missing item")
|
| 1742 |
if transaction.amount is None and transaction.transaction_type in {"sale", "expense", "customer_credit", "customer_payment"}:
|
| 1743 |
warnings.append("Missing amount")
|
| 1744 |
if transaction.transaction_type in {"customer_credit", "customer_payment"} and not transaction.customer:
|
|
|
|
| 1752 |
return warnings
|
| 1753 |
|
| 1754 |
|
| 1755 |
+
def _review_readiness(transaction: Transaction, warnings: list[str]) -> str:
|
| 1756 |
+
"""Return the primary trust cue for a parsed transaction."""
|
| 1757 |
+
blocking_warnings = {"Unknown type", "Missing item", "Missing amount", "Missing customer", "Inventory would go negative"}
|
| 1758 |
+
if transaction.transaction_type == "inventory_purchase" and transaction.quantity is not None:
|
| 1759 |
+
blocking_warnings.discard("Missing amount")
|
| 1760 |
+
if any(warning in blocking_warnings for warning in warnings):
|
| 1761 |
+
return '<span class="vl-warning-badge vl-warning-strong">Needs review</span>'
|
| 1762 |
+
return '<span class="vl-success-badge">Safe to save</span>'
|
| 1763 |
+
|
| 1764 |
+
|
| 1765 |
+
def _warning_badge_class(warning: str) -> str:
|
| 1766 |
+
"""Return a severity-aware warning badge class."""
|
| 1767 |
+
if warning in {"Inventory would go negative", "Possible duplicate"}:
|
| 1768 |
+
return "vl-warning-badge vl-warning-strong"
|
| 1769 |
+
return "vl-warning-badge"
|
| 1770 |
+
|
| 1771 |
+
|
| 1772 |
def _would_make_stock_negative(transaction: Transaction, db_path: str | Path | None) -> bool:
|
| 1773 |
"""Return whether saving this sale would take inventory below zero."""
|
| 1774 |
if transaction.transaction_type != "sale" or not transaction.item or transaction.quantity is None:
|
|
|
|
| 1840 |
) -> str:
|
| 1841 |
"""Return a human-readable parsing status."""
|
| 1842 |
parts = []
|
| 1843 |
+
parts.append(_source_chip(source_message, fallback_reason))
|
| 1844 |
if prefix:
|
| 1845 |
parts.append(prefix)
|
| 1846 |
parts.append(source_message)
|
|
|
|
| 1856 |
return " ".join(parts)
|
| 1857 |
|
| 1858 |
|
| 1859 |
+
def _source_chip(source_message: str, fallback_reason: str | None = None) -> str:
|
| 1860 |
+
"""Return a visual parse-source chip for Markdown/HTML status output."""
|
| 1861 |
+
if fallback_reason or "local" in source_message.lower() or "fallback" in source_message.lower():
|
| 1862 |
+
return '<span class="vl-status-chip vl-status-chip-fallback">Local fallback</span>'
|
| 1863 |
+
if "modal" in source_message.lower() or "nemotron" in source_message.lower():
|
| 1864 |
+
return '<span class="vl-status-chip vl-status-chip-cloud">Cloud AI</span>'
|
| 1865 |
+
return '<span class="vl-status-chip">Parsed</span>'
|
| 1866 |
+
|
| 1867 |
+
|
| 1868 |
def _transcription_status(result: modal_api.TranscriptionResult) -> str:
|
| 1869 |
"""Return a concise transcription source status."""
|
| 1870 |
status = result.message
|
voiceledger/ui/theme.py
CHANGED
|
@@ -315,7 +315,9 @@ body,
|
|
| 315 |
.vl-detail-card,
|
| 316 |
.vl-closeout-card,
|
| 317 |
.vl-pipeline-strip,
|
| 318 |
-
.vl-small-model-card
|
|
|
|
|
|
|
| 319 |
background: #ffffff !important;
|
| 320 |
border: 1px solid var(--vl-border) !important;
|
| 321 |
border-radius: 14px !important;
|
|
@@ -330,7 +332,9 @@ body,
|
|
| 330 |
.vl-detail-card *,
|
| 331 |
.vl-closeout-card *,
|
| 332 |
.vl-pipeline-strip *,
|
| 333 |
-
.vl-small-model-card *
|
|
|
|
|
|
|
| 334 |
color: var(--vl-text) !important;
|
| 335 |
opacity: 1 !important;
|
| 336 |
}
|
|
@@ -341,7 +345,9 @@ body,
|
|
| 341 |
.vl-detail-card h2,
|
| 342 |
.vl-closeout-card h2,
|
| 343 |
.vl-pipeline-strip h2,
|
| 344 |
-
.vl-small-model-card h2
|
|
|
|
|
|
|
| 345 |
font-size: 18px !important;
|
| 346 |
font-weight: 800 !important;
|
| 347 |
margin: 0 0 10px !important;
|
|
@@ -350,7 +356,8 @@ body,
|
|
| 350 |
.vl-today-panel div,
|
| 351 |
.vl-review-grid,
|
| 352 |
.vl-closeout-grid,
|
| 353 |
-
.vl-small-model-card div
|
|
|
|
| 354 |
display: grid !important;
|
| 355 |
gap: 10px !important;
|
| 356 |
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)) !important;
|
|
@@ -359,7 +366,8 @@ body,
|
|
| 359 |
.vl-today-panel span,
|
| 360 |
.vl-review-grid div,
|
| 361 |
.vl-closeout-grid span,
|
| 362 |
-
.vl-small-model-card span
|
|
|
|
| 363 |
background: #f6f7f2 !important;
|
| 364 |
border: 1px solid var(--vl-border) !important;
|
| 365 |
border-radius: 10px !important;
|
|
@@ -370,7 +378,8 @@ body,
|
|
| 370 |
.vl-today-panel strong,
|
| 371 |
.vl-review-grid span,
|
| 372 |
.vl-closeout-grid strong,
|
| 373 |
-
.vl-small-model-card strong
|
|
|
|
| 374 |
color: #475247 !important;
|
| 375 |
display: block !important;
|
| 376 |
font-size: 12px !important;
|
|
@@ -418,6 +427,12 @@ body,
|
|
| 418 |
color: #5f370e !important;
|
| 419 |
}
|
| 420 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
.vl-success-badge {
|
| 422 |
background: #e7f6ed !important;
|
| 423 |
border: 1px solid #9bd2ad !important;
|
|
@@ -472,6 +487,58 @@ body,
|
|
| 472 |
margin-top: 4px !important;
|
| 473 |
}
|
| 474 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 475 |
.vl-chip {
|
| 476 |
background: #2c2926 !important;
|
| 477 |
border-radius: 6px !important;
|
|
|
|
| 315 |
.vl-detail-card,
|
| 316 |
.vl-closeout-card,
|
| 317 |
.vl-pipeline-strip,
|
| 318 |
+
.vl-small-model-card,
|
| 319 |
+
.vl-command-center,
|
| 320 |
+
.vl-seller-timeline {
|
| 321 |
background: #ffffff !important;
|
| 322 |
border: 1px solid var(--vl-border) !important;
|
| 323 |
border-radius: 14px !important;
|
|
|
|
| 332 |
.vl-detail-card *,
|
| 333 |
.vl-closeout-card *,
|
| 334 |
.vl-pipeline-strip *,
|
| 335 |
+
.vl-small-model-card *,
|
| 336 |
+
.vl-command-center *,
|
| 337 |
+
.vl-seller-timeline * {
|
| 338 |
color: var(--vl-text) !important;
|
| 339 |
opacity: 1 !important;
|
| 340 |
}
|
|
|
|
| 345 |
.vl-detail-card h2,
|
| 346 |
.vl-closeout-card h2,
|
| 347 |
.vl-pipeline-strip h2,
|
| 348 |
+
.vl-small-model-card h2,
|
| 349 |
+
.vl-command-center h2,
|
| 350 |
+
.vl-seller-timeline h2 {
|
| 351 |
font-size: 18px !important;
|
| 352 |
font-weight: 800 !important;
|
| 353 |
margin: 0 0 10px !important;
|
|
|
|
| 356 |
.vl-today-panel div,
|
| 357 |
.vl-review-grid,
|
| 358 |
.vl-closeout-grid,
|
| 359 |
+
.vl-small-model-card div,
|
| 360 |
+
.vl-command-center div {
|
| 361 |
display: grid !important;
|
| 362 |
gap: 10px !important;
|
| 363 |
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)) !important;
|
|
|
|
| 366 |
.vl-today-panel span,
|
| 367 |
.vl-review-grid div,
|
| 368 |
.vl-closeout-grid span,
|
| 369 |
+
.vl-small-model-card span,
|
| 370 |
+
.vl-command-center span {
|
| 371 |
background: #f6f7f2 !important;
|
| 372 |
border: 1px solid var(--vl-border) !important;
|
| 373 |
border-radius: 10px !important;
|
|
|
|
| 378 |
.vl-today-panel strong,
|
| 379 |
.vl-review-grid span,
|
| 380 |
.vl-closeout-grid strong,
|
| 381 |
+
.vl-small-model-card strong,
|
| 382 |
+
.vl-command-center strong {
|
| 383 |
color: #475247 !important;
|
| 384 |
display: block !important;
|
| 385 |
font-size: 12px !important;
|
|
|
|
| 427 |
color: #5f370e !important;
|
| 428 |
}
|
| 429 |
|
| 430 |
+
.vl-warning-strong {
|
| 431 |
+
background: #f8d7da !important;
|
| 432 |
+
border-color: #d98b91 !important;
|
| 433 |
+
color: #651922 !important;
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
.vl-success-badge {
|
| 437 |
background: #e7f6ed !important;
|
| 438 |
border: 1px solid #9bd2ad !important;
|
|
|
|
| 487 |
margin-top: 4px !important;
|
| 488 |
}
|
| 489 |
|
| 490 |
+
.vl-seller-timeline ol {
|
| 491 |
+
display: grid !important;
|
| 492 |
+
gap: 8px !important;
|
| 493 |
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)) !important;
|
| 494 |
+
list-style: none !important;
|
| 495 |
+
margin: 0 !important;
|
| 496 |
+
padding: 0 !important;
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
.vl-seller-timeline li {
|
| 500 |
+
background: #f6f7f2 !important;
|
| 501 |
+
border: 1px solid var(--vl-border) !important;
|
| 502 |
+
border-left: 5px solid #4aa357 !important;
|
| 503 |
+
border-radius: 10px !important;
|
| 504 |
+
padding: 10px !important;
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
.vl-seller-timeline li strong,
|
| 508 |
+
.vl-seller-timeline li span {
|
| 509 |
+
display: block !important;
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
.vl-seller-timeline li span {
|
| 513 |
+
color: #475247 !important;
|
| 514 |
+
font-size: 13px !important;
|
| 515 |
+
margin-top: 4px !important;
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
.vl-status-chip {
|
| 519 |
+
background: #eef6ed !important;
|
| 520 |
+
border: 1px solid #b9d4b4 !important;
|
| 521 |
+
border-radius: 999px !important;
|
| 522 |
+
color: #183525 !important;
|
| 523 |
+
display: inline-block !important;
|
| 524 |
+
font-size: 12px !important;
|
| 525 |
+
font-weight: 900 !important;
|
| 526 |
+
margin-right: 6px !important;
|
| 527 |
+
padding: 4px 9px !important;
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
.vl-status-chip-cloud {
|
| 531 |
+
background: #e8f0ff !important;
|
| 532 |
+
border-color: #9eb8eb !important;
|
| 533 |
+
color: #173c73 !important;
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
.vl-status-chip-fallback {
|
| 537 |
+
background: #fff3cd !important;
|
| 538 |
+
border-color: #e3bc5b !important;
|
| 539 |
+
color: #5f370e !important;
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
.vl-chip {
|
| 543 |
background: #2c2926 !important;
|
| 544 |
border-radius: 6px !important;
|