lambda-erp 0.8.21__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.
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/PKG-INFO +2 -1
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/attachments.py +20 -2
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/chat.py +175 -4
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/main.py +2 -1
- lambda_erp-0.8.22/api/routers/bank_statements.py +118 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/services.py +2 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/frontend/src/api/client.ts +84 -0
- lambda_erp-0.8.22/lambda_erp/accounting/bank_account.py +77 -0
- lambda_erp-0.8.22/lambda_erp/accounting/bank_statement_import.py +417 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/bank_transaction.py +22 -3
- lambda_erp-0.8.22/lambda_erp/accounting/camt.py +457 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/database.py +176 -2
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/pyproject.toml +2 -1
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/.gitignore +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/LICENSE +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/README.md +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/__init__.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/auth.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/bootstrap.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/demo_limits.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/deps.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/errors.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/list_values.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/oauth.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/pdf.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/providers.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/remarks_md.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/__init__.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/accounting.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/actions.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/admin.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/analytics.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/availability.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/chat_api.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/documents.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/masters.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/mcp.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/proposals.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/reports.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/setup.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/templates/document.html +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/templates/proposal.html +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/docs/agents/README.md +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/frontend/LICENSE +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/frontend/README.md +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/model.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.8.21 → 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.
|
|
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
|
|
@@ -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),
|
|
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}"}
|
|
@@ -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).
|
|
@@ -814,10 +814,58 @@ TOOLS = [
|
|
|
814
814
|
"type": "function",
|
|
815
815
|
"function": {
|
|
816
816
|
"name": "list_chat_attachments",
|
|
817
|
-
"description": "List all files the user has uploaded in this chat session — PDFs, images, spreadsheets (Excel/CSV/ODS),
|
|
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.",
|
|
818
818
|
"parameters": {"type": "object", "properties": {}},
|
|
819
819
|
},
|
|
820
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
|
+
},
|
|
821
869
|
{
|
|
822
870
|
"type": "function",
|
|
823
871
|
"function": {
|
|
@@ -1302,15 +1350,20 @@ def build_tools(user_info: dict | None = None):
|
|
|
1302
1350
|
extra_docs = _extra_document_slugs()
|
|
1303
1351
|
extra_masters = _extra_master_types()
|
|
1304
1352
|
action_tools = services.registered_action_tools()
|
|
1353
|
+
hidden_tools: set[str] = set()
|
|
1305
1354
|
if user_info is not None:
|
|
1306
1355
|
role = user_info.get("role")
|
|
1307
1356
|
action_tools = [
|
|
1308
1357
|
tool for tool in action_tools
|
|
1309
1358
|
if services.registered_action_allowed(tool["function"]["name"], role)
|
|
1310
1359
|
]
|
|
1311
|
-
|
|
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:
|
|
1312
1363
|
return TOOLS
|
|
1313
1364
|
tools = copy.deepcopy(TOOLS)
|
|
1365
|
+
if hidden_tools:
|
|
1366
|
+
tools = [tool for tool in tools if tool["function"]["name"] not in hidden_tools]
|
|
1314
1367
|
for tool in tools:
|
|
1315
1368
|
props = tool["function"].get("parameters", {}).get("properties", {})
|
|
1316
1369
|
for schema in props.values():
|
|
@@ -1813,6 +1866,98 @@ def _handle_retrieve_chat_attachment(args, session_id: str | None = None, user_i
|
|
|
1813
1866
|
}
|
|
1814
1867
|
|
|
1815
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
|
+
|
|
1816
1961
|
def _handle_query_dataset(args):
|
|
1817
1962
|
from api.routers.analytics import aggregate_semantic_dataset
|
|
1818
1963
|
|
|
@@ -1970,6 +2115,8 @@ TOOL_HANDLERS = {
|
|
|
1970
2115
|
"retrieve_chat_history": lambda args: _handle_retrieve_chat_history(args),
|
|
1971
2116
|
"list_chat_attachments": _handle_list_chat_attachments,
|
|
1972
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,
|
|
1973
2120
|
"query_dataset": _handle_query_dataset,
|
|
1974
2121
|
"create_custom_analytics_report": lambda args: _handle_create_custom_analytics_report(args),
|
|
1975
2122
|
"get_custom_analytics_report": lambda args: _handle_get_custom_analytics_report(args),
|
|
@@ -2203,13 +2350,31 @@ When a user asks you to do something they don't have permission for, explain wha
|
|
|
2203
2350
|
## Available Document Types (use the slug when calling tools)
|
|
2204
2351
|
- **Selling:** quotation, proposal, sales-order, sales-invoice, pos-invoice
|
|
2205
2352
|
- **Buying:** purchase-order, purchase-invoice
|
|
2206
|
-
- **Accounting:** payment-entry, journal-entry, budget, subscription, bank-transaction
|
|
2353
|
+
- **Accounting:** payment-entry, journal-entry, budget, subscription, bank-account, bank-transaction
|
|
2207
2354
|
- **Stock:** stock-entry, delivery-note, purchase-receipt
|
|
2208
2355
|
- **Assets & hire:** asset, reservation
|
|
2209
2356
|
- **Settings:** pricing-rule{extension_doctypes_line}{chat_doctype_section}
|
|
2210
2357
|
|
|
2211
2358
|
## Document Workflow & What Each Document Does
|
|
2212
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
|
+
|
|
2213
2378
|
### Sales Cycle
|
|
2214
2379
|
Quotation → Sales Order → Delivery Note (shipping) / Sales Invoice (billing) → Payment Entry
|
|
2215
2380
|
Shortcuts: Quotation can also convert directly to Sales Invoice or Delivery Note (skipping Sales Order) for quick deals.
|
|
@@ -3042,6 +3207,12 @@ async def run_thinking_loop(
|
|
|
3042
3207
|
tool_handlers["retrieve_chat_history"] = lambda args: _handle_retrieve_chat_history(args, session_id)
|
|
3043
3208
|
user_id_for_tools = user_info.get("name") if user_info else None
|
|
3044
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
|
+
)
|
|
3045
3216
|
_scoped_retrieve_attachment = (
|
|
3046
3217
|
lambda args: _handle_retrieve_chat_attachment(args, session_id, user_id_for_tools)
|
|
3047
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")
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Manual CAMT statement preview, import history, and audit-file access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
|
9
|
+
from fastapi.responses import Response
|
|
10
|
+
|
|
11
|
+
from api.auth import require_non_public_manager, require_role
|
|
12
|
+
from lambda_erp.accounting.bank_statement_import import import_documents, preview_documents
|
|
13
|
+
from lambda_erp.accounting.camt import CamtError, MAX_ARCHIVE_BYTES, mask_iban, parse_camt_upload
|
|
14
|
+
from lambda_erp.database import get_db
|
|
15
|
+
from lambda_erp.exceptions import ValidationError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
router = APIRouter(prefix="/bank-statements", tags=["bank-statements"])
|
|
19
|
+
MAX_TOTAL_UPLOAD_BYTES = 50 * 1024 * 1024
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def _parse_uploads(files: list[UploadFile]):
|
|
23
|
+
if not files:
|
|
24
|
+
raise HTTPException(status_code=422, detail="Select at least one CAMT XML or ZIP file")
|
|
25
|
+
documents = []
|
|
26
|
+
total = 0
|
|
27
|
+
for upload in files:
|
|
28
|
+
data = await upload.read(MAX_ARCHIVE_BYTES + 1)
|
|
29
|
+
total += len(data)
|
|
30
|
+
if len(data) > MAX_ARCHIVE_BYTES:
|
|
31
|
+
raise HTTPException(status_code=413, detail=f"File {upload.filename!r} is too large")
|
|
32
|
+
if total > MAX_TOTAL_UPLOAD_BYTES:
|
|
33
|
+
raise HTTPException(status_code=413, detail="Combined uploads are too large")
|
|
34
|
+
try:
|
|
35
|
+
documents.extend(parse_camt_upload(data, upload.filename or "statement.xml"))
|
|
36
|
+
except CamtError as exc:
|
|
37
|
+
raise HTTPException(status_code=422, detail=f"{upload.filename}: {exc}") from exc
|
|
38
|
+
return documents
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@router.post("/preview")
|
|
42
|
+
async def preview_bank_statements(
|
|
43
|
+
files: list[UploadFile] = File(...),
|
|
44
|
+
_user: dict = Depends(require_non_public_manager),
|
|
45
|
+
):
|
|
46
|
+
"""Parse uploads without writing any statement or transaction data."""
|
|
47
|
+
return preview_documents(await _parse_uploads(files))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@router.post("/import")
|
|
51
|
+
async def import_bank_statements(
|
|
52
|
+
files: list[UploadFile] = File(...),
|
|
53
|
+
mappings: str = Form(...),
|
|
54
|
+
user: dict = Depends(require_non_public_manager),
|
|
55
|
+
):
|
|
56
|
+
"""Re-parse and atomically import the previewed uploads."""
|
|
57
|
+
try:
|
|
58
|
+
parsed_mappings = json.loads(mappings)
|
|
59
|
+
except (TypeError, json.JSONDecodeError) as exc:
|
|
60
|
+
raise HTTPException(status_code=422, detail="mappings must be a JSON object") from exc
|
|
61
|
+
if not isinstance(parsed_mappings, dict) or not all(
|
|
62
|
+
isinstance(key, str) and isinstance(value, str)
|
|
63
|
+
for key, value in parsed_mappings.items()
|
|
64
|
+
):
|
|
65
|
+
raise HTTPException(status_code=422, detail="mappings must map statement keys to Bank Accounts")
|
|
66
|
+
try:
|
|
67
|
+
return import_documents(
|
|
68
|
+
await _parse_uploads(files), parsed_mappings, imported_by=user.get("name")
|
|
69
|
+
)
|
|
70
|
+
except ValidationError as exc:
|
|
71
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@router.get("")
|
|
75
|
+
def list_bank_statement_imports(
|
|
76
|
+
limit: int = 50,
|
|
77
|
+
_user: dict = Depends(require_role("viewer")),
|
|
78
|
+
):
|
|
79
|
+
limit = max(1, min(limit, 200))
|
|
80
|
+
rows = get_db().sql(
|
|
81
|
+
'SELECT i.name, i.bank_account, b.account_name AS bank_account_name, '
|
|
82
|
+
'i.source_filename, i.schema_version, i.account_iban, i.currency, '
|
|
83
|
+
'i.from_date, i.to_date, i.opening_balance, i.closing_balance, '
|
|
84
|
+
'i.imported_entry_count, i.duplicate_entry_count, i.warning_count, '
|
|
85
|
+
'i.status, i.imported_by, i.creation '
|
|
86
|
+
'FROM "Bank Statement Import" i '
|
|
87
|
+
'LEFT JOIN "Bank Account" b ON b.name = i.bank_account '
|
|
88
|
+
'ORDER BY i.creation DESC LIMIT ?',
|
|
89
|
+
[limit],
|
|
90
|
+
)
|
|
91
|
+
result = []
|
|
92
|
+
for row in rows:
|
|
93
|
+
item = dict(row)
|
|
94
|
+
item["account_iban_masked"] = mask_iban(item.pop("account_iban") or "")
|
|
95
|
+
result.append(item)
|
|
96
|
+
return {"rows": result}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@router.get("/{import_name}/source")
|
|
100
|
+
def download_bank_statement_source(
|
|
101
|
+
import_name: str,
|
|
102
|
+
_user: dict = Depends(require_non_public_manager),
|
|
103
|
+
):
|
|
104
|
+
rows = get_db().sql(
|
|
105
|
+
'SELECT i.source_filename, s.source_data '
|
|
106
|
+
'FROM "Bank Statement Import" i '
|
|
107
|
+
'JOIN "Bank Statement Source" s ON s.import_name = i.name '
|
|
108
|
+
'WHERE i.name = ?',
|
|
109
|
+
[import_name],
|
|
110
|
+
)
|
|
111
|
+
if not rows:
|
|
112
|
+
raise HTTPException(status_code=404, detail="Bank statement source not found")
|
|
113
|
+
filename = rows[0]["source_filename"] or f"{import_name}.xml"
|
|
114
|
+
return Response(
|
|
115
|
+
content=bytes(rows[0]["source_data"]),
|
|
116
|
+
media_type="application/xml",
|
|
117
|
+
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
|
|
118
|
+
)
|
|
@@ -25,6 +25,7 @@ from lambda_erp.controllers.pricing_rule import PricingRule
|
|
|
25
25
|
from lambda_erp.accounting.budget import Budget
|
|
26
26
|
from lambda_erp.accounting.subscription import Subscription
|
|
27
27
|
from lambda_erp.accounting.bank_transaction import BankTransaction
|
|
28
|
+
from lambda_erp.accounting.bank_account import BankAccount
|
|
28
29
|
from lambda_erp.assets.asset import Asset
|
|
29
30
|
from lambda_erp.assets.reservation import Reservation
|
|
30
31
|
|
|
@@ -48,6 +49,7 @@ DOCUMENT_CLASSES = {
|
|
|
48
49
|
"Budget": Budget,
|
|
49
50
|
"Subscription": Subscription,
|
|
50
51
|
"Bank Transaction": BankTransaction,
|
|
52
|
+
"Bank Account": BankAccount,
|
|
51
53
|
# Neither posts to the GL or the Stock Ledger; both stay at docstatus 0 and
|
|
52
54
|
# carry their meaning in `status`. Registered here so the generic document
|
|
53
55
|
# CRUD, the chat tools and MCP drive them like every other doctype.
|
|
@@ -61,6 +61,71 @@ export async function request<T>(path: string, options?: RequestInit): Promise<T
|
|
|
61
61
|
return res.json();
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
async function multipartRequest<T>(path: string, form: FormData): Promise<T> {
|
|
65
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
credentials: "include",
|
|
68
|
+
body: form,
|
|
69
|
+
});
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
if (res.status === 401) {
|
|
72
|
+
window.location.href = "/login";
|
|
73
|
+
throw new ApiError(401, "Session expired");
|
|
74
|
+
}
|
|
75
|
+
const body = await res.json().catch(() => ({ detail: res.statusText }));
|
|
76
|
+
throw new ApiError(res.status, formatErrorDetail(body.detail));
|
|
77
|
+
}
|
|
78
|
+
return res.json() as Promise<T>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface BankStatementPreview {
|
|
82
|
+
statement_key: string;
|
|
83
|
+
file_name: string;
|
|
84
|
+
schema_version: string;
|
|
85
|
+
statement_id: string;
|
|
86
|
+
account_iban_masked: string;
|
|
87
|
+
account_name: string | null;
|
|
88
|
+
account_currency: string;
|
|
89
|
+
bank_name: string | null;
|
|
90
|
+
bank_bic: string | null;
|
|
91
|
+
from_date: string | null;
|
|
92
|
+
to_date: string | null;
|
|
93
|
+
opening_balance: string | null;
|
|
94
|
+
closing_balance: string | null;
|
|
95
|
+
credit_total: string;
|
|
96
|
+
debit_total: string;
|
|
97
|
+
entry_count: number;
|
|
98
|
+
booked_entry_count: number;
|
|
99
|
+
detail_count: number;
|
|
100
|
+
batch_entry_count: number;
|
|
101
|
+
qr_reference_count: number;
|
|
102
|
+
duplicate_entry_count: number;
|
|
103
|
+
already_imported: boolean;
|
|
104
|
+
existing_import: string | null;
|
|
105
|
+
matched_bank_account: {
|
|
106
|
+
name: string;
|
|
107
|
+
account_name: string;
|
|
108
|
+
company: string;
|
|
109
|
+
account: string;
|
|
110
|
+
currency: string;
|
|
111
|
+
bank_name: string | null;
|
|
112
|
+
iban_masked: string;
|
|
113
|
+
} | null;
|
|
114
|
+
warnings: string[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface BankStatementImportResult {
|
|
118
|
+
imports: Array<{
|
|
119
|
+
name: string;
|
|
120
|
+
bank_account: string;
|
|
121
|
+
account_iban_masked: string;
|
|
122
|
+
imported_entry_count: number;
|
|
123
|
+
duplicate_entry_count: number;
|
|
124
|
+
skipped_non_booked_count: number;
|
|
125
|
+
}>;
|
|
126
|
+
exact_duplicate_imports: string[];
|
|
127
|
+
}
|
|
128
|
+
|
|
64
129
|
function qs(params?: Record<string, string | number | undefined>) {
|
|
65
130
|
if (!params) return "";
|
|
66
131
|
const clean = Object.fromEntries(
|
|
@@ -153,6 +218,25 @@ export const api = {
|
|
|
153
218
|
},
|
|
154
219
|
getChatAttachmentUrl: (id: string) => `${BASE}/chat/attachments/${encodeURIComponent(id)}`,
|
|
155
220
|
|
|
221
|
+
previewBankStatements: (files: File[]) => {
|
|
222
|
+
const form = new FormData();
|
|
223
|
+
files.forEach((file) => form.append("files", file));
|
|
224
|
+
return multipartRequest<{ statements: BankStatementPreview[] }>("/bank-statements/preview", form);
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
importBankStatements: (files: File[], mappings: Record<string, string>) => {
|
|
228
|
+
const form = new FormData();
|
|
229
|
+
files.forEach((file) => form.append("files", file));
|
|
230
|
+
form.append("mappings", JSON.stringify(mappings));
|
|
231
|
+
return multipartRequest<BankStatementImportResult>("/bank-statements/import", form);
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
listBankStatementImports: (limit = 50) =>
|
|
235
|
+
request<{ rows: any[] }>(`/bank-statements${qs({ limit })}`),
|
|
236
|
+
|
|
237
|
+
bankStatementSourceUrl: (name: string) =>
|
|
238
|
+
`${BASE}/bank-statements/${encodeURIComponent(name)}/source`,
|
|
239
|
+
|
|
156
240
|
// Chat
|
|
157
241
|
createChatSession: () =>
|
|
158
242
|
request<{ id: string; title: string; created_at: string; updated_at: string; last_message_at?: string | null }>(
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Bank-account master used to map an external IBAN to a GL account."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from lambda_erp.database import get_db
|
|
6
|
+
from lambda_erp.exceptions import ValidationError
|
|
7
|
+
from lambda_erp.model import Document
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_IBAN_RE = re.compile(r"^[A-Z]{2}[0-9A-Z]{13,32}$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def normalize_iban(value: str | None) -> str:
|
|
14
|
+
"""Return a compact uppercase IBAN and validate its checksum.
|
|
15
|
+
|
|
16
|
+
IBANs are identifiers, not display strings. Persisting one canonical form
|
|
17
|
+
makes account lookup and CAMT imports deterministic even when users paste
|
|
18
|
+
spaces or lowercase characters from a bank document.
|
|
19
|
+
"""
|
|
20
|
+
iban = re.sub(r"\s+", "", value or "").upper()
|
|
21
|
+
if not _IBAN_RE.fullmatch(iban):
|
|
22
|
+
raise ValidationError("IBAN has an invalid format")
|
|
23
|
+
rearranged = iban[4:] + iban[:4]
|
|
24
|
+
numeric = "".join(str(ord(ch) - 55) if ch.isalpha() else ch for ch in rearranged)
|
|
25
|
+
remainder = 0
|
|
26
|
+
for offset in range(0, len(numeric), 9):
|
|
27
|
+
remainder = int(str(remainder) + numeric[offset:offset + 9]) % 97
|
|
28
|
+
if remainder != 1:
|
|
29
|
+
raise ValidationError("IBAN checksum is invalid")
|
|
30
|
+
return iban
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BankAccount(Document):
|
|
34
|
+
"""A real-world bank account mapped to one ledger Bank account."""
|
|
35
|
+
|
|
36
|
+
DOCTYPE = "Bank Account"
|
|
37
|
+
CHILD_TABLES = {}
|
|
38
|
+
PREFIX = "BANK"
|
|
39
|
+
|
|
40
|
+
LINK_FIELDS = {
|
|
41
|
+
"company": "Company",
|
|
42
|
+
"account": "Account",
|
|
43
|
+
}
|
|
44
|
+
ACCOUNT_TYPE_CONSTRAINTS = {
|
|
45
|
+
"account": {"account_type": "Bank"},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
def validate(self):
|
|
49
|
+
if not self.account_name:
|
|
50
|
+
raise ValidationError("Bank Account Name is required")
|
|
51
|
+
if not self.company:
|
|
52
|
+
raise ValidationError("Company is required")
|
|
53
|
+
if not self.account:
|
|
54
|
+
raise ValidationError("GL Account is required")
|
|
55
|
+
|
|
56
|
+
self._data["iban"] = normalize_iban(self.iban)
|
|
57
|
+
account = get_db().get_value(
|
|
58
|
+
"Account", self.account, ["company", "account_currency", "account_type"]
|
|
59
|
+
)
|
|
60
|
+
if account:
|
|
61
|
+
if account.get("company") != self.company:
|
|
62
|
+
raise ValidationError("GL Account belongs to a different company")
|
|
63
|
+
account_currency = (account.get("account_currency") or "").upper()
|
|
64
|
+
requested_currency = (self.currency or account_currency).upper()
|
|
65
|
+
if account_currency and requested_currency != account_currency:
|
|
66
|
+
raise ValidationError(
|
|
67
|
+
f"Bank Account currency {requested_currency} does not match "
|
|
68
|
+
f"GL Account currency {account_currency}"
|
|
69
|
+
)
|
|
70
|
+
self._data["currency"] = requested_currency
|
|
71
|
+
|
|
72
|
+
existing = get_db().sql(
|
|
73
|
+
'SELECT name FROM "Bank Account" WHERE iban = ? AND name <> ? LIMIT 1',
|
|
74
|
+
[self.iban, self.name],
|
|
75
|
+
)
|
|
76
|
+
if existing:
|
|
77
|
+
raise ValidationError("This IBAN is already mapped to another Bank Account")
|