lambda-erp 0.8.20__tar.gz → 0.8.22__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 (94) hide show
  1. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/PKG-INFO +6 -5
  2. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/README.md +4 -4
  3. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/attachments.py +20 -2
  4. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/auth.py +1 -1
  5. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/chat.py +261 -70
  6. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/main.py +2 -1
  7. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/routers/availability.py +3 -2
  8. lambda_erp-0.8.22/api/routers/bank_statements.py +118 -0
  9. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/routers/chat_api.py +1 -1
  10. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/routers/masters.py +41 -35
  11. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/services.py +164 -11
  12. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/frontend/src/api/client.ts +85 -1
  13. lambda_erp-0.8.22/lambda_erp/accounting/bank_account.py +77 -0
  14. lambda_erp-0.8.22/lambda_erp/accounting/bank_statement_import.py +417 -0
  15. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/bank_transaction.py +22 -3
  16. lambda_erp-0.8.22/lambda_erp/accounting/camt.py +457 -0
  17. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/revaluation.py +1 -1
  18. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/database.py +177 -3
  19. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/pyproject.toml +2 -1
  20. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/.gitignore +0 -0
  21. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/LICENSE +0 -0
  22. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/__init__.py +0 -0
  23. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/bootstrap.py +0 -0
  24. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/demo_limits.py +0 -0
  25. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/deps.py +0 -0
  26. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/errors.py +0 -0
  27. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/list_values.py +0 -0
  28. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/oauth.py +0 -0
  29. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/pdf.py +0 -0
  30. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/providers.py +0 -0
  31. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/remarks_md.py +0 -0
  32. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/routers/__init__.py +0 -0
  33. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/routers/accounting.py +0 -0
  34. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/routers/actions.py +0 -0
  35. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/routers/admin.py +0 -0
  36. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/routers/analytics.py +0 -0
  37. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/routers/bank_reconciliation.py +0 -0
  38. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/routers/documents.py +0 -0
  39. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/routers/mcp.py +0 -0
  40. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/routers/proposals.py +0 -0
  41. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/routers/reports.py +0 -0
  42. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/routers/setup.py +0 -0
  43. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/templates/document.html +0 -0
  44. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/api/templates/proposal.html +0 -0
  45. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/docs/agents/README.md +0 -0
  46. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/frontend/LICENSE +0 -0
  47. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/frontend/README.md +0 -0
  48. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/__init__.py +0 -0
  49. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/__init__.py +0 -0
  50. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/budget.py +0 -0
  51. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  52. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/general_ledger.py +0 -0
  53. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/journal_entry.py +0 -0
  54. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/payment_entry.py +0 -0
  55. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/pos_invoice.py +0 -0
  56. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/purchase_invoice.py +0 -0
  57. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/sales_invoice.py +0 -0
  58. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/__init__.py +0 -0
  59. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/engine.py +0 -0
  60. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/pack.py +0 -0
  61. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  62. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  63. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  64. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  65. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  66. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  67. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/profiles.py +0 -0
  68. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/spine.py +0 -0
  69. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/accounting/subscription.py +0 -0
  70. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/assets/__init__.py +0 -0
  71. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/assets/asset.py +0 -0
  72. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/assets/reservation.py +0 -0
  73. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/buying/__init__.py +0 -0
  74. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/buying/purchase_order.py +0 -0
  75. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/controllers/__init__.py +0 -0
  76. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/controllers/currency.py +0 -0
  77. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/controllers/defaults.py +0 -0
  78. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/controllers/pricing_rule.py +0 -0
  79. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  80. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/exceptions.py +0 -0
  81. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/hooks.py +0 -0
  82. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/model.py +0 -0
  83. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/selling/__init__.py +0 -0
  84. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/selling/proposal.py +0 -0
  85. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/selling/quotation.py +0 -0
  86. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/selling/sales_order.py +0 -0
  87. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/simulation.py +0 -0
  88. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/stock/__init__.py +0 -0
  89. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/stock/delivery_note.py +0 -0
  90. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/stock/purchase_receipt.py +0 -0
  91. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/stock/stock_entry.py +0 -0
  92. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/stock/stock_ledger.py +0 -0
  93. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/lambda_erp/utils.py +0 -0
  94. {lambda_erp-0.8.20 → lambda_erp-0.8.22}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 0.8.20
3
+ Version: 0.8.22
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -8,6 +8,7 @@ License-File: LICENSE
8
8
  Requires-Python: >=3.10
9
9
  Requires-Dist: anthropic>=0.40
10
10
  Requires-Dist: bcrypt>=5
11
+ Requires-Dist: defusedxml>=0.7
11
12
  Requires-Dist: fastapi>=0.115
12
13
  Requires-Dist: holidays>=0.40
13
14
  Requires-Dist: httpx>=0.27
@@ -92,7 +93,7 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
92
93
 
93
94
  ┌──────────────────────────────────────────┐
94
95
  │ LLM orchestrator │
95
- │ - GPT-5.4 drives the reasoning loop │
96
+ │ - GPT-5.6 drives the reasoning loop │
96
97
  │ - Tool-use: document CRUD, search, │
97
98
  │ reports, aggregations, analytics │
98
99
  │ - Delegates JS generation to Anthropic │
@@ -449,9 +450,9 @@ replacement file.
449
450
  Both the backend seams (document classes, lifecycle hooks, converters, plugin
450
451
  loading) and the frontend seams (doctype/route/nav/component registries,
451
452
  branding, configurable API base, Tailwind preset) are implemented. The backend
452
- also builds a clean pip wheel and the frontend a `@lambda-development/erp-core` npm library
453
- — see [`docs/packaging-distribution-plan.md`](docs/packaging-distribution-plan.md)
454
- for the publish path.
453
+ ships as a pip package and the frontend as the
454
+ `@lambda-development/erp-core` npm package. See
455
+ [`docs/releasing.md`](docs/releasing.md) for the release process.
455
456
 
456
457
  ---
457
458
 
@@ -64,7 +64,7 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
64
64
 
65
65
  ┌──────────────────────────────────────────┐
66
66
  │ LLM orchestrator │
67
- │ - GPT-5.4 drives the reasoning loop │
67
+ │ - GPT-5.6 drives the reasoning loop │
68
68
  │ - Tool-use: document CRUD, search, │
69
69
  │ reports, aggregations, analytics │
70
70
  │ - Delegates JS generation to Anthropic │
@@ -421,9 +421,9 @@ replacement file.
421
421
  Both the backend seams (document classes, lifecycle hooks, converters, plugin
422
422
  loading) and the frontend seams (doctype/route/nav/component registries,
423
423
  branding, configurable API base, Tailwind preset) are implemented. The backend
424
- also builds a clean pip wheel and the frontend a `@lambda-development/erp-core` npm library
425
- — see [`docs/packaging-distribution-plan.md`](docs/packaging-distribution-plan.md)
426
- for the publish path.
424
+ ships as a pip package and the frontend as the
425
+ `@lambda-development/erp-core` npm package. See
426
+ [`docs/releasing.md`](docs/releasing.md) for the release process.
427
427
 
428
428
  ---
429
429
 
@@ -49,10 +49,17 @@ OFFICE_MIME_TYPES = {
49
49
  # Plain-text formats — inlined into the prompt as text (no file part needed).
50
50
  TEXT_MIME_TYPES = {"text/csv", "text/plain"}
51
51
 
52
+ # Structured bank uploads are retained as ordinary chat attachments, but are
53
+ # never dumped wholesale into the LLM context. Dedicated deterministic tools
54
+ # parse them and return a compact preview instead.
55
+ STRUCTURED_TOOL_MIME_TYPES = {
56
+ "application/xml", "text/xml", "application/zip", "application/x-zip-compressed",
57
+ }
58
+
52
59
  ALLOWED_MIME_TYPES = {
53
60
  "image/png", "image/jpeg", "image/gif", "image/webp",
54
61
  "application/pdf",
55
- } | OFFICE_MIME_TYPES | TEXT_MIME_TYPES
62
+ } | OFFICE_MIME_TYPES | TEXT_MIME_TYPES | STRUCTURED_TOOL_MIME_TYPES
56
63
 
57
64
  # Extension -> canonical mime, so a correctly-named file still uploads when the
58
65
  # browser/OS mislabels its type (Office files are frequently sent as
@@ -69,6 +76,8 @@ _EXT_TO_MIME = {
69
76
  "odp": "application/vnd.oasis.opendocument.presentation",
70
77
  "csv": "text/csv",
71
78
  "txt": "text/plain",
79
+ "xml": "application/xml",
80
+ "zip": "application/zip",
72
81
  "pdf": "application/pdf",
73
82
  "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
74
83
  "gif": "image/gif", "webp": "image/webp",
@@ -240,7 +249,8 @@ async def upload_attachment(
240
249
  raise HTTPException(
241
250
  status_code=400,
242
251
  detail=(f"Unsupported file type: {mime or ext or 'unknown'}. Allowed: images, PDF, "
243
- "spreadsheets (Excel/CSV/ODS), and documents (Word/OpenDocument)."),
252
+ "spreadsheets (Excel/CSV/ODS), documents (Word/OpenDocument), "
253
+ "and structured bank files (XML/ZIP)."),
244
254
  )
245
255
 
246
256
  data = await file.read()
@@ -389,6 +399,14 @@ def build_multimodal_content(attachment: dict) -> dict:
389
399
  "type": "image_url",
390
400
  "image_url": {"url": f"data:{mime};base64,{data_b64}"},
391
401
  }
402
+ if mime in STRUCTURED_TOOL_MIME_TYPES:
403
+ return {
404
+ "type": "text",
405
+ "text": (
406
+ f"[Structured attachment: {filename}. Do not retrieve or parse its raw contents in "
407
+ "the conversation; use the dedicated bank-statement preview tool.]"
408
+ ),
409
+ }
392
410
  if mime in TEXT_MIME_TYPES or mime.startswith("text/"):
393
411
  text = attachment["data"].decode("utf-8", errors="replace")
394
412
  return {"type": "text", "text": f"[File: {filename}]\n{text}"}
@@ -612,7 +612,7 @@ DEFAULTS = {
612
612
  "allow_public_signup": "0",
613
613
  # When "1", the programmatic chat API (POST /api/v1/chat, Bearer API keys) is
614
614
  # active. Default off — an admin turns it on and issues keys. See
615
- # docs/chat-api-plan.md.
615
+ # docs/chat-api.md.
616
616
  "chat_api_enabled": "0",
617
617
  # When "1", the same Bearer API keys authenticate the regular REST API
618
618
  # (/api/documents, /api/masters, /api/reports, …) in addition to the session
@@ -448,7 +448,7 @@ DOCUMENT_SLUGS = [
448
448
  "purchase-order", "purchase-invoice",
449
449
  "payment-entry", "journal-entry", "stock-entry",
450
450
  "delivery-note", "purchase-receipt", "pos-invoice",
451
- "pricing-rule", "budget", "subscription", "bank-transaction",
451
+ "pricing-rule", "budget", "subscription", "bank-account", "bank-transaction",
452
452
  # Owned equipment and its hire calendar. Core doctypes, so they belong in
453
453
  # this list rather than surfacing as "deployment-specific extensions"
454
454
  # (_extra_document_slugs is defined as everything NOT named here).
@@ -478,6 +478,20 @@ TOOLS = [
478
478
  },
479
479
  },
480
480
  },
481
+ {
482
+ "type": "function",
483
+ "function": {
484
+ "name": "get_document_fields",
485
+ "description": "List the available columns of a document type and identify which are text fields. Call this before building list_documents filters when you are unsure of a field name or whether it supports the case-insensitive contains operator. Also returns the default fields used by free-text search.",
486
+ "parameters": {
487
+ "type": "object",
488
+ "properties": {
489
+ "doctype": {"type": "string", "enum": DOCUMENT_SLUGS},
490
+ },
491
+ "required": ["doctype"],
492
+ },
493
+ },
494
+ },
481
495
  {
482
496
  "type": "function",
483
497
  "function": {
@@ -627,7 +641,7 @@ TOOLS = [
627
641
  "type": "function",
628
642
  "function": {
629
643
  "name": "search_masters",
630
- "description": "Search master data (customers, suppliers, items, warehouses, accounts, companies, cost centers). Case-insensitive, with fuzzy fallback for misspellings. By default matches across standard text fields (name, display name, address/city/zip); large free-text columns like description/templates are skipped unless named in `fields`. PREFER passing `fields` whenever you know which attribute you're matching on (e.g. a city, an email, a tax id) — it's faster, more precise, and avoids false hits from other columns. Returns ALL matching records by default (no cap); pass `limit` only to bound a large list.",
644
+ "description": "Search and filter master data (customers, suppliers, items, warehouses, accounts, companies, cost centers). `query` is a case-insensitive free-text lookup with fuzzy fallback for simple misspellings. Use `filters` for deterministic field-aware queries and for combining different values across fields. Returns ALL matching records by default (no cap); pass `limit` only to bound a large list.",
631
645
  "parameters": {
632
646
  "type": "object",
633
647
  "properties": {
@@ -638,7 +652,20 @@ TOOLS = [
638
652
  "items": {"type": "string"},
639
653
  "description": "Recommended: the column(s) to search, e.g. [\"city\"] or [\"customer_name\"]. Narrowing here is faster and avoids matching unrelated columns. Omit only when you genuinely don't know which field holds the value, to search all standard text fields.",
640
654
  },
655
+ "filters": {
656
+ "type": "object",
657
+ "description": "Optional deterministic filters on any real column, ANDed together and with `query`. Scalar values are exact: {\"disabled\": 0}. Text substring: {\"legal_form\": [\"contains\", \"AG\"]}. Comparisons and NULL checks use the same forms as list_documents. `contains` is only valid for schema-confirmed text fields.",
658
+ "default": {},
659
+ },
660
+ "order_by": {"type": "string", "description": "Optional real column to sort by. Defaults to name."},
661
+ "order": {"type": "string", "enum": ["asc", "desc"], "description": "Sort direction (default asc).", "default": "asc"},
641
662
  "limit": {"type": "integer", "description": "Optional max number of results. OMIT for no cap — returns ALL matches (e.g. to see the entire chart of accounts). Pass a number only to bound a large list."},
663
+ "offset": {"type": "integer", "description": "Skip this many matching rows. Use with limit for pagination.", "default": 0},
664
+ "result_fields": {
665
+ "type": "array",
666
+ "items": {"type": "string"},
667
+ "description": "Optional result projection. Returns only these real columns plus name. This is separate from `fields`, which selects columns searched by `query`.",
668
+ },
642
669
  "include_disabled": {"type": "boolean", "description": "Default false (active records only). Set true to ALSO return disabled/archived records — needed to find a record you must inspect, re-enable, or reference, or to answer 'what's disabled'. Retry with this if a lookup for a record you know exists comes back empty."},
643
670
  },
644
671
  "required": ["master_type"],
@@ -649,7 +676,7 @@ TOOLS = [
649
676
  "type": "function",
650
677
  "function": {
651
678
  "name": "get_master_fields",
652
- "description": "List the available columns of a master type (customer, supplier, item, ...). Call this WHENEVER you're unsure which columns exist: before passing `fields` to search_masters, and before building a `data` payload for create_master/update_master when you're not certain a field exists or where a value belongs (e.g. a contact person, a tax id, a payment term). It lets you target real fields instead of guessing or wrongly concluding a value can't be stored. Returns: `fields` (all columns), `default_search_fields` (what search_masters searches when `fields` is omitted), and `bulk_text_fields` (large text columns searched only when named in `fields`).",
679
+ "description": "List the available columns of a master type (customer, supplier, item, ...). Call this WHENEVER you're unsure which columns exist: before passing `fields` or `filters` to search_masters, and before building a create/update payload. Returns all fields, text fields (the columns that support `contains`), default free-text search fields, and bulk text fields that are searched only when explicitly named.",
653
680
  "parameters": {
654
681
  "type": "object",
655
682
  "properties": {
@@ -787,10 +814,58 @@ TOOLS = [
787
814
  "type": "function",
788
815
  "function": {
789
816
  "name": "list_chat_attachments",
790
- "description": "List all files the user has uploaded in this chat session — PDFs, images, spreadsheets (Excel/CSV/ODS), and documents (Word/OpenDocument). Returns metadata including id, filename, mime type, size, and upload date. Use this when the user references a previously uploaded file or you need to find an attachment to retrieve.",
817
+ "description": "List all files the user has uploaded in this chat session — PDFs, images, spreadsheets (Excel/CSV/ODS), documents (Word/OpenDocument), and structured bank files (CAMT XML/ZIP). Returns metadata including id, filename, mime type, size, and upload date. Use this when the user references a previously uploaded file or you need its attachment id.",
791
818
  "parameters": {"type": "object", "properties": {}},
792
819
  },
793
820
  },
821
+ {
822
+ "type": "function",
823
+ "function": {
824
+ "name": "preview_bank_statement_attachments",
825
+ "description": "Safely parse CAMT.053 XML or ZIP chat attachments and return a compact, read-only import preview: masked account, currency, period, balances, entry/detail/batch/QR counts, duplicates, warnings, and any exact Bank Account mapping. The raw XML is never added to the model context. Always call this before importing bank statements.",
826
+ "parameters": {
827
+ "type": "object",
828
+ "properties": {
829
+ "attachment_ids": {
830
+ "type": "array",
831
+ "items": {"type": "string"},
832
+ "minItems": 1,
833
+ "maxItems": 20,
834
+ "description": "CAMT XML/ZIP attachment ids from this chat.",
835
+ },
836
+ },
837
+ "required": ["attachment_ids"],
838
+ },
839
+ },
840
+ },
841
+ {
842
+ "type": "function",
843
+ "function": {
844
+ "name": "import_bank_statement_attachments",
845
+ "description": "Import previously previewed CAMT attachments as Bank Transactions without creating or posting any Payment Entry, Journal Entry, or GL entry. Exact file re-uploads and stable duplicate bank entries are skipped. Use only after showing the preview and receiving explicit user confirmation. Requires manager access and is unavailable in public demo mode.",
846
+ "parameters": {
847
+ "type": "object",
848
+ "properties": {
849
+ "attachment_ids": {
850
+ "type": "array",
851
+ "items": {"type": "string"},
852
+ "minItems": 1,
853
+ "maxItems": 20,
854
+ },
855
+ "mappings": {
856
+ "type": "object",
857
+ "additionalProperties": {"type": "string"},
858
+ "description": "Optional mapping of statement_key from the preview to Bank Account document name. Exact IBAN matches are used automatically.",
859
+ },
860
+ "confirmed": {
861
+ "type": "boolean",
862
+ "description": "Must be true, and only after the user explicitly confirmed the displayed preview.",
863
+ },
864
+ },
865
+ "required": ["attachment_ids", "confirmed"],
866
+ },
867
+ },
868
+ },
794
869
  {
795
870
  "type": "function",
796
871
  "function": {
@@ -1275,15 +1350,20 @@ def build_tools(user_info: dict | None = None):
1275
1350
  extra_docs = _extra_document_slugs()
1276
1351
  extra_masters = _extra_master_types()
1277
1352
  action_tools = services.registered_action_tools()
1353
+ hidden_tools: set[str] = set()
1278
1354
  if user_info is not None:
1279
1355
  role = user_info.get("role")
1280
1356
  action_tools = [
1281
1357
  tool for tool in action_tools
1282
1358
  if services.registered_action_allowed(tool["function"]["name"], role)
1283
1359
  ]
1284
- if not extra_docs and not extra_masters and not action_tools:
1360
+ if role not in {"manager", "admin"}:
1361
+ hidden_tools.add("import_bank_statement_attachments")
1362
+ if not extra_docs and not extra_masters and not action_tools and not hidden_tools:
1285
1363
  return TOOLS
1286
1364
  tools = copy.deepcopy(TOOLS)
1365
+ if hidden_tools:
1366
+ tools = [tool for tool in tools if tool["function"]["name"] not in hidden_tools]
1287
1367
  for tool in tools:
1288
1368
  props = tool["function"].get("parameters", {}).get("properties", {})
1289
1369
  for schema in props.values():
@@ -1326,18 +1406,6 @@ def _handle_list_documents(args):
1326
1406
  # within the tool-result budget. Use get_document to drill into one doc.
1327
1407
  doctype = args["doctype"]
1328
1408
  filters = args.get("filters") or {}
1329
- # Chat callers routinely pass `search` but omit `search_fields`; with no fields
1330
- # the free-text search silently no-ops and returns the newest N by creation
1331
- # (2026-08-13: tag lookups on leads "found nothing"). Default the columns to the
1332
- # doctype's registered chat fields so document search matches a record the way
1333
- # search_masters already does for masters. The frontend never hits this — its
1334
- # UI config always sends search_fields.
1335
- if filters.get("search") and not filters.get("search_fields"):
1336
- registered = (services.CHAT_DOCTYPES.get(doctype) or {}).get("fields") or []
1337
- valid = set(services.document_columns(doctype) or [])
1338
- default_fields = [f for f in registered if f in valid]
1339
- if default_fields:
1340
- filters = {**filters, "search_fields": default_fields}
1341
1409
  err = _validate_filter_columns(doctype, filters)
1342
1410
  if err:
1343
1411
  return {"error": err}
@@ -1369,6 +1437,23 @@ def _handle_list_documents(args):
1369
1437
  return rows
1370
1438
 
1371
1439
 
1440
+ def _handle_get_document_fields(args):
1441
+ doctype = args["doctype"]
1442
+ table = services.SLUG_TO_DOCTYPE.get(doctype)
1443
+ if not table:
1444
+ return {"error": f"Unknown document type: {doctype}"}
1445
+ db = get_db()
1446
+ cls = services.DOCUMENT_CLASSES.get(table)
1447
+ return {
1448
+ "doctype": doctype,
1449
+ "fields": sorted(db._get_table_columns(table)),
1450
+ "text_fields": sorted(db._get_text_columns(table)),
1451
+ "default_search_fields": services.document_search_columns(db, doctype),
1452
+ "child_tables": sorted((cls.CHILD_TABLES or {}).keys()) if cls else [],
1453
+ "link_fields": dict(getattr(cls, "LINK_FIELDS", None) or {}) if cls else {},
1454
+ }
1455
+
1456
+
1372
1457
  def _handle_get_document(args):
1373
1458
  return services.load_document(args["doctype"], args["name"])
1374
1459
 
@@ -1407,22 +1492,6 @@ def _handle_convert_document(args):
1407
1492
  return services.convert_document(args["doctype"], args["name"], args["target_doctype"])
1408
1493
 
1409
1494
 
1410
- # Columns that are technically text but are noise for free-text master search.
1411
- _MASTER_SEARCH_SKIP_COLUMNS = {
1412
- "naming_series",
1413
- "owner",
1414
- "modified_by",
1415
- "creation",
1416
- "modified",
1417
- "created_at",
1418
- "updated_at",
1419
- }
1420
-
1421
- # Large free-text columns excluded from the DEFAULT search: scanning/fuzzing a
1422
- # big blob (e.g. an item description or an HTML template) is costly and rarely
1423
- # how you identify a record. Still searchable on demand via the `fields` arg.
1424
- _MASTER_BULK_TEXT_COLUMNS = {"description", "notes", "remarks", "terms", "comments"}
1425
-
1426
1495
  # Minimum difflib similarity for a fuzzy (misspelled) match to be returned.
1427
1496
  _MASTER_FUZZY_THRESHOLD = 0.6
1428
1497
  # Bounds for the fuzzy scorer so a single large value can't blow up cost:
@@ -1431,18 +1500,9 @@ _FUZZY_MAX_VALUE_LEN = 200
1431
1500
  _FUZZY_MAX_TOKENS = 16
1432
1501
 
1433
1502
 
1434
- def _is_bulk_text_column(col):
1435
- return (col in _MASTER_BULK_TEXT_COLUMNS
1436
- or col.endswith("_template") or col.endswith("_html"))
1437
-
1438
-
1439
1503
  def _master_search_columns(db, doctype):
1440
- """Text columns worth matching a query against by default. Discovered from
1441
- the live schema (new text fields become searchable automatically), minus
1442
- audit noise and large free-text/template columns."""
1443
- cols = db._get_text_columns(doctype) - _MASTER_SEARCH_SKIP_COLUMNS
1444
- # Deterministic order keeps generated SQL and fuzzy scoring stable.
1445
- return sorted(c for c in cols if not _is_bulk_text_column(c))
1504
+ """Compatibility wrapper for the shared REST/MCP field policy."""
1505
+ return services.master_search_columns(db, doctype)
1446
1506
 
1447
1507
 
1448
1508
  def _fuzzy_master_search(db, doctype, search_cols, query, has_disabled, limit):
@@ -1485,10 +1545,11 @@ def _handle_get_master_fields(args):
1485
1545
  return {"error": f"Unknown master type: {master_type}"}
1486
1546
  doctype, _ = entry
1487
1547
  default_search = _master_search_columns(db, doctype)
1488
- bulk = sorted(c for c in db._get_text_columns(doctype) if _is_bulk_text_column(c))
1548
+ bulk = services.master_bulk_text_columns(db, doctype)
1489
1549
  return {
1490
1550
  "master_type": master_type,
1491
1551
  "fields": sorted(db._get_table_columns(doctype)),
1552
+ "text_fields": sorted(db._get_text_columns(doctype)),
1492
1553
  # What search_masters searches when `fields` is omitted.
1493
1554
  "default_search_fields": default_search,
1494
1555
  # Large text fields searched ONLY when named in search_masters `fields`.
@@ -1517,23 +1578,20 @@ def _handle_search_masters(args):
1517
1578
  return {"error": f"Unknown master type: {master_type}"}
1518
1579
 
1519
1580
  doctype, _name_field = entry
1520
- has_disabled = "disabled" in db._get_table_columns(doctype)
1581
+ columns = set(db._get_table_columns(doctype))
1582
+ has_disabled = "disabled" in columns
1521
1583
  # Active-only by default (retired/archived records are hidden — you shouldn't
1522
1584
  # book to them or wade through them). Set include_disabled to reach a disabled
1523
1585
  # record you need to inspect, re-enable, or reference. Only meaningful when the
1524
1586
  # table has a `disabled` column.
1525
1587
  filter_disabled = has_disabled and not bool(args.get("include_disabled"))
1526
1588
 
1527
- if not query:
1528
- filters = {"disabled": 0} if filter_disabled else None
1529
- return db.get_all(doctype, filters=filters, fields=["*"], limit=limit)
1530
-
1531
1589
  # Optional `fields` narrows the search to specific columns — cheaper, more
1532
1590
  # precise, and the only way to reach bulk columns (description, templates)
1533
1591
  # that the default search skips. Unknown names are ignored.
1534
1592
  requested = args.get("fields") or []
1535
1593
  if requested:
1536
- valid = set(db._get_table_columns(doctype))
1594
+ valid = columns
1537
1595
  # The identity alias (e.g. "item_code") is what the model calls the code
1538
1596
  # everywhere else — create_master, document lines, the asset/reservation
1539
1597
  # prompt — so it naturally passes fields=["item_code"]. But the real
@@ -1556,22 +1614,36 @@ def _handle_search_masters(args):
1556
1614
  else:
1557
1615
  search_cols = _master_search_columns(db, doctype)
1558
1616
 
1559
- active_prefix = "disabled = 0 AND " if filter_disabled else ""
1560
-
1561
- # Case-insensitive substring match. lower() on both sides is portable (bare
1562
- # LIKE is case-insensitive on SQLite but case-sensitive on Postgres, which
1563
- # silently broke prod search); CAST lets targeted non-text columns match too.
1564
- where = " OR ".join(f'lower(CAST("{col}" AS TEXT)) LIKE ?' for col in search_cols)
1565
- pattern = f"%{query.lower()}%"
1566
- limit_sql = f" LIMIT {limit}" if limit else ""
1567
- rows = db.sql(
1568
- f'SELECT * FROM "{doctype}" WHERE {active_prefix}({where}){limit_sql}',
1569
- [pattern] * len(search_cols),
1570
- )
1571
- if rows:
1572
- return [dict(r) for r in rows]
1573
-
1574
- # Nothing matched literally — try fuzzy matching to catch misspellings.
1617
+ filters = args.get("filters") or {}
1618
+ result_fields = args.get("result_fields") or None
1619
+ offset = args.get("offset", 0) or 0
1620
+ order_by = args.get("order_by")
1621
+ order = args.get("order", "asc")
1622
+ try:
1623
+ result = services.list_master_records(
1624
+ master_type,
1625
+ filters=filters,
1626
+ search=query or None,
1627
+ search_fields=search_cols if query else None,
1628
+ include_disabled=bool(args.get("include_disabled")),
1629
+ order_by=order_by,
1630
+ order=order,
1631
+ limit=limit,
1632
+ offset=offset,
1633
+ fields=result_fields,
1634
+ with_total=False,
1635
+ )
1636
+ except (TypeError, ValueError) as exc:
1637
+ return {"error": str(exc)}
1638
+ rows = [dict(row) for row in result["rows"]]
1639
+ if rows or not query:
1640
+ return rows
1641
+
1642
+ # Preserve the established fuzzy fallback for a simple lookup. A
1643
+ # deterministic field-filter query must return exactly its literal result;
1644
+ # relaxing only the free-text part could otherwise select the wrong record.
1645
+ if filters or offset or order_by or result_fields:
1646
+ return []
1575
1647
  return _fuzzy_master_search(db, doctype, search_cols, query, filter_disabled, limit=limit)
1576
1648
 
1577
1649
 
@@ -1794,6 +1866,98 @@ def _handle_retrieve_chat_attachment(args, session_id: str | None = None, user_i
1794
1866
  }
1795
1867
 
1796
1868
 
1869
+ def _load_camt_chat_attachments(args, user_id: str | None):
1870
+ attachment_ids = args.get("attachment_ids") or []
1871
+ if not user_id:
1872
+ return None, {"error": "No authenticated user for bank statement attachments."}
1873
+ if not isinstance(attachment_ids, list) or not 1 <= len(attachment_ids) <= 20:
1874
+ return None, {"error": "attachment_ids must contain between 1 and 20 attachment ids."}
1875
+ from api.attachments import get_attachments_by_ids
1876
+ from lambda_erp.accounting.camt import CamtError, parse_camt_upload
1877
+
1878
+ attachments = get_attachments_by_ids(attachment_ids, user_id)
1879
+ found = {item["id"] for item in attachments}
1880
+ missing = [item for item in attachment_ids if item not in found]
1881
+ if missing:
1882
+ return None, {"error": "One or more attachments were not found or access was denied."}
1883
+ documents = []
1884
+ try:
1885
+ for attachment in attachments:
1886
+ documents.extend(parse_camt_upload(attachment["data"], attachment["filename"]))
1887
+ except CamtError as exc:
1888
+ return None, {"error": str(exc)}
1889
+ return documents, None
1890
+
1891
+
1892
+ def _handle_preview_bank_statement_attachments(args, user_id: str | None = None):
1893
+ documents, error = _load_camt_chat_attachments(args, user_id)
1894
+ if error:
1895
+ return error
1896
+ from lambda_erp.accounting.bank_statement_import import preview_documents
1897
+ return preview_documents(documents)
1898
+
1899
+
1900
+ def _handle_import_bank_statement_attachments(args, user_info: dict | None = None):
1901
+ role = (user_info or {}).get("role")
1902
+ if role not in {"manager", "admin"}:
1903
+ return {"error": "Importing bank statements requires manager access and is unavailable in demo mode."}
1904
+ if args.get("confirmed") is not True:
1905
+ return {
1906
+ "error": (
1907
+ "Explicit confirmation is required. Show the bank statement preview, ask the user "
1908
+ "to confirm it, and call the import tool in a later turn with confirmed=true."
1909
+ )
1910
+ }
1911
+ user_id = (user_info or {}).get("name")
1912
+ documents, error = _load_camt_chat_attachments(args, user_id)
1913
+ if error:
1914
+ return error
1915
+ from lambda_erp.accounting.bank_statement_import import import_documents, preview_documents
1916
+ from lambda_erp.exceptions import ValidationError
1917
+
1918
+ mappings = args.get("mappings") or {}
1919
+ if not isinstance(mappings, dict):
1920
+ return {"error": "mappings must be an object of statement_key to Bank Account name."}
1921
+ # Exact IBAN matches are safe to fill automatically; anything ambiguous or
1922
+ # unmapped is returned to the model as a focused question for the user.
1923
+ preview = preview_documents(documents)
1924
+ effective_mappings = dict(mappings)
1925
+ missing = []
1926
+ for statement in preview["statements"]:
1927
+ if statement["already_imported"]:
1928
+ continue
1929
+ key = statement["statement_key"]
1930
+ matched = statement.get("matched_bank_account")
1931
+ if key not in effective_mappings and matched:
1932
+ effective_mappings[key] = matched["name"]
1933
+ if key not in effective_mappings:
1934
+ missing.append({
1935
+ "statement_key": key,
1936
+ "file_name": statement["file_name"],
1937
+ "account_iban_masked": statement["account_iban_masked"],
1938
+ "currency": statement["account_currency"],
1939
+ })
1940
+ if missing:
1941
+ return {
1942
+ "error": "Bank Account mapping is required before import.",
1943
+ "unmapped_statements": missing,
1944
+ }
1945
+ try:
1946
+ result = import_documents(
1947
+ documents, effective_mappings, imported_by=user_id
1948
+ )
1949
+ except ValidationError as exc:
1950
+ return {"error": str(exc)}
1951
+ # Keep the model context bounded even for multi-year statements.
1952
+ return {
1953
+ "imports": [
1954
+ {key: value for key, value in item.items() if key != "transactions"}
1955
+ for item in result["imports"]
1956
+ ],
1957
+ "exact_duplicate_imports": result["exact_duplicate_imports"],
1958
+ }
1959
+
1960
+
1797
1961
  def _handle_query_dataset(args):
1798
1962
  from api.routers.analytics import aggregate_semantic_dataset
1799
1963
 
@@ -1932,6 +2096,7 @@ def _handle_apply_company_setup(args):
1932
2096
 
1933
2097
  TOOL_HANDLERS = {
1934
2098
  "list_documents": _handle_list_documents,
2099
+ "get_document_fields": _handle_get_document_fields,
1935
2100
  "get_document": _handle_get_document,
1936
2101
  "create_document": _handle_create_document,
1937
2102
  "update_document": _handle_update_document,
@@ -1950,6 +2115,8 @@ TOOL_HANDLERS = {
1950
2115
  "retrieve_chat_history": lambda args: _handle_retrieve_chat_history(args),
1951
2116
  "list_chat_attachments": _handle_list_chat_attachments,
1952
2117
  "retrieve_chat_attachment": _handle_retrieve_chat_attachment,
2118
+ "preview_bank_statement_attachments": _handle_preview_bank_statement_attachments,
2119
+ "import_bank_statement_attachments": _handle_import_bank_statement_attachments,
1953
2120
  "query_dataset": _handle_query_dataset,
1954
2121
  "create_custom_analytics_report": lambda args: _handle_create_custom_analytics_report(args),
1955
2122
  "get_custom_analytics_report": lambda args: _handle_get_custom_analytics_report(args),
@@ -2183,13 +2350,31 @@ When a user asks you to do something they don't have permission for, explain wha
2183
2350
  ## Available Document Types (use the slug when calling tools)
2184
2351
  - **Selling:** quotation, proposal, sales-order, sales-invoice, pos-invoice
2185
2352
  - **Buying:** purchase-order, purchase-invoice
2186
- - **Accounting:** payment-entry, journal-entry, budget, subscription, bank-transaction
2353
+ - **Accounting:** payment-entry, journal-entry, budget, subscription, bank-account, bank-transaction
2187
2354
  - **Stock:** stock-entry, delivery-note, purchase-receipt
2188
2355
  - **Assets & hire:** asset, reservation
2189
2356
  - **Settings:** pricing-rule{extension_doctypes_line}{chat_doctype_section}
2190
2357
 
2191
2358
  ## Document Workflow & What Each Document Does
2192
2359
 
2360
+ ### CAMT bank statement imports
2361
+ CAMT XML/ZIP attachments are structured financial inputs. Never retrieve their raw contents into
2362
+ the conversation and never try to interpret the XML manually. Use this workflow:
2363
+ 1. Find the attachment ids with `list_chat_attachments` when necessary.
2364
+ 2. Call `preview_bank_statement_attachments` and present its period, masked account, currency,
2365
+ opening/closing balances, credits/debits, entry/detail/batch counts, QR-reference count,
2366
+ duplicate status, warnings, and proposed Bank Account mapping.
2367
+ 3. If there is no exact mapping, list or create a `bank-account` document. It must link the external
2368
+ IBAN to the correct Company and GL Account; never guess that accounting mapping.
2369
+ 4. Ask the user for explicit confirmation after showing the preview. Do not call the import tool in
2370
+ the same turn in which you first show the preview.
2371
+ 5. Only after that confirmation, call `import_bank_statement_attachments` with `confirmed=true`.
2372
+
2373
+ The CAMT import creates unposted Bank Transactions and their batch details only. It never creates,
2374
+ submits, or posts a Payment Entry, Journal Entry, or GL Entry. Do not imply that invoices have been
2375
+ settled or the ledger changed merely because a statement was imported. Matching/reconciliation is a
2376
+ separate later step. Keep Bank Account IBANs masked in ordinary chat responses.
2377
+
2193
2378
  ### Sales Cycle
2194
2379
  Quotation → Sales Order → Delivery Note (shipping) / Sales Invoice (billing) → Payment Entry
2195
2380
  Shortcuts: Quotation can also convert directly to Sales Invoice or Delivery Note (skipping Sales Order) for quick deals.
@@ -2405,10 +2590,10 @@ Every master record has a primary key (the `name` column) and a human-readable d
2405
2590
  When you fill in `item_code`, `customer`, `supplier`, `warehouse`, `company`, etc. in a document or child-table row, you MUST use the **primary key**, never the display name. `"item_code": "Project Management"` is ALWAYS wrong — it's a name, not a code.
2406
2591
 
2407
2592
  If the user refers to something by its human name ("bill them 8 hours of project mgmt", "add Redstone to the quote"), resolve the key first:
2408
- - `search_masters(master_type="item", q="project management")` → returns `[{{name: "SVC-005", item_name: "Project Management"}}]`. Use `name` as `item_code`.
2593
+ - `search_masters(master_type="item", query="project management")` → returns `[{{name: "SVC-005", item_name: "Project Management"}}]`. Use `name` as `item_code`.
2409
2594
  - Same for customers, suppliers, warehouses, etc. — `search_masters` is **case-insensitive** and falls back to **fuzzy matching for misspellings**, so a typo'd name ("Meynex") still resolves. Trust its results instead of concluding "not found" after one narrow try.
2410
2595
  - **Prefer narrowing with `fields`** whenever you know the attribute: search a customer by city with `fields=["city"]`, by email with `fields=["contact_email"]`, etc. It's faster and avoids false matches from unrelated columns. Omitting `fields` searches all standard text fields (a good fallback when you're unsure where the value lives), but large free-text columns (e.g. item `description`) are only searched when you name them explicitly in `fields`.
2411
- - **Don't guess column names** — call `get_master_fields(master_type=...)` first to see the real columns (and which are searched by default), then pass the exact names to `search_masters` `fields`. This also tells you which large text fields (like `description`) you must name explicitly to search.
2596
+ - **Don't guess column names** — call `get_master_fields(master_type=...)` first to see the real columns, which support `contains`, and which are searched by default. Use `query` + `fields` for a fuzzy lookup; use `filters` for exact, combined field conditions. For documents, use `get_document_fields` before constructing unfamiliar `list_documents` filters.
2412
2597
  - **When unsure where a value belongs, discover the schema — don't guess or give up.** Before a `create_master`/`update_master` where you're not certain a field exists or which column fits (the user gives a contact person, a VAT/tax id, a payment term, an IBAN, …), call `get_master_fields(master_type=...)` and map the value onto the real column. Never tell the user you can't store something without checking the fields first — the master usually has a column for it (e.g. a customer's contact person goes in `contact_person`/`contact_phone`/`contact_email`, not the company-level `phone`/`email`).
2413
2598
 
2414
2599
  When you list masters back to the user (items on an invoice, customers on a report), include the key in parentheses so follow-ups are unambiguous. Example: "Project Management (SVC-005) — 16 Hour".
@@ -3022,6 +3207,12 @@ async def run_thinking_loop(
3022
3207
  tool_handlers["retrieve_chat_history"] = lambda args: _handle_retrieve_chat_history(args, session_id)
3023
3208
  user_id_for_tools = user_info.get("name") if user_info else None
3024
3209
  tool_handlers["list_chat_attachments"] = lambda args: _handle_list_chat_attachments(args, session_id, user_id_for_tools)
3210
+ tool_handlers["preview_bank_statement_attachments"] = (
3211
+ lambda args: _handle_preview_bank_statement_attachments(args, user_id_for_tools)
3212
+ )
3213
+ tool_handlers["import_bank_statement_attachments"] = (
3214
+ lambda args: _handle_import_bank_statement_attachments(args, user_info)
3215
+ )
3025
3216
  _scoped_retrieve_attachment = (
3026
3217
  lambda args: _handle_retrieve_chat_attachment(args, session_id, user_id_for_tools)
3027
3218
  )
@@ -19,7 +19,7 @@ from api.auth import router as auth_router, COOKIE_NAME, decode_token
19
19
  from api.oauth import router as oauth_router
20
20
  from api.attachments import router as attachments_router
21
21
  from api.chat import chat_websocket, router as chat_router
22
- from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, analytics, accounting, proposals, chat_api, mcp, availability, actions
22
+ from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, bank_statements, analytics, accounting, proposals, chat_api, mcp, availability, actions
23
23
 
24
24
 
25
25
  def load_plugins() -> None:
@@ -112,6 +112,7 @@ app.include_router(reports.router, prefix="/api")
112
112
  app.include_router(analytics.router, prefix="/api")
113
113
  app.include_router(setup_router.router, prefix="/api")
114
114
  app.include_router(bank_reconciliation.router, prefix="/api")
115
+ app.include_router(bank_statements.router, prefix="/api")
115
116
  app.include_router(accounting.router, prefix="/api")
116
117
  app.include_router(admin.router, prefix="/api")
117
118
  app.include_router(chat_router, prefix="/api")