lambda-erp 0.8.26__tar.gz → 0.8.27__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.26 → lambda_erp-0.8.27}/PKG-INFO +1 -1
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/chat.py +26 -5
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/routers/bank_reconciliation.py +10 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/frontend/src/api/client.ts +11 -1
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/bank_reconciliation.py +253 -47
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/database.py +22 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/pyproject.toml +1 -1
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/.gitignore +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/LICENSE +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/README.md +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/__init__.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/attachments.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/auth.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/bootstrap.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/demo_limits.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/deps.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/errors.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/list_values.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/main.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/oauth.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/pdf.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/providers.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/remarks_md.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/routers/__init__.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/routers/accounting.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/routers/actions.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/routers/admin.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/routers/analytics.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/routers/availability.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/routers/bank_statements.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/routers/chat_api.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/routers/documents.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/routers/masters.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/routers/mcp.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/routers/proposals.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/routers/reports.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/routers/setup.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/services.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/templates/document.html +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/api/templates/proposal.html +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/docs/agents/README.md +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/frontend/LICENSE +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/frontend/README.md +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/model.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.8.26 → lambda_erp-0.8.27}/terraform/README.md +0 -0
|
@@ -899,11 +899,18 @@ TOOLS = [
|
|
|
899
899
|
"type": "function",
|
|
900
900
|
"function": {
|
|
901
901
|
"name": "reconcile_bank_transaction",
|
|
902
|
-
"description": "Reconcile
|
|
902
|
+
"description": "Reconcile imported bank transactions 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. If suggest_bank_reconciliation returns an existing_voucher_group, pass every exact bank transaction it lists in bank_transactions; the complete group is linked atomically. 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.",
|
|
903
903
|
"parameters": {
|
|
904
904
|
"type": "object",
|
|
905
905
|
"properties": {
|
|
906
906
|
"bank_transaction": {"type": "string"},
|
|
907
|
+
"bank_transactions": {
|
|
908
|
+
"type": "array",
|
|
909
|
+
"items": {"type": "string"},
|
|
910
|
+
"minItems": 2,
|
|
911
|
+
"maxItems": 100,
|
|
912
|
+
"description": "For an existing_voucher_group only: all exact Bank Transaction names returned by the suggestion, including bank_transaction.",
|
|
913
|
+
},
|
|
907
914
|
"mode": {"type": "string", "enum": ["invoice_payment", "existing_voucher", "journal"]},
|
|
908
915
|
"allocations": {
|
|
909
916
|
"type": "array",
|
|
@@ -932,7 +939,7 @@ TOOLS = [
|
|
|
932
939
|
"type": "function",
|
|
933
940
|
"function": {
|
|
934
941
|
"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.",
|
|
942
|
+
"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
943
|
"parameters": {
|
|
937
944
|
"type": "object",
|
|
938
945
|
"properties": {
|
|
@@ -2077,6 +2084,7 @@ def _handle_reconcile_bank_transaction(args, user_info: dict | None = None):
|
|
|
2077
2084
|
}
|
|
2078
2085
|
from lambda_erp.accounting.bank_reconciliation import (
|
|
2079
2086
|
reconcile_with_existing_voucher,
|
|
2087
|
+
reconcile_with_existing_voucher_group,
|
|
2080
2088
|
reconcile_with_journal,
|
|
2081
2089
|
reconcile_with_payment,
|
|
2082
2090
|
)
|
|
@@ -2089,6 +2097,14 @@ def _handle_reconcile_bank_transaction(args, user_info: dict | None = None):
|
|
|
2089
2097
|
conversion_rate=args.get("conversion_rate"), user=user, confirmed=True,
|
|
2090
2098
|
)
|
|
2091
2099
|
if mode == "existing_voucher":
|
|
2100
|
+
group = args.get("bank_transactions")
|
|
2101
|
+
if group:
|
|
2102
|
+
if args.get("bank_transaction") not in group:
|
|
2103
|
+
return {"error": "bank_transactions must include bank_transaction"}
|
|
2104
|
+
return reconcile_with_existing_voucher_group(
|
|
2105
|
+
group, args.get("voucher_type"), args.get("voucher_no"),
|
|
2106
|
+
user=user, confirmed=True,
|
|
2107
|
+
)
|
|
2092
2108
|
return reconcile_with_existing_voucher(
|
|
2093
2109
|
args.get("bank_transaction"), args.get("voucher_type"), args.get("voucher_no"),
|
|
2094
2110
|
user=user, confirmed=True,
|
|
@@ -2550,15 +2566,20 @@ masked in ordinary chat responses.
|
|
|
2550
2566
|
### Bank reconciliation
|
|
2551
2567
|
Use `list_bank_reconciliation_queue` and then `suggest_bank_reconciliation`; never guess from a raw
|
|
2552
2568
|
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
|
|
2569
|
+
paths: (1) link an existing submitted Payment/Journal voucher whose bank leg matches exactly—either
|
|
2570
|
+
one transaction or every transaction in an exact `existing_voucher_group`,
|
|
2554
2571
|
(2) create and submit a Payment Entry allocated to one or more open invoices of the same party, or
|
|
2555
2572
|
(3) create and submit a Journal Entry against a user-selected non-bank, non-AR/AP account. A created
|
|
2556
2573
|
voucher changes the GL and may change invoice outstanding amounts. Ask for explicit confirmation in
|
|
2557
2574
|
a later user turn, then call `reconcile_bank_transaction` with `confirmed=true`. Clearly disclose any
|
|
2558
|
-
unallocated on-account remainder.
|
|
2575
|
+
unallocated on-account remainder. Disclose all group members and their total before asking for
|
|
2576
|
+
confirmation; for a group, copy the complete
|
|
2577
|
+
`bank_transactions` list from the suggestion into the reconciliation call and never add or omit IDs.
|
|
2578
|
+
Never match a Bank Transaction directly to an invoice because that
|
|
2559
2579
|
would omit the cash posting. For reversals, first explain whether the voucher was created by the
|
|
2560
2580
|
reconciliation (it will be cancelled and reversed) or merely linked (it will only be unlinked), and
|
|
2561
|
-
|
|
2581
|
+
whether the selected transaction belongs to a group that will be unlinked together, and then require
|
|
2582
|
+
explicit confirmation before `undo_bank_reconciliation`.
|
|
2562
2583
|
|
|
2563
2584
|
### Sales Cycle
|
|
2564
2585
|
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.
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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":
|
|
412
|
+
"amount": abs(flt(movement, 2)),
|
|
289
413
|
"currency": tx.get("currency"),
|
|
290
414
|
"score": score,
|
|
291
|
-
"reasons": [
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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,
|
|
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"{
|
|
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,
|
|
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
|
|
760
|
+
if first.get("currency") == first.get("base_currency"):
|
|
614
761
|
movement = flt(row["debit"], 2) - flt(row["credit"], 2)
|
|
615
|
-
expected =
|
|
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
|
|
623
|
-
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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":
|
|
647
|
-
"
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
"
|
|
684
|
-
|
|
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
|
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|