Move cash payment compliance to SQLite accounting mirror
This commit is contained in:
@@ -4,7 +4,7 @@ import io
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
ERP_LOCAL_AGENT_VERSION = "1.24.4"
|
||||
ERP_LOCAL_AGENT_VERSION = "1.24.6"
|
||||
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
||||
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
||||
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
||||
@@ -65,7 +65,7 @@ def _build_zip(*, env_text: str | None, include_env: bool, include_admin_readme:
|
||||
text = (
|
||||
f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n"
|
||||
"Existing storage, WebSocket tunnel, dashboard, Tally mapping and client .act functionality are preserved.\n"
|
||||
"Existing master/transaction sync, accounting tools and controlled write-back are preserved.\nAccounting Mirror uses the proven v3.0.1 ODBC runtime for ledgers, vouchers, stock movement, HSN and GST rates.\nTally TDL is exposed through a stable local path and localhost URL so later agent updates can repair/replace the TDL without changing the Tally link.\nFull Accounting Export runs asynchronously from the ERP, publishes live progress in both ERP and the Local Agent dashboard, and refreshes the client SQLite accounting datasets after the ODBC mirror is built.\n"
|
||||
"Existing master/transaction sync, accounting tools and controlled write-back are preserved.\nAccounting Mirror uses the proven v3.0.1 ODBC runtime for ledgers, vouchers, stock movement, HSN and GST rates.\nTally TDL is exposed through a stable local path and localhost URL so later agent updates can repair/replace the TDL without changing the Tally link.\nFull Accounting Export runs asynchronously from the ERP, publishes live progress in both ERP and the Local Agent dashboard, and refreshes the client SQLite accounting datasets after the ODBC mirror is built.\nCash Payment Compliance reads only the local SQLite Accounting Mirror after mirroring; it no longer starts a Tally extraction job or requires TallyPrime during review.\n"
|
||||
"Dashboard: http://127.0.0.1:8788\nDesktop dashboard is single-instance: repeated shortcut clicks focus the existing window without starting another Local Agent.\nUpdate dashboard shows check, download, install, restart and completion progress.\n"
|
||||
)
|
||||
_write_zip_bytes(dst, "README_ERP_LOCAL_AGENT.txt", text.encode("utf-8"))
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
__version__ = "1.24.4"
|
||||
__version__ = "1.24.6"
|
||||
AGENT_NAME = "ERP Local Agent"
|
||||
|
||||
@@ -144,6 +144,8 @@ class AgentCommandProcessor:
|
||||
result = self._cash_payment_cache_cancel(payload)
|
||||
elif action == "accounting_cash_payment_cache_analyze":
|
||||
result = self._cash_payment_cache_analyze(payload)
|
||||
elif action == "accounting_cash_payment_mirror_analyze":
|
||||
result = self._cash_payment_mirror_analyze(payload)
|
||||
elif action == "accounting_cash_payment_compliance":
|
||||
result = self._cash_payment_compliance(payload)
|
||||
elif action == "accounting_tds_compliance":
|
||||
@@ -208,6 +210,7 @@ class AgentCommandProcessor:
|
||||
"purchase_voucher_write_capability": True,
|
||||
"it_depreciation_capability": True,
|
||||
"cash_payment_compliance_capability": True,
|
||||
"cash_payment_mirror_only_analysis_capability": True,
|
||||
"cash_payment_sqlite_cache_capability": True,
|
||||
"cash_payment_background_sync_capability": True,
|
||||
"cash_payment_ledger_confirmation_capability": True,
|
||||
@@ -2118,6 +2121,308 @@ class AgentCommandProcessor:
|
||||
"agent": self._agent_info(),
|
||||
}
|
||||
|
||||
def _cash_payment_mirror_analyze(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Analyse cash payments entirely from the client's local ODBC Accounting Mirror.
|
||||
|
||||
This path never calls TallyPrime. Tally is required only when the user
|
||||
explicitly runs "Mirror Tally to SQLite" from the Tally Accounting page.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from datetime import date as _date
|
||||
|
||||
client_id = int(payload.get("client_id") or 0)
|
||||
if client_id <= 0:
|
||||
raise ValueError("client_id is required.")
|
||||
if not self.store.exists(client_id):
|
||||
raise ValueError("Accounting local storage is not initialized for this client.")
|
||||
|
||||
date_from = str(payload.get("date_from") or "").strip()
|
||||
date_to = str(payload.get("date_to") or "").strip()
|
||||
try:
|
||||
start_day = _date.fromisoformat(date_from)
|
||||
end_day = _date.fromisoformat(date_to)
|
||||
except Exception as exc:
|
||||
raise ValueError("Select valid From Date and To Date.") from exc
|
||||
if start_day > end_day:
|
||||
raise ValueError("From Date cannot be after To Date.")
|
||||
|
||||
cash_limit = round(float(payload.get("cash_limit") or 10000.0), 2)
|
||||
split_window_days = max(1, int(payload.get("split_window_days") or 3))
|
||||
near_limit_percent = max(1.0, min(float(payload.get("near_limit_percent") or 80.0), 100.0))
|
||||
requested_cash_ledger = str(payload.get("cash_ledger_name") or "").strip()
|
||||
|
||||
mirror_status = self.tally.mirror.status(client_id)
|
||||
mirror = mirror_status.get("mirror") or {}
|
||||
if not mirror.get("ready"):
|
||||
raise ValueError(
|
||||
"Accounting Mirror is not ready for this client. "
|
||||
"Open Tally Accounting and run 'Mirror Tally to SQLite' first."
|
||||
)
|
||||
company = mirror.get("company") or {}
|
||||
company_name = str(company.get("company_name") or "").strip()
|
||||
company_guid = str(company.get("company_guid") or "").strip()
|
||||
if not company_name and not company_guid:
|
||||
raise ValueError("Accounting Mirror does not contain a valid Tally company identity.")
|
||||
|
||||
counts = mirror.get("counts") or {}
|
||||
if int(counts.get("voucher") or 0) <= 0:
|
||||
raise ValueError(
|
||||
"The Accounting Mirror contains no vouchers. "
|
||||
"Run 'Mirror Tally to SQLite' for the required Tally period first."
|
||||
)
|
||||
|
||||
db = self.tally.mirror.open_db(client_id, company_name, company_guid)
|
||||
try:
|
||||
ledger_rows = db.execute(
|
||||
"SELECT ledger_name, parent_group FROM ledger_master ORDER BY ledger_name"
|
||||
).fetchall()
|
||||
cash_ledger_names: list[str] = []
|
||||
for row in ledger_rows:
|
||||
name = str(row["ledger_name"] or "").strip()
|
||||
parent = re.sub(r"[\s_-]+", " ", str(row["parent_group"] or "").strip()).casefold()
|
||||
if not name:
|
||||
continue
|
||||
if parent in {"cash in hand", "cash"} or name.casefold() == "cash":
|
||||
cash_ledger_names.append(name)
|
||||
|
||||
if requested_cash_ledger:
|
||||
matched = next(
|
||||
(x for x in cash_ledger_names if x.casefold() == requested_cash_ledger.casefold()),
|
||||
None,
|
||||
)
|
||||
if not matched:
|
||||
all_names = {str(row["ledger_name"] or "").strip().casefold() for row in ledger_rows}
|
||||
if requested_cash_ledger.casefold() not in all_names:
|
||||
raise ValueError(
|
||||
f"Cash ledger '{requested_cash_ledger}' is not present in the Accounting Mirror."
|
||||
)
|
||||
matched = requested_cash_ledger
|
||||
cash_ledger_names = [matched]
|
||||
|
||||
# Preserve legacy behaviour if a very old Tally company has no
|
||||
# Cash-in-Hand hierarchy metadata but contains the standard Cash ledger.
|
||||
if not cash_ledger_names:
|
||||
cash_ledger_names = [
|
||||
str(row["ledger_name"] or "").strip()
|
||||
for row in ledger_rows
|
||||
if str(row["ledger_name"] or "").strip().casefold() == "cash"
|
||||
]
|
||||
if not cash_ledger_names:
|
||||
raise ValueError(
|
||||
"No Cash / Cash-in-Hand ledger was found in the Accounting Mirror. "
|
||||
"Refresh the Tally mirror and verify the ledger hierarchy."
|
||||
)
|
||||
|
||||
placeholders = ",".join("?" for _ in cash_ledger_names)
|
||||
params = [date_from, date_to] + [x.casefold() for x in cash_ledger_names]
|
||||
|
||||
total_vouchers = int(db.execute(
|
||||
"""SELECT COUNT(*) FROM voucher
|
||||
WHERE voucher_date>=? AND voucher_date<=?
|
||||
AND COALESCE(is_cancelled,0)=0 AND COALESCE(is_optional,0)=0""",
|
||||
(date_from, date_to),
|
||||
).fetchone()[0])
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
v.voucher_guid, v.voucher_date, v.voucher_type, v.voucher_number,
|
||||
v.party_ledger, v.narration, v.reference,
|
||||
e.ledger_name, e.dr_cr, e.raw_amount, e.amount, e.raw_is_deemed_positive
|
||||
FROM voucher v
|
||||
JOIN voucher_ledger_entry e
|
||||
ON e.company_instance_id=v.company_instance_id
|
||||
AND e.voucher_guid=v.voucher_guid
|
||||
WHERE v.voucher_date>=? AND v.voucher_date<=?
|
||||
AND COALESCE(v.is_cancelled,0)=0
|
||||
AND COALESCE(v.is_optional,0)=0
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM voucher_ledger_entry ce
|
||||
WHERE ce.company_instance_id=v.company_instance_id
|
||||
AND ce.voucher_guid=v.voucher_guid
|
||||
AND LOWER(ce.ledger_name) IN ({placeholders})
|
||||
)
|
||||
ORDER BY v.voucher_date, v.voucher_type, v.voucher_number, e.line_no
|
||||
"""
|
||||
rows = db.execute(sql, params).fetchall()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
cash_keys = {x.casefold() for x in cash_ledger_names}
|
||||
grouped: dict[str, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
guid = str(row["voucher_guid"] or "")
|
||||
item = grouped.setdefault(guid, {
|
||||
"date": str(row["voucher_date"] or ""),
|
||||
"voucher_type": str(row["voucher_type"] or ""),
|
||||
"voucher_number": str(row["voucher_number"] or ""),
|
||||
"party": str(row["party_ledger"] or "").strip(),
|
||||
"reference": str(row["reference"] or ""),
|
||||
"narration": str(row["narration"] or ""),
|
||||
"entries": [],
|
||||
})
|
||||
item["entries"].append({
|
||||
"ledger_name": str(row["ledger_name"] or "").strip(),
|
||||
"dr_cr": str(row["dr_cr"] or "").strip().upper(),
|
||||
"raw_amount": float(row["raw_amount"] or 0),
|
||||
"amount": float(row["amount"] or 0),
|
||||
"is_deemed_positive": str(row["raw_is_deemed_positive"] or "").strip(),
|
||||
})
|
||||
|
||||
payments: list[dict[str, Any]] = []
|
||||
for voucher in grouped.values():
|
||||
cash_credit: list[float] = []
|
||||
for entry in voucher["entries"]:
|
||||
if entry["ledger_name"].casefold() not in cash_keys:
|
||||
continue
|
||||
raw_amount = float(entry["raw_amount"] or 0)
|
||||
deemed = str(entry["is_deemed_positive"] or "").strip().lower()
|
||||
# Keep the sign interpretation used by the existing Cash Payment
|
||||
# Compliance engine, while also respecting the mirror's DR/CR field.
|
||||
if entry["dr_cr"] == "CR" or raw_amount > 0 or deemed == "no":
|
||||
cash_credit.append(abs(raw_amount if raw_amount else float(entry["amount"] or 0)))
|
||||
cash_amount = round(sum(cash_credit), 2)
|
||||
if cash_amount <= 0:
|
||||
continue
|
||||
|
||||
non_cash = [e for e in voucher["entries"] if e["ledger_name"].casefold() not in cash_keys]
|
||||
party = str(voucher["party"] or "").strip()
|
||||
if not party or party.casefold() in cash_keys:
|
||||
candidates = sorted(
|
||||
non_cash,
|
||||
key=lambda e: abs(float(e["raw_amount"] or e["amount"] or 0)),
|
||||
reverse=True,
|
||||
)
|
||||
party = candidates[0]["ledger_name"] if candidates else "Unidentified counter-ledger"
|
||||
|
||||
payments.append({
|
||||
"date": voucher["date"],
|
||||
"party": party or "Unidentified counter-ledger",
|
||||
"amount": cash_amount,
|
||||
"voucher_number": voucher["voucher_number"],
|
||||
"voucher_type": voucher["voucher_type"],
|
||||
"reference": voucher["reference"],
|
||||
"narration": voucher["narration"],
|
||||
})
|
||||
|
||||
by_party_date = defaultdict(list)
|
||||
for item in payments:
|
||||
by_party_date[(item["party"].casefold(), item["date"])].append(item)
|
||||
|
||||
exceptions: list[dict[str, Any]] = []
|
||||
seen = set()
|
||||
single_count = 0
|
||||
same_day_count = 0
|
||||
|
||||
for item in payments:
|
||||
if item["amount"] > cash_limit + 0.009:
|
||||
single_count += 1
|
||||
key = (item["party"].casefold(), item["date"], "single", item["voucher_number"])
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
exceptions.append({
|
||||
"date": item["date"],
|
||||
"party": item["party"],
|
||||
"amount": item["amount"],
|
||||
"reason": "Single cash-payment voucher exceeds configured limit",
|
||||
"voucher_numbers": [item["voucher_number"] or "-"],
|
||||
})
|
||||
|
||||
for (party_key, paid_on), party_rows in by_party_date.items():
|
||||
total = round(sum(x["amount"] for x in party_rows), 2)
|
||||
if total > cash_limit + 0.009 and len(party_rows) > 1:
|
||||
same_day_count += 1
|
||||
key = (party_key, paid_on, "aggregate")
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
exceptions.append({
|
||||
"date": paid_on,
|
||||
"party": party_rows[0]["party"],
|
||||
"amount": total,
|
||||
"reason": "Same-day aggregate cash payments to the same party exceed configured limit",
|
||||
"voucher_numbers": [x["voucher_number"] or "-" for x in party_rows],
|
||||
})
|
||||
|
||||
daily_by_party = defaultdict(lambda: defaultdict(float))
|
||||
for item in payments:
|
||||
daily_by_party[item["party"].casefold()][item["date"]] += item["amount"]
|
||||
|
||||
near_floor = cash_limit * near_limit_percent / 100.0
|
||||
split_patterns: list[dict[str, Any]] = []
|
||||
for party_key, day_map in daily_by_party.items():
|
||||
day_rows = sorted(
|
||||
(_date.fromisoformat(day), round(amount, 2))
|
||||
for day, amount in day_map.items()
|
||||
if day
|
||||
)
|
||||
for start_idx in range(len(day_rows)):
|
||||
window = []
|
||||
for idx in range(start_idx, len(day_rows)):
|
||||
d, amount = day_rows[idx]
|
||||
if (d - day_rows[start_idx][0]).days >= split_window_days:
|
||||
break
|
||||
window.append((d, amount))
|
||||
if len(window) < 2 or any(amount > cash_limit + 0.009 for _, amount in window):
|
||||
continue
|
||||
total = round(sum(amount for _, amount in window), 2)
|
||||
if total <= cash_limit + 0.009:
|
||||
continue
|
||||
if sum(1 for _, amount in window if amount >= near_floor) < 2:
|
||||
continue
|
||||
party_name = next(
|
||||
(x["party"] for x in payments if x["party"].casefold() == party_key),
|
||||
party_key,
|
||||
)
|
||||
signature = (party_key, window[0][0].isoformat(), window[-1][0].isoformat())
|
||||
if any(
|
||||
(r["party"].casefold(), r["date_from"], r["date_to"]) == signature
|
||||
for r in split_patterns
|
||||
):
|
||||
continue
|
||||
split_patterns.append({
|
||||
"party": party_name,
|
||||
"date_from": window[0][0].isoformat(),
|
||||
"date_to": window[-1][0].isoformat(),
|
||||
"total_amount": total,
|
||||
"days": [{"date": d.isoformat(), "amount": a} for d, a in window],
|
||||
"review_only": True,
|
||||
})
|
||||
|
||||
exceptions.sort(key=lambda x: (x["date"], x["party"].casefold()))
|
||||
split_patterns.sort(key=lambda x: (x["date_from"], x["party"].casefold()))
|
||||
|
||||
return {
|
||||
"cash_payment_review": {
|
||||
"company_name": company_name,
|
||||
"company_guid": company_guid,
|
||||
"date_from": date_from,
|
||||
"date_to": date_to,
|
||||
"cash_limit": cash_limit,
|
||||
"cash_ledgers": cash_ledger_names,
|
||||
"summary": {
|
||||
"vouchers_reviewed": total_vouchers,
|
||||
"cash_payment_vouchers": len(payments),
|
||||
"single_voucher_exceptions": single_count,
|
||||
"same_day_exceptions": same_day_count,
|
||||
"possible_split_patterns": len(split_patterns),
|
||||
},
|
||||
"exceptions": exceptions,
|
||||
"possible_split_payments": split_patterns,
|
||||
"cash_payments": payments,
|
||||
"sqlite_mirror": True,
|
||||
"sqlite_cache": False,
|
||||
"mirror_db_path": str(mirror_status.get("mirror_db_path") or mirror.get("path") or ""),
|
||||
"mirror_period": mirror.get("voucher_period") or {},
|
||||
"tally_contacted": False,
|
||||
"tally_request_count": 0,
|
||||
"tally_retry_count": 0,
|
||||
"tally_pause_ms": 0,
|
||||
"read_only": True,
|
||||
},
|
||||
"mirror": mirror,
|
||||
"agent": self._agent_info(),
|
||||
}
|
||||
|
||||
def _cash_payment_cache_analyze(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
from collections import defaultdict
|
||||
from datetime import date as _date
|
||||
|
||||
Reference in New Issue
Block a user