lambda-erp 0.8.30__tar.gz → 0.8.31__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/PKG-INFO +10 -10
  2. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/README.md +9 -8
  3. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/chat.py +93 -63
  4. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/demo_limits.py +1 -1
  5. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/providers.py +20 -41
  6. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/admin.py +2 -2
  7. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/analytics.py +138 -6
  8. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/pyproject.toml +1 -2
  9. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/terraform/README.md +11 -14
  10. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/.gitignore +0 -0
  11. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/LICENSE +0 -0
  12. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/__init__.py +0 -0
  13. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/attachments.py +0 -0
  14. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/auth.py +0 -0
  15. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/bootstrap.py +0 -0
  16. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/deps.py +0 -0
  17. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/errors.py +0 -0
  18. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/list_values.py +0 -0
  19. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/main.py +0 -0
  20. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/oauth.py +0 -0
  21. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/pdf.py +0 -0
  22. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/remarks_md.py +0 -0
  23. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/__init__.py +0 -0
  24. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/accounting.py +0 -0
  25. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/actions.py +0 -0
  26. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/availability.py +0 -0
  27. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/bank_reconciliation.py +0 -0
  28. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/bank_statements.py +0 -0
  29. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/chat_api.py +0 -0
  30. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/documents.py +0 -0
  31. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/masters.py +0 -0
  32. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/mcp.py +0 -0
  33. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/proposals.py +0 -0
  34. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/reports.py +0 -0
  35. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/routers/setup.py +0 -0
  36. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/services.py +0 -0
  37. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/templates/document.html +0 -0
  38. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/templates/proposal.html +0 -0
  39. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/api/tool_permissions.py +0 -0
  40. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/docs/agents/README.md +0 -0
  41. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/frontend/LICENSE +0 -0
  42. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/frontend/README.md +0 -0
  43. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/frontend/src/api/client.ts +0 -0
  44. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/__init__.py +0 -0
  45. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/__init__.py +0 -0
  46. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/bank_account.py +0 -0
  47. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/bank_reconciliation.py +0 -0
  48. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/bank_statement_import.py +0 -0
  49. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/bank_transaction.py +0 -0
  50. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/budget.py +0 -0
  51. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/camt.py +0 -0
  52. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  53. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/general_ledger.py +0 -0
  54. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/journal_entry.py +0 -0
  55. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/payment_entry.py +0 -0
  56. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/pos_invoice.py +0 -0
  57. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/purchase_invoice.py +0 -0
  58. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/revaluation.py +0 -0
  59. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/sales_invoice.py +0 -0
  60. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/__init__.py +0 -0
  61. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/engine.py +0 -0
  62. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/pack.py +0 -0
  63. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  64. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  65. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  66. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  67. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  68. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  69. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/profiles.py +0 -0
  70. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/spine.py +0 -0
  71. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/accounting/subscription.py +0 -0
  72. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/assets/__init__.py +0 -0
  73. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/assets/asset.py +0 -0
  74. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/assets/reservation.py +0 -0
  75. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/buying/__init__.py +0 -0
  76. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/buying/purchase_order.py +0 -0
  77. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/controllers/__init__.py +0 -0
  78. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/controllers/currency.py +0 -0
  79. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/controllers/defaults.py +0 -0
  80. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/controllers/pricing_rule.py +0 -0
  81. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  82. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/database.py +0 -0
  83. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/exceptions.py +0 -0
  84. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/hooks.py +0 -0
  85. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/model.py +0 -0
  86. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/selling/__init__.py +0 -0
  87. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/selling/proposal.py +0 -0
  88. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/selling/quotation.py +0 -0
  89. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/selling/sales_order.py +0 -0
  90. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/simulation.py +0 -0
  91. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/stock/__init__.py +0 -0
  92. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/stock/delivery_note.py +0 -0
  93. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/stock/purchase_receipt.py +0 -0
  94. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/stock/stock_entry.py +0 -0
  95. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/stock/stock_ledger.py +0 -0
  96. {lambda_erp-0.8.30 → lambda_erp-0.8.31}/lambda_erp/utils.py +0 -0
@@ -1,12 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 0.8.30
3
+ Version: 0.8.31
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
7
7
  License-File: LICENSE
8
8
  Requires-Python: >=3.10
9
- Requires-Dist: anthropic>=0.40
10
9
  Requires-Dist: bcrypt>=5
11
10
  Requires-Dist: defusedxml>=0.7
12
11
  Requires-Dist: fastapi>=0.115
@@ -96,8 +95,8 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
96
95
  │ - GPT-5.6 drives the reasoning loop │
97
96
  │ - Tool-use: document CRUD, search, │
98
97
  │ reports, aggregations, analytics │
99
- │ - Delegates report specs to Anthropic │
100
- │ report-specialist model │
98
+ │ - Delegates report specs to a separate │
99
+ │ GPT-5.6 Terra specialist call │
101
100
  └──────────────────┬───────────────────────┘
102
101
  │
103
102
  ▼
@@ -133,7 +132,7 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
133
132
  | Frontend | React + Vite + TypeScript + Tailwind + Recharts |
134
133
  | Chat transport | WebSocket |
135
134
  | LLM orchestrator | OpenAI (configurable) |
136
- | Code specialist | Anthropic (configurable) |
135
+ | Report specialist | OpenAI GPT-5.6 Terra (configurable) |
137
136
  | Auth | JWT httponly cookie, three roles + demo |
138
137
 
139
138
  ---
@@ -217,7 +216,7 @@ You need Docker and Compose v2. The canonical installs:
217
216
  Then, from the repo root:
218
217
 
219
218
  ```bash
220
- cp .env.example .env # add your OPENAI_API_KEY and ANTHROPIC_API_KEY (for custom analytics)
219
+ cp .env.example .env # add your OPENAI_API_KEY
221
220
  docker compose up --build
222
221
  ```
223
222
 
@@ -262,14 +261,15 @@ Open `http://localhost:5173`. Vite proxies `/api/*` to the backend.
262
261
 
263
262
  ```
264
263
  OPENAI_API_KEY=sk-...
265
- ANTHROPIC_API_KEY=sk-ant-... # optional, used for the report-specialist model
266
- ANTHROPIC_CODE_MODEL=claude-opus-4-7 # optional, default shown
264
+ LAMBDA_ERP_REPORT_MODEL=gpt-5.6-terra # optional specialist override; default shown
267
265
  LAMBDA_ERP_ADMIN_EMAIL=admin@example.com # optional, seeds the admin at boot
268
266
  LAMBDA_ERP_ADMIN_PASSWORD=... # optional, required with the line above
269
267
  LAMBDA_ERP_ADMIN_NAME=Administrator # optional, display name for the seeded admin
270
268
  ```
271
269
 
272
- Chat needs `OPENAI_API_KEY`. Custom-report specification generation uses `ANTHROPIC_API_KEY` when set; otherwise the chat will tell you it can't generate reports.
270
+ Chat and custom-report specification generation share `OPENAI_API_KEY`. Both
271
+ the orchestrator and report specialist default to GPT-5.6 Terra; the specialist
272
+ can be overridden with `LAMBDA_ERP_REPORT_MODEL`.
273
273
 
274
274
  **Seeded admin.** By default the first person to register becomes the admin. Set `LAMBDA_ERP_ADMIN_EMAIL` + `LAMBDA_ERP_ADMIN_PASSWORD` to instead provision that admin automatically at startup — useful when the database is recreated on every deploy, so a redeployed instance can't be claimed by whoever visits first. It's create-if-missing and idempotent (an existing account with that email is promoted to an enabled admin; its password is left untouched), and the password is only ever read from the environment.
275
275
 
@@ -487,4 +487,4 @@ Release notes live in [CHANGELOG.md](./CHANGELOG.md). Releases are tagged
487
487
 
488
488
  ## Trademarks and affiliations
489
489
 
490
- Lambda ERP and [lambda.dev](https://lambda.dev/) are product and trade names of **TORUS INVESTMENTS AG**. It is not affiliated with, endorsed by, or sponsored by OpenAI, Anthropic, SAP, Oracle, Microsoft, or any other company named in this repository. SAP, Business One, S/4HANA, Oracle, NetSuite, Microsoft, Dynamics, OpenAI, GPT, Anthropic, and Claude are trademarks of their respective owners and are referenced here only for descriptive and comparative purposes (nominative fair use). We interoperate with OpenAI and Anthropic APIs as a customer like anyone else; you supply your own API keys.
490
+ Lambda ERP and [lambda.dev](https://lambda.dev/) are product and trade names of **TORUS INVESTMENTS AG**. It is not affiliated with, endorsed by, or sponsored by OpenAI, Anthropic, SAP, Oracle, Microsoft, or any other company named in this repository. SAP, Business One, S/4HANA, Oracle, NetSuite, Microsoft, Dynamics, OpenAI, GPT, Anthropic, and Claude are trademarks of their respective owners and are referenced here only for descriptive and comparative purposes (nominative fair use). The ERP uses the OpenAI API as a customer; you supply your own API key.
@@ -67,8 +67,8 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
67
67
  │ - GPT-5.6 drives the reasoning loop │
68
68
  │ - Tool-use: document CRUD, search, │
69
69
  │ reports, aggregations, analytics │
70
- │ - Delegates report specs to Anthropic │
71
- │ report-specialist model │
70
+ │ - Delegates report specs to a separate │
71
+ │ GPT-5.6 Terra specialist call │
72
72
  └──────────────────┬───────────────────────┘
73
73
  │
74
74
  ▼
@@ -104,7 +104,7 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
104
104
  | Frontend | React + Vite + TypeScript + Tailwind + Recharts |
105
105
  | Chat transport | WebSocket |
106
106
  | LLM orchestrator | OpenAI (configurable) |
107
- | Code specialist | Anthropic (configurable) |
107
+ | Report specialist | OpenAI GPT-5.6 Terra (configurable) |
108
108
  | Auth | JWT httponly cookie, three roles + demo |
109
109
 
110
110
  ---
@@ -188,7 +188,7 @@ You need Docker and Compose v2. The canonical installs:
188
188
  Then, from the repo root:
189
189
 
190
190
  ```bash
191
- cp .env.example .env # add your OPENAI_API_KEY and ANTHROPIC_API_KEY (for custom analytics)
191
+ cp .env.example .env # add your OPENAI_API_KEY
192
192
  docker compose up --build
193
193
  ```
194
194
 
@@ -233,14 +233,15 @@ Open `http://localhost:5173`. Vite proxies `/api/*` to the backend.
233
233
 
234
234
  ```
235
235
  OPENAI_API_KEY=sk-...
236
- ANTHROPIC_API_KEY=sk-ant-... # optional, used for the report-specialist model
237
- ANTHROPIC_CODE_MODEL=claude-opus-4-7 # optional, default shown
236
+ LAMBDA_ERP_REPORT_MODEL=gpt-5.6-terra # optional specialist override; default shown
238
237
  LAMBDA_ERP_ADMIN_EMAIL=admin@example.com # optional, seeds the admin at boot
239
238
  LAMBDA_ERP_ADMIN_PASSWORD=... # optional, required with the line above
240
239
  LAMBDA_ERP_ADMIN_NAME=Administrator # optional, display name for the seeded admin
241
240
  ```
242
241
 
243
- Chat needs `OPENAI_API_KEY`. Custom-report specification generation uses `ANTHROPIC_API_KEY` when set; otherwise the chat will tell you it can't generate reports.
242
+ Chat and custom-report specification generation share `OPENAI_API_KEY`. Both
243
+ the orchestrator and report specialist default to GPT-5.6 Terra; the specialist
244
+ can be overridden with `LAMBDA_ERP_REPORT_MODEL`.
244
245
 
245
246
  **Seeded admin.** By default the first person to register becomes the admin. Set `LAMBDA_ERP_ADMIN_EMAIL` + `LAMBDA_ERP_ADMIN_PASSWORD` to instead provision that admin automatically at startup — useful when the database is recreated on every deploy, so a redeployed instance can't be claimed by whoever visits first. It's create-if-missing and idempotent (an existing account with that email is promoted to an enabled admin; its password is left untouched), and the password is only ever read from the environment.
246
247
 
@@ -458,4 +459,4 @@ Release notes live in [CHANGELOG.md](./CHANGELOG.md). Releases are tagged
458
459
 
459
460
  ## Trademarks and affiliations
460
461
 
461
- Lambda ERP and [lambda.dev](https://lambda.dev/) are product and trade names of **TORUS INVESTMENTS AG**. It is not affiliated with, endorsed by, or sponsored by OpenAI, Anthropic, SAP, Oracle, Microsoft, or any other company named in this repository. SAP, Business One, S/4HANA, Oracle, NetSuite, Microsoft, Dynamics, OpenAI, GPT, Anthropic, and Claude are trademarks of their respective owners and are referenced here only for descriptive and comparative purposes (nominative fair use). We interoperate with OpenAI and Anthropic APIs as a customer like anyone else; you supply your own API keys.
462
+ Lambda ERP and [lambda.dev](https://lambda.dev/) are product and trade names of **TORUS INVESTMENTS AG**. It is not affiliated with, endorsed by, or sponsored by OpenAI, Anthropic, SAP, Oracle, Microsoft, or any other company named in this repository. SAP, Business One, S/4HANA, Oracle, NetSuite, Microsoft, Dynamics, OpenAI, GPT, Anthropic, and Claude are trademarks of their respective owners and are referenced here only for descriptive and comparative purposes (nominative fair use). The ERP uses the OpenAI API as a customer; you supply your own API key.
@@ -35,7 +35,7 @@ from api.demo_limits import (
35
35
  is_demo_role,
36
36
  limiter as demo_limiter,
37
37
  )
38
- from api.providers import cost_of_anthropic_call, cost_of_openai_call, cost_of_transcription
38
+ from api.providers import cost_of_openai_call, cost_of_transcription
39
39
  from api.routers.masters import create_master_record, update_master_record, delete_master_record, MASTER_IDENTITY_ALIAS
40
40
  from lambda_erp.database import get_db
41
41
  from lambda_erp.utils import flt, now, nowdate
@@ -985,7 +985,11 @@ TOOLS = [
985
985
  "- `ap_open_items` — outstanding purchase invoice amounts\n"
986
986
  "- `stock_balances` — current stock on hand by item × warehouse\n"
987
987
  "- `stock_movements` — stock ledger entries (movement history); "
988
- "`abs_qty` = units moved (use `sum`), `actual_qty` = net flow\n\n"
988
+ "`abs_qty` = units moved (use `sum`), `actual_qty` = net flow\n"
989
+ "- `gl_entries` — posted ledger rows; use `income_amount` and "
990
+ "`expense_amount` for P&L analysis\n"
991
+ "- `bank_transactions` — imported cash movements; use `deposit` "
992
+ "and `withdrawal` for bank inflow/outflow analysis\n\n"
989
993
  "Shape your call like: `{dataset, group_by, measures, filters?, "
990
994
  "order_by?, limit?}`. `group_by` is a list of field names. "
991
995
  "`measures` is an object of `{alias: [op, field]}` where `op` "
@@ -1025,6 +1029,9 @@ TOOLS = [
1025
1029
  "ar_open_items",
1026
1030
  "ap_open_items",
1027
1031
  "stock_balances",
1032
+ "stock_movements",
1033
+ "gl_entries",
1034
+ "bank_transactions",
1028
1035
  ],
1029
1036
  },
1030
1037
  "group_by": {
@@ -1067,10 +1074,11 @@ TOOLS = [
1067
1074
  "Available semantic datasets include: sales_invoices, "
1068
1075
  "sales_invoice_lines, purchase_invoices, purchase_invoice_lines, "
1069
1076
  "payments, ar_open_items, ap_open_items, stock_balances, "
1070
- "stock_movements.\n\n"
1077
+ "stock_movements, gl_entries, bank_transactions.\n\n"
1071
1078
  "The report language supports grouped dimensions, month/quarter/year "
1072
1079
  "date buckets, sum/count/avg/min/max measures and KPIs, sorting, "
1073
- "limits, tables, and bar/line/pie charts. Request only fields "
1080
+ "limits, tables, and single- or multi-series bar/line charts plus "
1081
+ "pie charts. Request only fields "
1074
1082
  "exposed by dataset metadata. Always include the returned URL."
1075
1083
  ),
1076
1084
  "parameters": {
@@ -1105,6 +1113,9 @@ TOOLS = [
1105
1113
  "ar_open_items",
1106
1114
  "ap_open_items",
1107
1115
  "stock_balances",
1116
+ "stock_movements",
1117
+ "gl_entries",
1118
+ "bank_transactions",
1108
1119
  ],
1109
1120
  },
1110
1121
  "fields": {"type": "array", "items": {"type": "string"}},
@@ -1153,7 +1164,7 @@ TOOLS = [
1153
1164
  "sales_invoice_lines; use amount instead\", or \"add a line "
1154
1165
  "chart showing monthly trend\"). The backend will hand both "
1155
1166
  "the current draft and your feedback to the report specialist "
1156
- "model (Anthropic) to rewrite the declarative spec.\n\n"
1167
+ "model to rewrite the declarative spec.\n\n"
1157
1168
  "Only pass `data_requests` or `report` directly if you already "
1158
1169
  "have a complete version-1 declarative definition."
1159
1170
  ),
@@ -1188,6 +1199,9 @@ TOOLS = [
1188
1199
  "ar_open_items",
1189
1200
  "ap_open_items",
1190
1201
  "stock_balances",
1202
+ "stock_movements",
1203
+ "gl_entries",
1204
+ "bank_transactions",
1191
1205
  ],
1192
1206
  },
1193
1207
  "fields": {"type": "array", "items": {"type": "string"}},
@@ -2100,17 +2114,18 @@ def _handle_query_dataset(args):
2100
2114
  def _handle_create_custom_analytics_report(args, user_info: dict | None = None, session_id: str | None = None, client_ip: str | None = None):
2101
2115
  from api.routers.analytics import create_report_draft_record
2102
2116
 
2103
- # GPT may pass intent + (optionally) a sketch. If the code spec is
2104
- # missing, delegate to the Anthropic report-spec specialist.
2117
+ # GPT may pass intent + (optionally) a sketch. If the declarative spec is
2118
+ # missing, delegate to the report-spec specialist.
2105
2119
  if not args.get("report") or not args.get("data_requests"):
2106
2120
  intent = args.get("intent") or args.get("description") or args.get("title")
2107
2121
  if not intent:
2108
2122
  return {"error": "Provide either `intent` or a complete spec (data_requests + report)."}
2109
2123
  try:
2110
- spec = _generate_report_spec_via_anthropic(
2124
+ spec = _generate_report_spec_via_openai(
2111
2125
  intent,
2112
2126
  client_ip=client_ip,
2113
2127
  user_role=(user_info or {}).get("role"),
2128
+ session_id=session_id,
2114
2129
  )
2115
2130
  except Exception as e:
2116
2131
  return {"error": f"Report specialist failed: {e}"}
@@ -2138,7 +2153,12 @@ def _handle_get_custom_analytics_report(args, user_info: dict | None = None):
2138
2153
  return row
2139
2154
 
2140
2155
 
2141
- def _handle_update_custom_analytics_report(args, user_info: dict | None = None, client_ip: str | None = None):
2156
+ def _handle_update_custom_analytics_report(
2157
+ args,
2158
+ user_info: dict | None = None,
2159
+ client_ip: str | None = None,
2160
+ session_id: str | None = None,
2161
+ ):
2142
2162
  from api.routers.analytics import get_report_draft_record, update_report_draft_record
2143
2163
 
2144
2164
  report_id = args.get("report_id")
@@ -2154,12 +2174,13 @@ def _handle_update_custom_analytics_report(args, user_info: dict | None = None,
2154
2174
  if not existing:
2155
2175
  return {"error": f"Report draft '{report_id}' not found"}
2156
2176
  try:
2157
- spec = _generate_report_spec_via_anthropic(
2177
+ spec = _generate_report_spec_via_openai(
2158
2178
  intent=existing.get("description") or existing.get("title") or "",
2159
2179
  existing_spec=existing,
2160
2180
  feedback=feedback,
2161
2181
  client_ip=client_ip,
2162
2182
  user_role=(user_info or {}).get("role"),
2183
+ session_id=session_id,
2163
2184
  )
2164
2185
  except Exception as e:
2165
2186
  return {"error": f"Report specialist failed: {e}"}
@@ -2441,11 +2462,11 @@ Only state that something was done — created, changed, enabled/disabled, booke
2441
2462
 
2442
2463
  **Path 2: aggregated facts → `query_dataset`.** For "who is our top customer by revenue", "total sales this month", "outstanding AR by customer", "average invoice size", "count of POs per supplier" — anything that requires summing, counting, ranking, or grouping across many rows — call `query_dataset`. It runs a deterministic SQL aggregation server-side and returns the actual aggregated numbers you can cite in chat. NEVER try to compute a top-N or sum by eyeballing a `list_documents` sample — it defaults to 20 rows and will give a wrong answer on any meaningful dataset.
2443
2464
 
2444
- **Path 3: charts / complex reports → `create_custom_analytics_report`.** Only call this when the user **explicitly asks for** a chart, graph, visualization, dashboard, trend, pivot, breakdown, or saved report — or when the analysis genuinely combines multiple datasets (e.g. purchases vs sales joined by month). Do **not** invoke it for a factual question `query_dataset` could answer in one call.
2465
+ **Path 3: charts / complex reports → `create_custom_analytics_report`.** Only call this when the user **explicitly asks for** a chart, graph, visualization, dashboard, trend, pivot, breakdown, or saved report — or when the analysis genuinely needs a reusable report. Do **not** invoke it for a factual question `query_dataset` could answer in one call.
2445
2466
 
2446
- Important constraint on the analytics report tool: the JS transform runs **client-side only** — you never see its output. So you cannot "open the report to read the numbers." If the user asks you to summarise or interpret the result after the fact, tell them you don't have access to the executed data and either (a) re-answer via `query_dataset`, or (b) ask them what they see on the page. Never claim you'll look at the report yourself.
2467
+ Important constraint on the analytics report tool: its bounded declarative specification runs **client-side only** — you never see its output. So you cannot "open the report to read the numbers." If the user asks you to summarise or interpret the result after the fact, re-answer via `query_dataset` or a standard financial report; do not infer data coverage from an empty invoice dataset when GL or bank data may exist.
2447
2468
 
2448
- When you do build a custom report, pass a plain-language `intent` to `create_custom_analytics_report` (a specialist model writes the code for you) and reply with the returned `/reports/analytics?report_id=…` link as a markdown link. The draft appears under **Custom Analytics** in the sidebar so the user can reopen or share it.
2469
+ When you do build a custom report, pass a plain-language `intent` to `create_custom_analytics_report` (a specialist model writes the bounded declarative spec for you) and reply with the returned `/reports/analytics?report_id=…` link as a markdown link. The draft appears under **Custom Analytics** in the sidebar so the user can reopen or share it.
2449
2470
 
2450
2471
  {markdown_links_section}
2451
2472
 
@@ -2675,9 +2696,9 @@ Master records CAN be deleted — use the `delete_master` tool (ADMIN role only)
2675
2696
 
2676
2697
  When you do call `create_custom_analytics_report`, pass `title` and
2677
2698
  `intent` — a clear plain-language description (filters, groupings, sort
2678
- orders, chart type). A specialist model writes the JS transform for you.
2699
+ orders, chart type). A specialist model writes the declarative report spec for you.
2679
2700
  Relay the returned `/reports/analytics?report_id=…` link as a markdown
2680
- link. You will NOT see the executed data — the transform runs in the
2701
+ link. You will NOT see the rendered data — the declarative spec runs in the
2681
2702
  user's browser. So do not promise to "read" or "interpret" the report
2682
2703
  yourself; if the user wants a narrative answer, use `list_documents`
2683
2704
  instead.
@@ -2762,33 +2783,20 @@ Users can attach PDFs and images (receipts, bills, contracts, screenshots) to th
2762
2783
 
2763
2784
 
2764
2785
  # ---------------------------------------------------------------------------
2765
- # Anthropic report-spec specialist
2786
+ # OpenAI report-spec specialist
2766
2787
  #
2767
- # GPT-5.4 stays as the planner/orchestrator. When it decides a report needs
2788
+ # GPT-5.6 Terra stays as the planner/orchestrator. When it decides a report needs
2768
2789
  # a custom analytics view it calls the create/update custom analytics tools.
2769
- # Those handlers delegate the bounded declarative spec to Anthropic. The
2770
- # `ANTHROPIC_CODE_MODEL` environment name is retained for deployment backwards
2771
- # compatibility even though executable report code is no longer accepted.
2790
+ # Those handlers make a separate, narrowly prompted Terra call for the bounded
2791
+ # declarative report spec. Both roles use the existing OPENAI_API_KEY.
2772
2792
  # ---------------------------------------------------------------------------
2773
2793
 
2774
2794
 
2775
- def _code_model() -> str:
2776
- return (
2777
- os.environ.get("ANTHROPIC_CODE_MODEL")
2778
- or os.environ.get("ANTHROPIC_REPORT_REPAIR_MODEL") # legacy fallback
2779
- or "claude-sonnet-4-20250514"
2780
- )
2795
+ REPORT_SPECIALIST_MODEL = "gpt-5.6-terra"
2781
2796
 
2782
2797
 
2783
- def _anthropic_available() -> tuple[bool, str]:
2784
- api_key = os.environ.get("ANTHROPIC_API_KEY", "")
2785
- if not api_key or api_key == "your-anthropic-key-here":
2786
- return False, "ANTHROPIC_API_KEY is not configured"
2787
- try:
2788
- import anthropic # noqa: F401 # type: ignore
2789
- except Exception as e:
2790
- return False, f"Anthropic SDK not available: {e}"
2791
- return True, ""
2798
+ def _report_specialist_model() -> str:
2799
+ return os.environ.get("LAMBDA_ERP_REPORT_MODEL") or REPORT_SPECIALIST_MODEL
2792
2800
 
2793
2801
 
2794
2802
  _REPORT_CODE_SYSTEM_PROMPT = """You are a report-spec specialist for Lambda ERP.
@@ -2824,6 +2832,17 @@ all except count require a field. KPI operations use the same set. Formats are
2824
2832
  currency, percent, number, date, or string as appropriate. Charts are bar, line,
2825
2833
  or pie and must reference a table id via data_table.
2826
2834
 
2835
+ For a chart with multiple bars or lines per x value, omit `y` and use
2836
+ `series`: [{"key":"income","label":"Income"},{"key":"expenses","label":"Expenses"}].
2837
+ Every series key must be an output column of the chart's data_table. Pie charts
2838
+ allow only one series. A single-series chart should keep using `y`.
2839
+
2840
+ Use `gl_entries` with `income_amount` and `expense_amount` for accounting
2841
+ income/expense or profit-and-loss analysis. Use `bank_transactions` with
2842
+ `deposit` and `withdrawal` only when the user asks for cash/bank inflows and
2843
+ outflows. Do not infer that there is no financial activity merely because the
2844
+ sales_invoices or purchase_invoices datasets are empty.
2845
+
2827
2846
  Filters are objects keyed by an exposed filter field: equality values, arrays
2828
2847
  for IN, or {"from":...,"to":...} for ranges. Never use SQL or operator strings.
2829
2848
  Use only dataset fields provided in the user message. Identifier fields (name,
@@ -2856,25 +2875,24 @@ def _extract_json_object(text: str) -> dict:
2856
2875
  start = text.find("{")
2857
2876
  end = text.rfind("}")
2858
2877
  if start == -1 or end == -1 or end <= start:
2859
- raise ValueError("Code specialist did not return a JSON object.")
2878
+ raise ValueError("Report specialist did not return a JSON object.")
2860
2879
  candidate = text[start : end + 1]
2861
2880
  return json.loads(candidate)
2862
2881
 
2863
2882
 
2864
- def _generate_report_spec_via_anthropic(
2883
+ def _generate_report_spec_via_openai(
2865
2884
  intent: str,
2866
2885
  existing_spec: dict | None = None,
2867
2886
  feedback: str | None = None,
2868
2887
  client_ip: str | None = None,
2869
2888
  user_role: str | None = None,
2889
+ session_id: str | None = None,
2870
2890
  ) -> dict:
2871
- ok, reason = _anthropic_available()
2872
- if not ok:
2873
- raise RuntimeError(reason)
2874
- import anthropic # type: ignore
2891
+ api_key = os.environ.get("OPENAI_API_KEY", "")
2892
+ if not api_key or api_key == "sk-your-key-here":
2893
+ raise RuntimeError("OPENAI_API_KEY is not configured")
2875
2894
 
2876
- model = _code_model()
2877
- api_key = os.environ["ANTHROPIC_API_KEY"]
2895
+ model = _report_specialist_model()
2878
2896
 
2879
2897
  user_parts: list[str] = []
2880
2898
  user_parts.append(f"## Today's date\n{date.today().isoformat()}")
@@ -2903,10 +2921,13 @@ def _generate_report_spec_via_anthropic(
2903
2921
  user_msg = "\n\n".join(user_parts)
2904
2922
 
2905
2923
  print(
2906
- f"[chat_llm] provider=anthropic role=code_specialist model={model}",
2924
+ f"[chat_llm] provider=openai role=report_specialist model={model}",
2907
2925
  flush=True,
2908
2926
  )
2909
- client = anthropic.Anthropic(api_key=api_key, timeout=120.0)
2927
+ client = OpenAI(
2928
+ api_key=api_key,
2929
+ timeout=httpx.Timeout(120.0, connect=10.0),
2930
+ )
2910
2931
  reservation_id = None
2911
2932
  if is_demo_role(user_role):
2912
2933
  _blocked, reservation_id = demo_limiter.reserve(
@@ -2923,11 +2944,12 @@ def _generate_report_spec_via_anthropic(
2923
2944
  # reservation for the process lifetime (or until TTL sweep).
2924
2945
  settled = False
2925
2946
  try:
2926
- response = client.messages.create(
2947
+ response = client.responses.create(
2927
2948
  model=model,
2928
- system=_REPORT_CODE_SYSTEM_PROMPT,
2929
- messages=[{"role": "user", "content": user_msg}],
2930
- max_tokens=4096,
2949
+ instructions=_REPORT_CODE_SYSTEM_PROMPT,
2950
+ input=user_msg,
2951
+ max_output_tokens=4096,
2952
+ reasoning={"effort": "low"},
2931
2953
  )
2932
2954
  # Log every call for the admin dashboard. Only public_manager rows
2933
2955
  # count against the demo cap — other roles are logged for
@@ -2935,22 +2957,28 @@ def _generate_report_spec_via_anthropic(
2935
2957
  usage = getattr(response, "usage", None)
2936
2958
  demo_limiter.settle(
2937
2959
  reservation_id,
2938
- actual_cost_usd=cost_of_anthropic_call(model, usage),
2960
+ actual_cost_usd=cost_of_openai_call(model, usage),
2939
2961
  ip=client_ip or "unknown",
2940
2962
  role=user_role,
2941
- provider="anthropic",
2963
+ provider="openai",
2942
2964
  model=model,
2943
2965
  prompt_tokens=int(getattr(usage, "input_tokens", 0) or 0) if usage else 0,
2944
2966
  completion_tokens=int(getattr(usage, "output_tokens", 0) or 0) if usage else 0,
2967
+ session_id=session_id,
2945
2968
  )
2946
2969
  settled = True
2947
2970
  finally:
2948
2971
  if not settled:
2949
2972
  demo_limiter.release(reservation_id)
2950
2973
 
2951
- text = "".join(
2952
- block.text for block in response.content if getattr(block, "type", "") == "text"
2953
- )
2974
+ text = getattr(response, "output_text", "") or ""
2975
+ if not text:
2976
+ text = "".join(
2977
+ getattr(block, "text", "") or ""
2978
+ for item in (getattr(response, "output", None) or [])
2979
+ for block in (getattr(item, "content", None) or [])
2980
+ if getattr(block, "type", "") in ("output_text", "text")
2981
+ )
2954
2982
  spec = _extract_json_object(text)
2955
2983
  if not spec.get("report") or not spec.get("data_requests"):
2956
2984
  raise RuntimeError("Report specialist returned an incomplete spec.")
@@ -3251,10 +3279,10 @@ async def run_thinking_loop(
3251
3279
  ):
3252
3280
  """Run the agentic reasoning loop.
3253
3281
 
3254
- The orchestrator is always OpenAI (gpt-5.6-terra). When GPT decides to call
3282
+ The orchestrator and report specialist both use OpenAI gpt-5.6-terra. When GPT decides to call
3255
3283
  `create_custom_analytics_report` or `update_custom_analytics_report`
3256
3284
  with an intent/feedback hint, the tool handler itself delegates the
3257
- declarative-spec step to Anthropic (ANTHROPIC_CODE_MODEL). We emit an
3285
+ declarative-spec step to a separate narrowly prompted Terra call. We emit an
3258
3286
  `llm_provider` event around that delegation so the UI can surface it.
3259
3287
  """
3260
3288
  openai_api_key = os.environ.get("OPENAI_API_KEY", "")
@@ -3337,7 +3365,9 @@ async def run_thinking_loop(
3337
3365
  lambda args: _handle_get_custom_analytics_report(args, principal_ref[0])
3338
3366
  )
3339
3367
  tool_handlers["update_custom_analytics_report"] = (
3340
- lambda args: _handle_update_custom_analytics_report(args, principal_ref[0], client_ip=client_ip)
3368
+ lambda args: _handle_update_custom_analytics_report(
3369
+ args, principal_ref[0], client_ip=client_ip, session_id=session_id
3370
+ )
3341
3371
  )
3342
3372
 
3343
3373
  # Demo sessions get a per-turn cap on attachment retrieval: each
@@ -3458,20 +3488,20 @@ async def run_thinking_loop(
3458
3488
  principal_ref[0] = refresh_auth_principal(principal_ref[0])
3459
3489
  live_role = principal_ref[0].get("role") if principal_ref[0] else None
3460
3490
 
3461
- # If GPT is delegating report-spec generation to the Anthropic specialist,
3491
+ # If the orchestrator is delegating report-spec generation to Terra,
3462
3492
  # surface the handoff in the UI.
3463
- will_delegate_to_code_specialist = (
3493
+ will_delegate_to_report_specialist = (
3464
3494
  tool_allowed(fn_name, live_role)
3465
3495
  and fn_name in ("create_custom_analytics_report", "update_custom_analytics_report")
3466
3496
  and not fn_args.get("report")
3467
3497
  and not fn_args.get("data_requests")
3468
3498
  )
3469
- if will_delegate_to_code_specialist:
3499
+ if will_delegate_to_report_specialist:
3470
3500
  await on_event({
3471
3501
  "type": "llm_provider",
3472
- "provider": "anthropic",
3473
- "model": _code_model(),
3474
- "role": "code_specialist",
3502
+ "provider": "openai",
3503
+ "model": _report_specialist_model(),
3504
+ "role": "report_specialist",
3475
3505
  })
3476
3506
 
3477
3507
  handler = tool_handlers.get(fn_name)
@@ -37,7 +37,7 @@ _WINDOW_SECONDS = 3600 # 1 hour
37
37
 
38
38
  # Upper bound on how long a reservation may hold budget before it's
39
39
  # considered stale and ignored. Must exceed the longest provider call
40
- # timeout (OpenAI/Anthropic clients use 120s) plus a fudge for the
40
+ # timeout (OpenAI clients use 120s) plus a fudge for the
41
41
  # event-loop latency between the SDK call returning and settle() firing.
42
42
  # Defence-in-depth against cancellation leaks that slip past try/finally.
43
43
  _RESERVATION_TTL_SECONDS = 180
@@ -4,8 +4,9 @@ Mirrors the shape of `lambda-web/backend/providers.py`. Only the models
4
4
  this app actually calls are listed — refresh this dict when model
5
5
  choices change. Prices are USD per 1M tokens.
6
6
 
7
- Consumers import `cost_of_openai_call` / `cost_of_anthropic_call` to
8
- turn an SDK `response.usage` object into a USD amount.
7
+ Consumers import `cost_of_openai_call` to turn an SDK `response.usage` object
8
+ into a USD amount. Both Chat Completions and Responses usage shapes are
9
+ accepted because the orchestrator and report specialist use different calls.
9
10
  """
10
11
 
11
12
  from __future__ import annotations
@@ -44,14 +45,6 @@ OPENAI_PRICING: dict[str, dict[str, Any]] = {
44
45
  "gpt-4o-mini": {"input": 0.15, "cached_input": 0.075, "output": 0.60},
45
46
  }
46
47
 
47
- ANTHROPIC_PRICING: dict[str, dict[str, Any]] = {
48
- "claude-opus-4-7": {"input": 5.00, "output": 25.00, "cache_write": 6.25, "cache_read": 0.50},
49
- "claude-opus-4-6": {"input": 5.00, "output": 25.00, "cache_write": 6.25, "cache_read": 0.50},
50
- "claude-sonnet-4-6": {"input": 3.00, "output": 15.00, "cache_write": 3.75, "cache_read": 0.30},
51
- "claude-sonnet-4-5": {"input": 3.00, "output": 15.00, "cache_write": 3.75, "cache_read": 0.30},
52
- "claude-haiku-4-5": {"input": 1.00, "output": 5.00, "cache_write": 1.25, "cache_read": 0.10},
53
- }
54
-
55
48
  # Speech-to-text, priced per minute of audio (not per token).
56
49
  TRANSCRIBE_PRICING: dict[str, dict[str, float]] = {
57
50
  "gpt-4o-transcribe": {"per_minute": 0.006},
@@ -78,27 +71,34 @@ def get_openai_rates(model: str, input_tokens: int = 0) -> dict[str, float]:
78
71
  return entry
79
72
 
80
73
 
81
- def get_anthropic_rates(model: str) -> dict[str, float]:
82
- return ANTHROPIC_PRICING.get(model) or ANTHROPIC_PRICING["claude-opus-4-7"]
83
-
84
-
85
74
  # ---------------------------------------------------------------------------
86
75
  # Cost calculators. `usage` is the SDK's raw usage object; we accept None
87
76
  # and degrade to 0 so callers don't need to pre-check.
88
77
  # ---------------------------------------------------------------------------
89
78
 
90
79
  def cost_of_openai_call(model: str, usage: Optional[Any]) -> float:
91
- """USD cost of one OpenAI chat.completions call from `response.usage`.
80
+ """USD cost of one OpenAI Chat Completions or Responses API call.
92
81
 
93
- Understands the `prompt_tokens_details.cached_tokens` breakdown so cache
94
- hits are billed at the discounted rate."""
82
+ Understands either endpoint's cached-token breakdown so cache hits are
83
+ billed at the discounted rate."""
95
84
  if usage is None:
96
85
  return 0.0
97
- prompt_tokens = int(getattr(usage, "prompt_tokens", 0) or 0)
98
- completion_tokens = int(getattr(usage, "completion_tokens", 0) or 0)
86
+ prompt_tokens = int(
87
+ getattr(usage, "prompt_tokens", 0)
88
+ or getattr(usage, "input_tokens", 0)
89
+ or 0
90
+ )
91
+ completion_tokens = int(
92
+ getattr(usage, "completion_tokens", 0)
93
+ or getattr(usage, "output_tokens", 0)
94
+ or 0
95
+ )
99
96
 
100
97
  cached = 0
101
- details = getattr(usage, "prompt_tokens_details", None)
98
+ details = (
99
+ getattr(usage, "prompt_tokens_details", None)
100
+ or getattr(usage, "input_tokens_details", None)
101
+ )
102
102
  if details is not None:
103
103
  cached = int(getattr(details, "cached_tokens", 0) or 0)
104
104
 
@@ -111,27 +111,6 @@ def cost_of_openai_call(model: str, usage: Optional[Any]) -> float:
111
111
  + completion_tokens * rates["output"]
112
112
  )
113
113
  return total / 1_000_000
114
-
115
-
116
- def cost_of_anthropic_call(model: str, usage: Optional[Any]) -> float:
117
- """USD cost of one Anthropic messages.create call from `response.usage`."""
118
- if usage is None:
119
- return 0.0
120
- input_tokens = int(getattr(usage, "input_tokens", 0) or 0)
121
- output_tokens = int(getattr(usage, "output_tokens", 0) or 0)
122
- cache_create = int(getattr(usage, "cache_creation_input_tokens", 0) or 0)
123
- cache_read = int(getattr(usage, "cache_read_input_tokens", 0) or 0)
124
-
125
- rates = get_anthropic_rates(model)
126
- total = (
127
- input_tokens * rates["input"]
128
- + cache_create * rates.get("cache_write", rates["input"])
129
- + cache_read * rates.get("cache_read", rates["input"])
130
- + output_tokens * rates["output"]
131
- )
132
- return total / 1_000_000
133
-
134
-
135
114
  def cost_of_transcription(model: str, audio_seconds: float) -> float:
136
115
  """USD cost of one speech-to-text call, billed per minute of audio.
137
116
 
@@ -83,8 +83,8 @@ def demo_spend_overview(_user: dict = Depends(require_admin)):
83
83
  Only `public_manager` spend counts against the demo cap — `demo_usd`
84
84
  in each window is the number the global cap compares against. Non-
85
85
  demo calls (your own admin/manager sessions) are included in
86
- `total_usd` so you can see the full OpenAI + Anthropic bill from this
87
- deployment.
86
+ `total_usd` so you can see the full model bill from this deployment,
87
+ including historical provider rows recorded before a provider change.
88
88
  """
89
89
  init_schema()
90
90
  now = time.time()
@@ -3,9 +3,9 @@
3
3
  This module now exposes two layers:
4
4
 
5
5
  1. The original preset analytics API used by the current /reports/analytics page.
6
- 2. A semantic dataset registry + bounded data-fetch endpoint for client-side
7
- programmable reports. The browser can request approved datasets, then run an
8
- arbitrary JS transform locally in a worker without direct database access.
6
+ 2. A semantic dataset registry + bounded data-fetch endpoint for declarative
7
+ reports. The browser can request approved datasets, then group, aggregate,
8
+ and chart them without executing report-provided code.
9
9
  """
10
10
 
11
11
  import json
@@ -381,13 +381,31 @@ class ReportKpiDefinition(StrictReportModel):
381
381
  return self
382
382
 
383
383
 
384
+ class ReportChartSeries(StrictReportModel):
385
+ key: str = Field(pattern=r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
386
+ label: str | None = Field(default=None, max_length=200)
387
+
388
+
384
389
  class ReportChartDefinition(StrictReportModel):
385
390
  id: str | None = Field(default=None, pattern=r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
386
391
  title: str = Field(max_length=200)
387
392
  type: Literal["bar", "line", "pie"]
388
393
  data_table: str = Field(pattern=r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
389
394
  x: str = Field(pattern=r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
390
- y: str = Field(pattern=r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
395
+ # `y` keeps existing single-series v1 drafts working. New reports may use
396
+ # `series` for grouped bars / multiple lines over the same table.
397
+ y: str | None = Field(default=None, pattern=r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
398
+ series: list[ReportChartSeries] = Field(default_factory=list, max_length=8)
399
+
400
+ @model_validator(mode="after")
401
+ def require_series(self):
402
+ if bool(self.y) == bool(self.series):
403
+ raise ValueError("Chart must define exactly one of 'y' or 'series'")
404
+ if self.type == "pie" and len(self.series) > 1:
405
+ raise ValueError("Pie charts support only one series")
406
+ if len({series.key for series in self.series}) != len(self.series):
407
+ raise ValueError("Chart series keys must be unique")
408
+ return self
391
409
 
392
410
 
393
411
  class DeclarativeReportDefinition(StrictReportModel):
@@ -477,9 +495,10 @@ class ReportDraftPayload(StrictReportModel):
477
495
  raise ValueError(
478
496
  f"Chart '{chart.title}' references unknown table '{chart.data_table}'"
479
497
  )
480
- if chart.x not in available or chart.y not in available:
498
+ series_keys = [chart.y] if chart.y else [series.key for series in chart.series]
499
+ if chart.x not in available or any(key not in available for key in series_keys):
481
500
  raise ValueError(
482
- f"Chart '{chart.title}' references unknown x/y table output"
501
+ f"Chart '{chart.title}' references unknown x/series table output"
483
502
  )
484
503
  return self
485
504
 
@@ -672,6 +691,119 @@ SEMANTIC_DATASETS: dict[str, dict[str, Any]] = {
672
691
  "max_limit": 10000,
673
692
  "default_order_by": "pe.posting_date DESC, pe.name DESC",
674
693
  },
694
+ "gl_entries": {
695
+ "label": "General Ledger Entries",
696
+ "description": (
697
+ "Posted double-entry accounting rows joined to the chart of accounts. "
698
+ "Use income_amount and expense_amount for profit-and-loss charts; they "
699
+ "apply the correct normal-balance sign and are zero for other account "
700
+ "roots. Use debit/credit for raw ledger movement."
701
+ ),
702
+ "sql_from": 'FROM "GL Entry" gle LEFT JOIN "Account" a ON a.name = gle.account',
703
+ "fields": {
704
+ "name": "string",
705
+ "posting_date": "date",
706
+ "company": "string",
707
+ "account": "string",
708
+ "account_name": "string",
709
+ "root_type": "string",
710
+ "account_type": "string",
711
+ "party_type": "string",
712
+ "party": "string",
713
+ "cost_center": "string",
714
+ "voucher_type": "string",
715
+ "voucher_no": "string",
716
+ "debit": "number",
717
+ "credit": "number",
718
+ "normal_balance_amount": "number",
719
+ "income_amount": "number",
720
+ "expense_amount": "number",
721
+ "is_opening": "string",
722
+ },
723
+ "field_sql": {
724
+ "name": "gle.name",
725
+ "posting_date": "gle.posting_date",
726
+ "company": "gle.company",
727
+ "account": "gle.account",
728
+ "account_name": "a.account_name",
729
+ "root_type": "a.root_type",
730
+ "account_type": "a.account_type",
731
+ "party_type": "gle.party_type",
732
+ "party": "gle.party",
733
+ "cost_center": "gle.cost_center",
734
+ "voucher_type": "gle.voucher_type",
735
+ "voucher_no": "gle.voucher_no",
736
+ "debit": "gle.debit",
737
+ "credit": "gle.credit",
738
+ "normal_balance_amount": (
739
+ "CASE WHEN a.root_type IN ('Liability', 'Equity', 'Income') "
740
+ "THEN gle.credit - gle.debit ELSE gle.debit - gle.credit END"
741
+ ),
742
+ "income_amount": (
743
+ "CASE WHEN a.root_type = 'Income' THEN gle.credit - gle.debit ELSE 0 END"
744
+ ),
745
+ "expense_amount": (
746
+ "CASE WHEN a.root_type = 'Expense' THEN gle.debit - gle.credit ELSE 0 END"
747
+ ),
748
+ "is_opening": "gle.is_opening",
749
+ },
750
+ "default_where": ["COALESCE(gle.is_cancelled, 0) = 0"],
751
+ "filter_fields": {
752
+ "posting_date", "company", "account", "root_type", "account_type",
753
+ "party_type", "party", "cost_center", "voucher_type", "voucher_no",
754
+ "is_opening",
755
+ },
756
+ "default_limit": 1000,
757
+ "max_limit": 10000,
758
+ "default_order_by": "gle.posting_date DESC, gle.name DESC",
759
+ },
760
+ "bank_transactions": {
761
+ "label": "Bank Transactions",
762
+ "description": (
763
+ "Imported bank-statement movements. Use deposit for cash inflows and "
764
+ "withdrawal for cash outflows. These are cash movements, not accounting "
765
+ "income/expenses; use gl_entries for a profit-and-loss view."
766
+ ),
767
+ "sql_from": 'FROM "Bank Transaction" bt',
768
+ "fields": {
769
+ "name": "string",
770
+ "posting_date": "date",
771
+ "value_date": "date",
772
+ "bank_account": "string",
773
+ "currency": "string",
774
+ "deposit": "number",
775
+ "withdrawal": "number",
776
+ "counterparty_name": "string",
777
+ "description": "string",
778
+ "reference_number": "string",
779
+ "status": "string",
780
+ "reference_doctype": "string",
781
+ "reference_name": "string",
782
+ },
783
+ "field_sql": {
784
+ "name": "bt.name",
785
+ "posting_date": "bt.posting_date",
786
+ "value_date": "bt.value_date",
787
+ "bank_account": "bt.bank_account",
788
+ "currency": "bt.currency",
789
+ "deposit": "bt.deposit",
790
+ "withdrawal": "bt.withdrawal",
791
+ "counterparty_name": "bt.counterparty_name",
792
+ "description": "bt.description",
793
+ "reference_number": "bt.reference_number",
794
+ "status": "bt.status",
795
+ "reference_doctype": "bt.reference_doctype",
796
+ "reference_name": "bt.reference_name",
797
+ },
798
+ "default_where": ["COALESCE(bt.reversal_indicator, 0) = 0"],
799
+ "filter_fields": {
800
+ "posting_date", "value_date", "bank_account", "currency", "status",
801
+ "reference_doctype", "reference_name",
802
+ },
803
+ "default_limit": 1000,
804
+ "max_limit": 10000,
805
+ "default_order_by": "bt.posting_date DESC, bt.name DESC",
806
+ },
675
807
  "ar_open_items": {
676
808
  "label": "AR Open Items",
677
809
  "description": "Open submitted non-return sales invoices with positive outstanding amounts.",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.8.30"
3
+ version = "0.8.31"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -11,7 +11,6 @@ dependencies = [
11
11
  "uvicorn[standard]>=0.30",
12
12
  "python-multipart>=0.0.9",
13
13
  "pydantic>=2.0",
14
- "anthropic>=0.40",
15
14
  "openai>=1.0",
16
15
  "httpx>=0.27",
17
16
  "python-dotenv>=1.0",
@@ -27,10 +27,8 @@ rate/cost, not the VM.
27
27
  ### 1. Gather inputs
28
28
 
29
29
  - **Azure subscription ID** — `az account show --query id -o tsv`
30
- - **OpenAI API key** — required; chat orchestration runs on GPT.
31
- - **Anthropic API key** — required; the code-specialist sub-agent that
32
- generates custom-analytics JS runs on Claude. The demo still boots
33
- without it but custom-report code generation will fail.
30
+ - **OpenAI API key** — required; chat orchestration and the report specialist
31
+ both run on GPT-5.6 Terra.
34
32
  - **JWT secret** — a stable 64-char hex string used to sign login cookies:
35
33
  ```bash
36
34
  python -c "import secrets; print(secrets.token_hex(32))"
@@ -38,19 +36,19 @@ rate/cost, not the VM.
38
36
  Generate once, keep stable across revisions (users' cookies survive
39
37
  rollouts that way).
40
38
 
41
- You collect these four items once, then paste them into GitHub — they
39
+ You collect these three items once, then paste them into GitHub — they
42
40
  never need to sit on a laptop during any terraform run:
43
41
 
44
42
  - **Subscription ID** goes into `terraform.tfvars` in step 3 (also into
45
43
  GitHub Actions Variables for CI).
46
- - **OpenAI key, Anthropic key, JWT secret** go directly into **GitHub
44
+ - **OpenAI key and JWT secret** go directly into **GitHub
47
45
  Actions Secrets** in step 6. From that point on, the
48
46
  `terraform-apply` workflow reads them as `TF_VAR_*` env vars on
49
47
  every CI-driven apply and pushes them into the Container App's
50
48
  secret store.
51
49
 
52
50
  The bootstrap `terraform apply` in step 4 runs with **no real secrets
53
- at all** — the three secret-backed variables all default to a
51
+ at all** — the two secret-backed variables both default to a
54
52
  placeholder string, which terraform writes into the Container App
55
53
  alongside the Microsoft quickstart image. The first CI-driven
56
54
  `terraform-apply` run (step 7) is where real secrets first reach
@@ -117,8 +115,8 @@ run has to happen from a machine where you can sign in as an owner of
117
115
  the subscription + Entra tenant. No further applies will run from a
118
116
  laptop after this.
119
117
 
120
- **No secrets go in on this run.** The three secret-backed variables
121
- (`openai_api_key`, `anthropic_api_key`, `jwt_secret_key`) all default
118
+ **No secrets go in on this run.** The two secret-backed variables
119
+ (`openai_api_key`, `jwt_secret_key`) both default
122
120
  to the placeholder string `"placeholder-will-be-set-by-github-actions"`;
123
121
  terraform writes that string into the Container App's secret store,
124
122
  which is fine because the app image isn't real yet either (the
@@ -175,7 +173,6 @@ Under **Secrets** tab (same page), add:
175
173
  | Secret | Value |
176
174
  |---------------------|-----------------------------------------------|
177
175
  | `OPENAI_API_KEY` | your OpenAI key |
178
- | `ANTHROPIC_API_KEY` | your Anthropic key |
179
176
  | `JWT_SECRET_KEY` | the 64-hex-char string from step 1 |
180
177
 
181
178
  These are what the `terraform-apply` workflow reads as `TF_VAR_*` env
@@ -208,7 +205,7 @@ From this point on, **no secret ever sits on a laptop**:
208
205
  |----------------------------------------------|---------------------------------------------------------------------------|
209
206
  | New app code (Python/React) | Push to `master`. `deploy.yml` builds, pushes, rolls the revision. |
210
207
  | New infra, env var, or demo cap | Edit `terraform/app/*.tf`, push, then run `terraform-apply` workflow. |
211
- | Rotate `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` | Update the GitHub Secret, then run `terraform-apply` with `action=apply`. |
208
+ | Rotate `OPENAI_API_KEY` | Update the GitHub Secret, then run `terraform-apply` with `action=apply`. |
212
209
  | Rotate `JWT_SECRET_KEY` | Same as above. All existing login cookies will become invalid. |
213
210
 
214
211
  The `terraform-apply` workflow has a `plan | apply` dropdown on manual
@@ -217,8 +214,8 @@ before applying.
217
214
 
218
215
  ## Secrets: who owns what
219
216
 
220
- - **Container App secrets** (`openai-api-key`, `anthropic-api-key`,
221
- `jwt-secret-key`) live in **GitHub Secrets** and are applied to
217
+ - **Container App secrets** (`openai-api-key`, `jwt-secret-key`) live in
218
+ **GitHub Secrets** and are applied to
222
219
  Azure by the `terraform-apply` workflow, which passes them as
223
220
  `TF_VAR_*` env vars. Terraform then writes them into the Container
224
221
  App's own secret store. No copy on disk anywhere else.
@@ -269,7 +266,7 @@ Set `custom_domain = "erp-demo.example.com"` in `terraform.tfvars`. After
269
266
  | Log Analytics (minimal) | ~$5 |
270
267
  | **Total infra** | **~$40** |
271
268
 
272
- LLM usage is billed separately by OpenAI/Anthropic. The demo cap above
269
+ LLM usage is billed separately by OpenAI. The demo cap above
273
270
  targets ~$50/day ceiling for visitor traffic, so the worst-case combined
274
271
  monthly bill is roughly $40 infra + $1500 LLM if the demo is slammed
275
272
  24/7.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes