Move cash payment compliance to SQLite accounting mirror
This commit is contained in:
@@ -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