lambda-erp 0.8.26__tar.gz → 0.8.28__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 (95) hide show
  1. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/PKG-INFO +1 -1
  2. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/chat.py +30 -12
  3. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/routers/bank_reconciliation.py +10 -0
  4. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/frontend/src/api/client.ts +11 -1
  5. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/bank_reconciliation.py +253 -47
  6. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/database.py +22 -0
  7. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/pyproject.toml +1 -1
  8. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/.gitignore +0 -0
  9. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/LICENSE +0 -0
  10. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/README.md +0 -0
  11. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/__init__.py +0 -0
  12. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/attachments.py +0 -0
  13. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/auth.py +0 -0
  14. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/bootstrap.py +0 -0
  15. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/demo_limits.py +0 -0
  16. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/deps.py +0 -0
  17. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/errors.py +0 -0
  18. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/list_values.py +0 -0
  19. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/main.py +0 -0
  20. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/oauth.py +0 -0
  21. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/pdf.py +0 -0
  22. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/providers.py +0 -0
  23. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/remarks_md.py +0 -0
  24. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/routers/__init__.py +0 -0
  25. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/routers/accounting.py +0 -0
  26. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/routers/actions.py +0 -0
  27. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/routers/admin.py +0 -0
  28. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/routers/analytics.py +0 -0
  29. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/routers/availability.py +0 -0
  30. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/routers/bank_statements.py +0 -0
  31. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/routers/chat_api.py +0 -0
  32. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/routers/documents.py +0 -0
  33. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/routers/masters.py +0 -0
  34. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/routers/mcp.py +0 -0
  35. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/routers/proposals.py +0 -0
  36. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/routers/reports.py +0 -0
  37. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/routers/setup.py +0 -0
  38. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/services.py +0 -0
  39. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/templates/document.html +0 -0
  40. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/api/templates/proposal.html +0 -0
  41. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/docs/agents/README.md +0 -0
  42. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/frontend/LICENSE +0 -0
  43. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/frontend/README.md +0 -0
  44. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/__init__.py +0 -0
  45. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/__init__.py +0 -0
  46. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/bank_account.py +0 -0
  47. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/bank_statement_import.py +0 -0
  48. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/bank_transaction.py +0 -0
  49. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/budget.py +0 -0
  50. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/camt.py +0 -0
  51. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  52. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/general_ledger.py +0 -0
  53. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/journal_entry.py +0 -0
  54. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/payment_entry.py +0 -0
  55. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/pos_invoice.py +0 -0
  56. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/purchase_invoice.py +0 -0
  57. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/revaluation.py +0 -0
  58. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/sales_invoice.py +0 -0
  59. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/setup/__init__.py +0 -0
  60. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/setup/engine.py +0 -0
  61. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/setup/pack.py +0 -0
  62. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  63. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  64. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  65. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  66. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  67. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  68. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/setup/profiles.py +0 -0
  69. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/setup/spine.py +0 -0
  70. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/accounting/subscription.py +0 -0
  71. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/assets/__init__.py +0 -0
  72. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/assets/asset.py +0 -0
  73. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/assets/reservation.py +0 -0
  74. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/buying/__init__.py +0 -0
  75. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/buying/purchase_order.py +0 -0
  76. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/controllers/__init__.py +0 -0
  77. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/controllers/currency.py +0 -0
  78. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/controllers/defaults.py +0 -0
  79. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/controllers/pricing_rule.py +0 -0
  80. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  81. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/exceptions.py +0 -0
  82. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/hooks.py +0 -0
  83. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/model.py +0 -0
  84. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/selling/__init__.py +0 -0
  85. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/selling/proposal.py +0 -0
  86. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/selling/quotation.py +0 -0
  87. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/selling/sales_order.py +0 -0
  88. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/simulation.py +0 -0
  89. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/stock/__init__.py +0 -0
  90. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/stock/delivery_note.py +0 -0
  91. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/stock/purchase_receipt.py +0 -0
  92. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/stock/stock_entry.py +0 -0
  93. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/stock/stock_ledger.py +0 -0
  94. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/lambda_erp/utils.py +0 -0
  95. {lambda_erp-0.8.26 → lambda_erp-0.8.28}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 0.8.26
3
+ Version: 0.8.28
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -899,11 +899,17 @@ TOOLS = [
899
899
  "type": "function",
900
900
  "function": {
901
901
  "name": "reconcile_bank_transaction",
902
- "description": "Reconcile one imported bank transaction after explicit user confirmation. mode=invoice_payment creates and submits a Payment Entry allocated to one or more invoices; mode=existing_voucher links an already-submitted Payment Entry or Journal Entry with an exactly matching bank leg; mode=journal creates and submits a two-sided Journal Entry against a non-bank, non-AR/AP account. This posts or links real accounting data. Never call in the same turn as the first proposal; confirmed must reflect a later explicit confirmation.",
902
+ "description": "Reconcile imported bank transactions after explicit user confirmation. Always pass bank_transactions: use a one-item list for an individual reconciliation and the complete list returned by an existing_voucher_group suggestion for a grouped reconciliation. mode=invoice_payment creates and submits a Payment Entry allocated to one or more invoices; mode=existing_voucher links an already-submitted Payment Entry or Journal Entry with an exactly matching bank leg, atomically for a group; mode=journal creates and submits a two-sided Journal Entry against a non-bank, non-AR/AP account. invoice_payment and journal accept exactly one bank transaction. This posts or links real accounting data. Never call in the same turn as the first proposal; confirmed must reflect a later explicit confirmation.",
903
903
  "parameters": {
904
904
  "type": "object",
905
905
  "properties": {
906
- "bank_transaction": {"type": "string"},
906
+ "bank_transactions": {
907
+ "type": "array",
908
+ "items": {"type": "string"},
909
+ "minItems": 1,
910
+ "maxItems": 100,
911
+ "description": "Bank Transaction names to reconcile: exactly one for an individual match/payment/journal, or the complete exact group returned by the suggestion.",
912
+ },
907
913
  "mode": {"type": "string", "enum": ["invoice_payment", "existing_voucher", "journal"]},
908
914
  "allocations": {
909
915
  "type": "array",
@@ -924,7 +930,7 @@ TOOLS = [
924
930
  "remarks": {"type": "string"},
925
931
  "confirmed": {"type": "boolean"},
926
932
  },
927
- "required": ["bank_transaction", "mode", "confirmed"],
933
+ "required": ["bank_transactions", "mode", "confirmed"],
928
934
  },
929
935
  },
930
936
  },
@@ -932,7 +938,7 @@ TOOLS = [
932
938
  "type": "function",
933
939
  "function": {
934
940
  "name": "undo_bank_reconciliation",
935
- "description": "Reverse an active bank reconciliation after explicit user confirmation. Vouchers created by reconciliation are cancelled and reversed; an existing linked voucher is only unlinked and remains submitted. Never call without explaining that distinction and receiving confirmation.",
941
+ "description": "Reverse an active bank reconciliation after explicit user confirmation. Vouchers created by reconciliation are cancelled and reversed; an existing linked voucher is only unlinked and remains submitted. If the selected transaction belongs to a group, every transaction in that group is unlinked atomically. Never call without listing the affected group, explaining that distinction, and receiving confirmation.",
936
942
  "parameters": {
937
943
  "type": "object",
938
944
  "properties": {
@@ -2076,26 +2082,32 @@ def _handle_reconcile_bank_transaction(args, user_info: dict | None = None):
2076
2082
  )
2077
2083
  }
2078
2084
  from lambda_erp.accounting.bank_reconciliation import (
2079
- reconcile_with_existing_voucher,
2085
+ reconcile_with_existing_voucher_group,
2080
2086
  reconcile_with_journal,
2081
2087
  reconcile_with_payment,
2082
2088
  )
2083
2089
  user = (user_info or {}).get("name")
2084
2090
  mode = args.get("mode")
2091
+ bank_transactions = args.get("bank_transactions")
2092
+ if not isinstance(bank_transactions, list) or not bank_transactions:
2093
+ return {"error": "bank_transactions must contain at least one Bank Transaction name"}
2094
+ if mode in {"invoice_payment", "journal"} and len(bank_transactions) != 1:
2095
+ return {"error": f"mode={mode} requires exactly one bank transaction"}
2096
+ bank_transaction = bank_transactions[0]
2085
2097
  try:
2086
2098
  if mode == "invoice_payment":
2087
2099
  return reconcile_with_payment(
2088
- args.get("bank_transaction"), args.get("allocations") or [],
2100
+ bank_transaction, args.get("allocations") or [],
2089
2101
  conversion_rate=args.get("conversion_rate"), user=user, confirmed=True,
2090
2102
  )
2091
2103
  if mode == "existing_voucher":
2092
- return reconcile_with_existing_voucher(
2093
- args.get("bank_transaction"), args.get("voucher_type"), args.get("voucher_no"),
2104
+ return reconcile_with_existing_voucher_group(
2105
+ bank_transactions, args.get("voucher_type"), args.get("voucher_no"),
2094
2106
  user=user, confirmed=True,
2095
2107
  )
2096
2108
  if mode == "journal":
2097
2109
  return reconcile_with_journal(
2098
- args.get("bank_transaction"), args.get("counterparty_account"),
2110
+ bank_transaction, args.get("counterparty_account"),
2099
2111
  conversion_rate=args.get("conversion_rate"), remarks=args.get("remarks"),
2100
2112
  user=user, confirmed=True,
2101
2113
  )
@@ -2550,15 +2562,21 @@ masked in ordinary chat responses.
2550
2562
  ### Bank reconciliation
2551
2563
  Use `list_bank_reconciliation_queue` and then `suggest_bank_reconciliation`; never guess from a raw
2552
2564
  description alone. Explain one exact proposal before doing anything. Reconciliation has three safe
2553
- paths: (1) link an existing submitted Payment/Journal voucher whose bank leg matches exactly,
2565
+ paths: (1) link an existing submitted Payment/Journal voucher whose bank leg matches exactly—either
2566
+ one transaction or every transaction in an exact `existing_voucher_group`,
2554
2567
  (2) create and submit a Payment Entry allocated to one or more open invoices of the same party, or
2555
2568
  (3) create and submit a Journal Entry against a user-selected non-bank, non-AR/AP account. A created
2556
2569
  voucher changes the GL and may change invoice outstanding amounts. Ask for explicit confirmation in
2557
2570
  a later user turn, then call `reconcile_bank_transaction` with `confirmed=true`. Clearly disclose any
2558
- unallocated on-account remainder. Never match a Bank Transaction directly to an invoice because that
2571
+ unallocated on-account remainder. Disclose all group members and their total before asking for
2572
+ confirmation. Every reconciliation call uses `bank_transactions`: pass a one-item list for an
2573
+ individual reconciliation; for a group, copy the complete list from the suggestion and never add or
2574
+ omit IDs.
2575
+ Never match a Bank Transaction directly to an invoice because that
2559
2576
  would omit the cash posting. For reversals, first explain whether the voucher was created by the
2560
2577
  reconciliation (it will be cancelled and reversed) or merely linked (it will only be unlinked), and
2561
- then require explicit confirmation before `undo_bank_reconciliation`.
2578
+ whether the selected transaction belongs to a group that will be unlinked together, and then require
2579
+ explicit confirmation before `undo_bank_reconciliation`.
2562
2580
 
2563
2581
  ### Sales Cycle
2564
2582
  Quotation → Sales Order → Delivery Note (shipping) / Sales Invoice (billing) → Payment Entry
@@ -6,6 +6,7 @@ from api.auth import require_role
6
6
  from lambda_erp.accounting.bank_reconciliation import (
7
7
  list_bank_transactions,
8
8
  reconcile_with_existing_voucher,
9
+ reconcile_with_existing_voucher_group,
9
10
  reconcile_with_journal,
10
11
  reconcile_with_payment,
11
12
  suggest_matches,
@@ -58,6 +59,15 @@ def post_journal(data: dict, user: dict = Depends(require_role("manager"))):
58
59
 
59
60
  @router.post("/bank-reconciliation/match-existing")
60
61
  def match_existing(data: dict, user: dict = Depends(require_role("manager"))):
62
+ bank_transactions = data.get("bank_transactions")
63
+ if bank_transactions:
64
+ return reconcile_with_existing_voucher_group(
65
+ bank_transactions,
66
+ data.get("voucher_type"),
67
+ data.get("voucher_no"),
68
+ user=user.get("name"),
69
+ confirmed=data.get("confirmed") is True,
70
+ )
61
71
  return reconcile_with_existing_voucher(
62
72
  data.get("bank_transaction"),
63
73
  data.get("voucher_type"),
@@ -172,7 +172,7 @@ export interface BankInvoiceSuggestion {
172
172
  }
173
173
 
174
174
  export interface BankVoucherSuggestion {
175
- kind: "existing_voucher";
175
+ kind: "existing_voucher" | "existing_voucher_group";
176
176
  voucher_type: "Payment Entry" | "Journal Entry";
177
177
  voucher_no: string;
178
178
  posting_date: string;
@@ -180,6 +180,15 @@ export interface BankVoucherSuggestion {
180
180
  currency: string;
181
181
  score: number;
182
182
  reasons: string[];
183
+ bank_transactions: Array<{
184
+ name: string;
185
+ posting_date: string;
186
+ amount: number;
187
+ deposit: number;
188
+ withdrawal: number;
189
+ counterparty_name: string | null;
190
+ description: string | null;
191
+ }>;
183
192
  }
184
193
 
185
194
  export interface BankReconciliationSuggestions {
@@ -327,6 +336,7 @@ export const api = {
327
336
 
328
337
  reconcileBankExistingVoucher: (data: {
329
338
  bank_transaction: string;
339
+ bank_transactions?: string[];
330
340
  voucher_type: string;
331
341
  voucher_no: string;
332
342
  confirmed: boolean;
@@ -2,9 +2,10 @@
2
2
 
3
3
  An imported Bank Transaction is evidence from the bank, not a ledger posting.
4
4
  Reconciliation either links that evidence to an existing submitted voucher or
5
- creates and submits the appropriate Payment Entry / Journal Entry. The active
6
- link is unique and every reversal remains in ``Bank Reconciliation`` as audit
7
- history.
5
+ creates and submits the appropriate Payment Entry / Journal Entry. One exact
6
+ voucher bank leg may cover a group of imported transactions, but it can only be
7
+ consumed by one active group. Every transaction keeps its own audit row and
8
+ every reversal remains in ``Bank Reconciliation`` as history.
8
9
  """
9
10
 
10
11
  from __future__ import annotations
@@ -59,6 +60,10 @@ def _is_deposit(tx: dict) -> bool:
59
60
  return flt(tx.get("deposit")) > 0
60
61
 
61
62
 
63
+ def _signed_amount(tx: dict) -> float:
64
+ return _amount(tx) if _is_deposit(tx) else -_amount(tx)
65
+
66
+
62
67
  def _active_reconciliation(bank_transaction: str) -> dict | None:
63
68
  rows = get_db().sql(
64
69
  'SELECT * FROM "Bank Reconciliation" '
@@ -68,6 +73,25 @@ def _active_reconciliation(bank_transaction: str) -> dict | None:
68
73
  return dict(rows[0]) if rows else None
69
74
 
70
75
 
76
+ def _group_rows(group_id: str | None, *, status: str = "Active") -> list[dict]:
77
+ if not group_id:
78
+ return []
79
+ return [dict(row) for row in get_db().sql(
80
+ 'SELECT * FROM "Bank Reconciliation" '
81
+ 'WHERE group_id = ? AND status = ? ORDER BY group_head DESC, name',
82
+ [group_id, status],
83
+ )]
84
+
85
+
86
+ def _public_active_reconciliation(active: dict | None) -> dict | None:
87
+ if not active:
88
+ return None
89
+ result = dict(active)
90
+ rows = _group_rows(result.get("group_id"))
91
+ result["bank_transactions"] = [row["bank_transaction"] for row in rows]
92
+ return result
93
+
94
+
71
95
  def _ensure_available(tx: dict) -> None:
72
96
  if _amount(tx) <= 0 or tx.get("status") == "Informational":
73
97
  raise ValidationError("Informational or zero-value bank transactions cannot be reconciled")
@@ -244,6 +268,93 @@ def _invoice_candidates(tx: dict, limit: int) -> list[dict]:
244
268
  return candidates[:limit]
245
269
 
246
270
 
271
+ def _compact_group_transaction(tx: dict) -> dict:
272
+ return {
273
+ "name": tx.get("name"),
274
+ "posting_date": tx.get("posting_date"),
275
+ "amount": _amount(tx),
276
+ "deposit": flt(tx.get("deposit"), 2),
277
+ "withdrawal": flt(tx.get("withdrawal"), 2),
278
+ "counterparty_name": tx.get("counterparty_name"),
279
+ "description": tx.get("description"),
280
+ }
281
+
282
+
283
+ def _same_day_group_pool(tx: dict, voucher_date: str) -> list[dict] | None:
284
+ rows = [dict(row) for row in get_db().sql(
285
+ 'SELECT name, posting_date, deposit, withdrawal, counterparty_name, description '
286
+ 'FROM "Bank Transaction" WHERE bank_account = ? AND currency = ? '
287
+ 'AND posting_date = ? AND status = ? AND bank_statement_import IS NOT NULL '
288
+ 'ORDER BY name LIMIT 101',
289
+ [tx["bank_account"], tx.get("currency"), voucher_date, "Unreconciled"],
290
+ )]
291
+ return None if len(rows) > 100 else rows
292
+
293
+
294
+ def _same_day_transaction_group(tx: dict, target_movement: float,
295
+ voucher_date: str | None,
296
+ pool: list[dict] | None) -> list[dict] | None:
297
+ """Find a deterministic exact same-day subset that includes ``tx``.
298
+
299
+ Group suggestions intentionally stay conservative: automatic grouping is
300
+ limited to imported, unreconciled transactions on the exact voucher date,
301
+ bank account, currency, and movement direction. Explicit reconciliation
302
+ still validates the complete supplied group independently.
303
+ """
304
+ if not voucher_date or tx.get("posting_date") != voucher_date:
305
+ return None
306
+ if (target_movement > 0) != _is_deposit(tx):
307
+ return None
308
+ target_cents = round(abs(target_movement) * 100)
309
+ selected_cents = round(_amount(tx) * 100)
310
+ if selected_cents <= 0 or selected_cents >= target_cents:
311
+ return None
312
+
313
+ if pool is None:
314
+ return None
315
+ selected = next((row for row in pool if row["name"] == tx["name"]), None)
316
+ if not selected:
317
+ return None
318
+ companions = [
319
+ row for row in pool
320
+ if row["name"] != tx["name"]
321
+ and (_is_deposit(row) == _is_deposit(tx))
322
+ and 0 < round(_amount(row) * 100) <= target_cents - selected_cents
323
+ ]
324
+ remainder = target_cents - selected_cents
325
+
326
+ if companions and sum(round(_amount(row) * 100) for row in companions) == remainder:
327
+ return [selected, *companions]
328
+
329
+ # Retain up to two ways to reach each subtotal. A suggestion is emitted only
330
+ # when the exact subset is unique; ambiguity is left for manual review.
331
+ states: dict[int, list[tuple[dict, ...]]] = {0: [()]}
332
+ for row in companions:
333
+ cents = round(_amount(row) * 100)
334
+ additions: dict[int, list[tuple[dict, ...]]] = {}
335
+ for subtotal, variants in list(states.items()):
336
+ for members in variants:
337
+ if len(members) >= 19:
338
+ continue
339
+ new_total = subtotal + cents
340
+ if new_total > remainder:
341
+ continue
342
+ bucket = additions.setdefault(new_total, [])
343
+ candidate = (*members, row)
344
+ if candidate not in bucket and len(bucket) < 2:
345
+ bucket.append(candidate)
346
+ for subtotal, variants in additions.items():
347
+ bucket = states.setdefault(subtotal, [])
348
+ for variant in variants:
349
+ if variant not in bucket and len(bucket) < 2:
350
+ bucket.append(variant)
351
+ # Refuse an expensive or ambiguous search instead of guessing.
352
+ if len(states) > 50_000:
353
+ return None
354
+ matches = states.get(remainder, [])
355
+ return [selected, *matches[0]] if len(matches) == 1 else None
356
+
357
+
247
358
  def _voucher_candidates(tx: dict, limit: int) -> list[dict]:
248
359
  db = get_db()
249
360
  rows = db.sql(
@@ -256,7 +367,7 @@ def _voucher_candidates(tx: dict, limit: int) -> list[dict]:
256
367
  'AND gle.voucher_type IN (?, ?) '
257
368
  'AND NOT EXISTS (SELECT 1 FROM "Bank Reconciliation" br '
258
369
  ' WHERE br.voucher_type = gle.voucher_type AND br.voucher_no = gle.voucher_no '
259
- ' AND br.status = ? AND br.bank_account = ?) '
370
+ ' AND br.status = ? AND br.bank_account = ? AND br.group_head = 1) '
260
371
  'GROUP BY gle.voucher_type, gle.voucher_no '
261
372
  'ORDER BY MIN(gle.posting_date) DESC LIMIT 500',
262
373
  [tx["bank_account"], "Payment Entry", "Journal Entry", "Active", tx["bank_account"]],
@@ -264,6 +375,8 @@ def _voucher_candidates(tx: dict, limit: int) -> list[dict]:
264
375
  expected = _amount(tx)
265
376
  deposit = _is_deposit(tx)
266
377
  out = []
378
+ same_day_pool: list[dict] | None = None
379
+ same_day_pool_loaded = False
267
380
  for row in rows:
268
381
  item = dict(row)
269
382
  debit_ccy = flt(item.get("debit_ccy"), 2)
@@ -271,7 +384,7 @@ def _voucher_candidates(tx: dict, limit: int) -> list[dict]:
271
384
  movement = debit_ccy - credit_ccy
272
385
  if not tx.get("currency") or tx.get("currency") == tx.get("base_currency"):
273
386
  movement = flt(item.get("debit"), 2) - flt(item.get("credit"), 2)
274
- if (movement > 0) != deposit or abs(abs(movement) - expected) > 0.01:
387
+ if (movement > 0) != deposit:
275
388
  continue
276
389
  try:
277
390
  days = abs((date.fromisoformat(tx["posting_date"]) - date.fromisoformat(item["posting_date"])).days)
@@ -279,16 +392,34 @@ def _voucher_candidates(tx: dict, limit: int) -> list[dict]:
279
392
  days = 9999
280
393
  if days > 45:
281
394
  continue
395
+ exact = abs(abs(movement) - expected) <= 0.01
396
+ group = None
397
+ if not exact and item.get("posting_date") == tx.get("posting_date"):
398
+ if not same_day_pool_loaded:
399
+ same_day_pool = _same_day_group_pool(tx, item["posting_date"])
400
+ same_day_pool_loaded = True
401
+ group = _same_day_transaction_group(
402
+ tx, movement, item.get("posting_date"), same_day_pool,
403
+ )
404
+ if not exact and not group:
405
+ continue
282
406
  score = 80 + (20 if days == 0 else max(0, 15 - days))
283
407
  out.append({
284
- "kind": "existing_voucher",
408
+ "kind": "existing_voucher" if exact else "existing_voucher_group",
285
409
  "voucher_type": item["voucher_type"],
286
410
  "voucher_no": item["voucher_no"],
287
411
  "posting_date": item["posting_date"],
288
- "amount": expected,
412
+ "amount": abs(flt(movement, 2)),
289
413
  "currency": tx.get("currency"),
290
414
  "score": score,
291
- "reasons": ["exact_bank_movement", "same_date" if days == 0 else "date_near"],
415
+ "reasons": [
416
+ "exact_bank_movement" if exact else "exact_grouped_bank_movement",
417
+ "same_date" if days == 0 else "date_near",
418
+ ],
419
+ "bank_transactions": [
420
+ _compact_group_transaction(member)
421
+ for member in (group or [tx])
422
+ ],
292
423
  })
293
424
  out.sort(key=lambda item: (-item["score"], item["voucher_no"]))
294
425
  return out[:limit]
@@ -299,19 +430,22 @@ def suggest_matches(bank_transaction: str, *, limit: int = 12) -> dict:
299
430
  active = _active_reconciliation(bank_transaction)
300
431
  return {
301
432
  "transaction": _public_transaction(tx),
302
- "active_reconciliation": active,
433
+ "active_reconciliation": _public_active_reconciliation(active),
303
434
  "existing_vouchers": [] if active else _voucher_candidates(tx, limit),
304
435
  "invoices": [] if active else _invoice_candidates(tx, limit),
305
436
  }
306
437
 
307
438
 
308
439
  def _new_audit(tx: dict, *, mode: str, voucher_type: str, voucher_no: str,
309
- user: str | None) -> str:
440
+ user: str | None, group_id: str | None = None,
441
+ group_head: bool = True) -> str:
310
442
  name = new_name("BRC")
311
443
  get_db().insert("Bank Reconciliation", {
312
444
  "name": name,
313
445
  "bank_transaction": tx["name"],
314
446
  "bank_account": tx["bank_account"],
447
+ "group_id": group_id or name,
448
+ "group_head": 1 if group_head else 0,
315
449
  "mode": mode,
316
450
  "voucher_type": voucher_type,
317
451
  "voucher_no": voucher_no,
@@ -359,12 +493,12 @@ def reverse_generated_reconciliation(reconciliation: str | None, *, voucher_type
359
493
  if reconciliation:
360
494
  row = db.get_value(
361
495
  "Bank Reconciliation", reconciliation,
362
- ["name", "bank_transaction", "voucher_type", "voucher_no", "status", "reversed_by"],
496
+ ["name", "bank_transaction", "group_id", "voucher_type", "voucher_no", "status", "reversed_by"],
363
497
  )
364
- rows = [row] if row else []
498
+ rows = _group_rows(row.get("group_id")) if row else []
365
499
  else:
366
500
  rows = db.sql(
367
- 'SELECT name, bank_transaction, voucher_type, voucher_no, status, reversed_by '
501
+ 'SELECT name, bank_transaction, group_id, voucher_type, voucher_no, status, reversed_by '
368
502
  'FROM "Bank Reconciliation" WHERE voucher_type = ? AND voucher_no = ? '
369
503
  'AND status = ?',
370
504
  [voucher_type, voucher_no, "Active"],
@@ -580,25 +714,38 @@ def reconcile_with_journal(bank_transaction: str, counterparty_account: str, *,
580
714
  }
581
715
 
582
716
 
583
- def _validate_existing_voucher(tx: dict, voucher_type: str, voucher_no: str) -> None:
717
+ def _validate_existing_voucher_group(transactions: list[dict], voucher_type: str,
718
+ voucher_no: str) -> None:
719
+ if not transactions:
720
+ raise ValidationError("At least one bank transaction is required")
584
721
  if voucher_type not in _VOUCHER_TYPES:
585
722
  raise ValidationError("Only submitted Payment Entries or Journal Entries can be matched")
586
723
  document = get_db().get_value(voucher_type, voucher_no, ["docstatus", "company"])
587
724
  if not document or flt(document.get("docstatus")) != 1:
588
725
  raise ValidationError(f"{voucher_type} {voucher_no} is unavailable or not submitted")
589
- if document.get("company") != tx["company"]:
726
+ first = transactions[0]
727
+ if document.get("company") != first["company"]:
590
728
  raise ValidationError("Voucher and bank transaction belong to different companies")
729
+ for tx in transactions[1:]:
730
+ if tx.get("company") != first.get("company"):
731
+ raise ValidationError("All grouped bank transactions must belong to the same company")
732
+ if tx.get("bank_account") != first.get("bank_account"):
733
+ raise ValidationError("All grouped bank transactions must use the same bank account")
734
+ if tx.get("currency") != first.get("currency"):
735
+ raise ValidationError("All grouped bank transactions must use the same currency")
736
+ if _is_deposit(tx) != _is_deposit(first):
737
+ raise ValidationError("Grouped bank transactions must have the same movement direction")
591
738
  db = get_db()
592
739
  used = db.sql(
593
- 'SELECT bank_transaction FROM "Bank Reconciliation" '
740
+ 'SELECT group_id, bank_transaction FROM "Bank Reconciliation" '
594
741
  'WHERE voucher_type = ? AND voucher_no = ? AND bank_account = ? '
595
- 'AND status = ? LIMIT 1',
596
- [voucher_type, voucher_no, tx["bank_account"], "Active"],
742
+ 'AND status = ? AND group_head = 1 LIMIT 1',
743
+ [voucher_type, voucher_no, first["bank_account"], "Active"],
597
744
  )
598
745
  if used:
599
746
  raise ValidationError(
600
747
  f"The {voucher_type} {voucher_no} bank movement on account "
601
- f"{tx['bank_account']} is already reconciled with {used[0]['bank_transaction']}"
748
+ f"{first['bank_account']} is already reconciled by group {used[0]['group_id']}"
602
749
  )
603
750
  rows = db.sql(
604
751
  'SELECT COALESCE(SUM(debit), 0) AS debit, COALESCE(SUM(credit), 0) AS credit, '
@@ -606,36 +753,75 @@ def _validate_existing_voucher(tx: dict, voucher_type: str, voucher_no: str) ->
606
753
  'COALESCE(SUM(credit_in_account_currency), 0) AS credit_ccy '
607
754
  'FROM "GL Entry" WHERE voucher_type = ? AND voucher_no = ? '
608
755
  'AND account = ? AND is_cancelled = 0',
609
- [voucher_type, voucher_no, tx["bank_account"]],
756
+ [voucher_type, voucher_no, first["bank_account"]],
610
757
  )
611
758
  row = rows[0]
612
759
  movement = flt(row["debit_ccy"], 2) - flt(row["credit_ccy"], 2)
613
- if tx.get("currency") == tx.get("base_currency"):
760
+ if first.get("currency") == first.get("base_currency"):
614
761
  movement = flt(row["debit"], 2) - flt(row["credit"], 2)
615
- expected = _amount(tx) if _is_deposit(tx) else -_amount(tx)
762
+ expected = flt(sum(_signed_amount(tx) for tx in transactions), 2)
616
763
  if abs(movement - expected) > 0.01:
617
764
  raise ValidationError(
618
- f"Voucher bank movement ({movement}) does not equal transaction movement ({expected})"
765
+ f"Voucher bank movement ({movement}) does not equal grouped transaction movement ({expected})"
619
766
  )
620
767
 
621
768
 
622
- def reconcile_with_existing_voucher(bank_transaction: str, voucher_type: str, voucher_no: str, *,
623
- user: str | None = None, confirmed: bool = False) -> dict:
769
+ def _begin_existing_voucher_transaction(db, voucher_type: str, voucher_no: str) -> None:
770
+ """Serialize consumers of one voucher while the group is validated."""
771
+ db._in_transaction = True
772
+ if db.dialect == "sqlite":
773
+ db.conn.execute("BEGIN IMMEDIATE")
774
+ else:
775
+ # ``voucher_type`` is checked against the fixed allowlist before this
776
+ # helper is called, so quoting the table name is safe.
777
+ db.conn.execute(
778
+ f'SELECT name FROM "{voucher_type}" WHERE name = ? FOR UPDATE',
779
+ [voucher_no],
780
+ ).fetchone()
781
+
782
+
783
+ def reconcile_with_existing_voucher_group(bank_transactions: list[str], voucher_type: str,
784
+ voucher_no: str, *, user: str | None = None,
785
+ confirmed: bool = False) -> dict:
786
+ """Atomically link an exact group to one submitted voucher bank leg."""
624
787
  if confirmed is not True:
625
788
  raise ValidationError("Explicit confirmation is required before matching an existing voucher")
626
- tx = _transaction(bank_transaction)
627
- _ensure_available(tx)
628
- _validate_existing_voucher(tx, voucher_type, voucher_no)
789
+ if voucher_type not in _VOUCHER_TYPES:
790
+ raise ValidationError("Only submitted Payment Entries or Journal Entries can be matched")
791
+ if not isinstance(bank_transactions, list) or not bank_transactions:
792
+ raise ValidationError("At least one bank transaction is required")
793
+ names = []
794
+ for value in bank_transactions:
795
+ name = str(value or "").strip()
796
+ if not name:
797
+ raise ValidationError("Every grouped bank transaction needs a name")
798
+ if name in names:
799
+ raise ValidationError(f"Bank Transaction {name} occurs more than once in the group")
800
+ names.append(name)
801
+
629
802
  db = get_db()
630
- db._in_transaction = True
631
803
  try:
632
- reconciliation = _new_audit(
633
- tx, mode="Existing Voucher", voucher_type=voucher_type,
634
- voucher_no=voucher_no, user=user,
635
- )
636
- activate_generated_reconciliation(
637
- reconciliation, voucher_type=voucher_type, voucher_no=voucher_no,
638
- )
804
+ _begin_existing_voucher_transaction(db, voucher_type, voucher_no)
805
+ transactions = [_transaction(name) for name in names]
806
+ for tx in transactions:
807
+ _ensure_available(tx)
808
+ _validate_existing_voucher_group(transactions, voucher_type, voucher_no)
809
+
810
+ reconciliations: list[str] = []
811
+ group_id = None
812
+ for index, tx in enumerate(transactions):
813
+ reconciliation = _new_audit(
814
+ tx, mode="Existing Voucher", voucher_type=voucher_type,
815
+ voucher_no=voucher_no, user=user, group_id=group_id,
816
+ group_head=index == 0,
817
+ )
818
+ if group_id is None:
819
+ group_id = reconciliation
820
+ reconciliations.append(reconciliation)
821
+ for reconciliation in reconciliations:
822
+ activate_generated_reconciliation(
823
+ reconciliation, voucher_type=voucher_type, voucher_no=voucher_no,
824
+ )
639
825
  db.commit()
640
826
  except Exception:
641
827
  db.conn.rollback()
@@ -643,14 +829,27 @@ def reconcile_with_existing_voucher(bank_transaction: str, voucher_type: str, vo
643
829
  finally:
644
830
  db._in_transaction = False
645
831
  return {
646
- "bank_transaction": bank_transaction,
647
- "reconciliation": reconciliation,
832
+ "bank_transaction": names[0],
833
+ "bank_transactions": names,
834
+ "reconciliation": reconciliations[0],
835
+ "reconciliations": reconciliations,
836
+ "group_id": group_id,
648
837
  "voucher_type": voucher_type,
649
838
  "voucher_no": voucher_no,
650
839
  "status": "Reconciled",
840
+ "amount": flt(sum(_amount(tx) for tx in transactions), 2),
841
+ "currency": transactions[0].get("currency"),
651
842
  }
652
843
 
653
844
 
845
+ def reconcile_with_existing_voucher(bank_transaction: str, voucher_type: str, voucher_no: str, *,
846
+ user: str | None = None, confirmed: bool = False) -> dict:
847
+ return reconcile_with_existing_voucher_group(
848
+ [bank_transaction], voucher_type, voucher_no,
849
+ user=user, confirmed=confirmed,
850
+ )
851
+
852
+
654
853
  def undo_reconciliation(bank_transaction: str, *, user: str | None = None,
655
854
  confirmed: bool = False) -> dict:
656
855
  if confirmed is not True:
@@ -674,15 +873,18 @@ def undo_reconciliation(bank_transaction: str, *, user: str | None = None,
674
873
  else:
675
874
  db._in_transaction = True
676
875
  try:
677
- db.set_value("Bank Reconciliation", audit["name"], {
678
- "status": "Reversed", "reversed_by": user, "reversed_at": now(),
679
- })
680
- db.set_value("Bank Transaction", bank_transaction, {
681
- "reference_doctype": None, "reference_name": None,
682
- "allocated_amount": 0, "unallocated_amount": _amount(tx),
683
- "status": "Unreconciled", "reconciled_by": None,
684
- "reconciled_at": None, "modified": now(),
685
- })
876
+ group = _group_rows(audit.get("group_id")) or [audit]
877
+ for member in group:
878
+ member_tx = _transaction(member["bank_transaction"])
879
+ db.set_value("Bank Reconciliation", member["name"], {
880
+ "status": "Reversed", "reversed_by": user, "reversed_at": now(),
881
+ })
882
+ db.set_value("Bank Transaction", member_tx["name"], {
883
+ "reference_doctype": None, "reference_name": None,
884
+ "allocated_amount": 0, "unallocated_amount": _amount(member_tx),
885
+ "status": "Unreconciled", "reconciled_by": None,
886
+ "reconciled_at": None, "modified": now(),
887
+ })
686
888
  db.commit()
687
889
  except Exception:
688
890
  db.conn.rollback()
@@ -691,6 +893,10 @@ def undo_reconciliation(bank_transaction: str, *, user: str | None = None,
691
893
  db._in_transaction = False
692
894
  return {
693
895
  "bank_transaction": bank_transaction,
896
+ "bank_transactions": [
897
+ row["bank_transaction"]
898
+ for row in (_group_rows(audit.get("group_id"), status="Reversed") or [audit])
899
+ ],
694
900
  "reversed_reconciliation": audit["name"],
695
901
  "voucher_type": audit["voucher_type"],
696
902
  "voucher_no": audit["voucher_no"],
@@ -1431,6 +1431,8 @@ class Database:
1431
1431
  name TEXT PRIMARY KEY,
1432
1432
  bank_transaction TEXT NOT NULL,
1433
1433
  bank_account TEXT NOT NULL,
1434
+ group_id TEXT,
1435
+ group_head INTEGER DEFAULT 1,
1434
1436
  mode TEXT NOT NULL,
1435
1437
  voucher_type TEXT NOT NULL,
1436
1438
  voucher_no TEXT NOT NULL,
@@ -2448,6 +2450,25 @@ def _m025_bank_reconciliation_multi_account_voucher(db: "Database") -> None:
2448
2450
  ], ["Bank Reconciliation"])
2449
2451
 
2450
2452
 
2453
+ def _m026_bank_reconciliation_groups(db: "Database") -> None:
2454
+ """Allow one exact voucher bank leg to cover a group of transactions.
2455
+
2456
+ A group has one head row so the database can still enforce that a voucher's
2457
+ bank-account movement is consumed at most once, while every imported bank
2458
+ transaction retains its own auditable reconciliation row.
2459
+ """
2460
+ db.ensure_column("Bank Reconciliation", "group_id", "TEXT")
2461
+ db.ensure_column("Bank Reconciliation", "group_head", "INTEGER DEFAULT 1")
2462
+ db._alter_table_lock_safe([
2463
+ 'UPDATE "Bank Reconciliation" SET group_id = name WHERE group_id IS NULL',
2464
+ 'UPDATE "Bank Reconciliation" SET group_head = 1 WHERE group_head IS NULL',
2465
+ 'DROP INDEX IF EXISTS "ux_bank_reconciliation_active_voucher_account"',
2466
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "ux_bank_reconciliation_active_group_head" '
2467
+ 'ON "Bank Reconciliation" (voucher_type, voucher_no, bank_account) '
2468
+ "WHERE status = 'Active' AND group_head = 1",
2469
+ ], ["Bank Reconciliation"])
2470
+
2471
+
2451
2472
  Database.MIGRATIONS = [
2452
2473
  (1, "chat_message_session_id", _m001_chat_message_session_id),
2453
2474
  (2, "chat_session_user_id", _m002_chat_session_user_id),
@@ -2474,6 +2495,7 @@ Database.MIGRATIONS = [
2474
2495
  (23, "camt_bank_statement_import", _m023_camt_bank_statement_import),
2475
2496
  (24, "bank_reconciliation", _m024_bank_reconciliation),
2476
2497
  (25, "bank_reconciliation_multi_account_voucher", _m025_bank_reconciliation_multi_account_voucher),
2498
+ (26, "bank_reconciliation_groups", _m026_bank_reconciliation_groups),
2477
2499
  ]
2478
2500
 
2479
2501
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.8.26"
3
+ version = "0.8.28"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
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
File without changes