from __future__ import annotations from typing import Any from app.modules.accounting.agent_bridge import request_agent_command DEFAULT_QUERY_TIMEOUT_SECONDS = 45 DEFAULT_SYNC_TIMEOUT_SECONDS = 330 class AccountingMirrorError(RuntimeError): pass def _unwrap(response: dict[str, Any]) -> dict[str, Any]: if not isinstance(response, dict): raise AccountingMirrorError("ERP Local Agent returned an invalid response.") if not response.get("ok"): raise AccountingMirrorError(str(response.get("error") or "ERP Local Agent command failed.")) result = response.get("result") if not isinstance(result, dict): raise AccountingMirrorError("ERP Local Agent returned an invalid mirror result.") return result def mirror_status(*, node_code: str, accounting_payload: dict[str, Any]) -> dict[str, Any]: response = request_agent_command( node_code, "accounting_mirror_status", accounting_payload, timeout_seconds=DEFAULT_QUERY_TIMEOUT_SECONDS, ) return _unwrap(response) def mirror_sync( *, node_code: str, accounting_payload: dict[str, Any], company_name: str = "", company_guid: str = "", dsn: str = "TallyODBC64_9000", timeout_seconds: int = DEFAULT_SYNC_TIMEOUT_SECONDS, ) -> dict[str, Any]: payload = dict(accounting_payload or {}) payload.update( { "company_name": str(company_name or "").strip(), "company_guid": str(company_guid or "").strip(), "dsn": str(dsn or "TallyODBC64_9000").strip(), "timeout_seconds": max(60, int(timeout_seconds)), } ) response = request_agent_command( node_code, "accounting_mirror_sync", payload, timeout_seconds=max(60, int(timeout_seconds)), ) return _unwrap(response) def mirror_query( *, node_code: str, accounting_payload: dict[str, Any], query: str, filters: dict[str, Any] | None = None, timeout_seconds: int = DEFAULT_QUERY_TIMEOUT_SECONDS, ) -> dict[str, Any]: payload = dict(accounting_payload or {}) payload.update({"query": str(query or "").strip(), "filters": filters or {}}) response = request_agent_command( node_code, "accounting_mirror_query", payload, timeout_seconds=max(10, int(timeout_seconds)), ) return _unwrap(response) def daybook(*, node_code: str, accounting_payload: dict[str, Any], from_date="", to_date="", limit=500): return mirror_query( node_code=node_code, accounting_payload=accounting_payload, query="daybook", filters={"from_date": from_date, "to_date": to_date, "limit": limit}, ) def trial_balance(*, node_code: str, accounting_payload: dict[str, Any], limit=5000): return mirror_query( node_code=node_code, accounting_payload=accounting_payload, query="trial_balance", filters={"limit": limit}, ) def ledger_transactions( *, node_code: str, accounting_payload: dict[str, Any], ledger_name: str, from_date="", to_date="", limit=5000, ): return mirror_query( node_code=node_code, accounting_payload=accounting_payload, query="ledger_transactions", filters={ "ledger_name": ledger_name, "from_date": from_date, "to_date": to_date, "limit": limit, }, ) def inventory( *, node_code: str, accounting_payload: dict[str, Any], stock_item_name: str = "", from_date="", to_date="", limit=5000, ): return mirror_query( node_code=node_code, accounting_payload=accounting_payload, query="inventory", filters={ "stock_item_name": stock_item_name, "from_date": from_date, "to_date": to_date, "limit": limit, }, ) def stock_items(*, node_code: str, accounting_payload: dict[str, Any], limit=5000): return mirror_query( node_code=node_code, accounting_payload=accounting_payload, query="stock_items", filters={"limit": limit}, ) def hsn_history(*, node_code: str, accounting_payload: dict[str, Any], stock_item_name: str): return mirror_query( node_code=node_code, accounting_payload=accounting_payload, query="hsn_history", filters={"stock_item_name": stock_item_name}, ) def gst_history(*, node_code: str, accounting_payload: dict[str, Any], stock_item_name: str): return mirror_query( node_code=node_code, accounting_payload=accounting_payload, query="gst_history", filters={"stock_item_name": stock_item_name}, ) def cash_transactions( *, node_code: str, accounting_payload: dict[str, Any], ledger_name: str = "Cash", from_date="", to_date="", limit=5000, ): return mirror_query( node_code=node_code, accounting_payload=accounting_payload, query="cash_transactions", filters={ "ledger_name": ledger_name, "from_date": from_date, "to_date": to_date, "limit": limit, }, ) def voucher(*, node_code: str, accounting_payload: dict[str, Any], voucher_guid: str): return mirror_query( node_code=node_code, accounting_payload=accounting_payload, query="voucher", filters={"voucher_guid": voucher_guid}, ) def mirror_exceptions(*, node_code: str, accounting_payload: dict[str, Any], limit=5000): return mirror_query( node_code=node_code, accounting_payload=accounting_payload, query="exceptions", filters={"limit": limit}, ) def refresh_masters(*, node_code: str, accounting_payload: dict[str, Any], tally_guid: str, requested_by_user_id: int | None = None) -> dict[str, Any]: payload = dict(accounting_payload or {}) payload["tally_guid"] = str(tally_guid or "").strip() if requested_by_user_id is not None: payload["requested_by_user_id"] = int(requested_by_user_id) return _unwrap(request_agent_command( node_code, "accounting_sync_masters", payload, timeout_seconds=330 )) def refresh_transactions(*, node_code: str, accounting_payload: dict[str, Any], tally_guid: str, date_from: str, date_to: str, requested_by_user_id: int | None = None) -> dict[str, Any]: payload = dict(accounting_payload or {}) payload.update({"tally_guid": str(tally_guid or "").strip(), "date_from": str(date_from or "").strip(), "date_to": str(date_to or "").strip()}) if requested_by_user_id is not None: payload["requested_by_user_id"] = int(requested_by_user_id) return _unwrap(request_agent_command( node_code, "accounting_sync_transactions", payload, timeout_seconds=330 )) def _creditor_movements( *, node_code: str, accounting_payload: dict[str, Any], from_date: str, to_date: str, limit: int = 50000, ) -> dict[str, Any]: return mirror_query( node_code=node_code, accounting_payload=accounting_payload, query="sundry_creditor_movements", filters={ "from_date": str(from_date or "").strip(), "to_date": str(to_date or "").strip(), "limit": max(1, min(100000, int(limit or 50000))), }, timeout_seconds=90, ) def sundry_creditors_aging( *, node_code: str, accounting_payload: dict[str, Any], fy_start: str, fy_end: str, follow_up_accounting_payload: dict[str, Any] | None = None, follow_up_start: str = "", follow_up_end: str = "", limit: int = 50000, ) -> dict[str, Any]: """Age Sundry Creditors at FY close and optionally trace later settlement. The v3 Accounting Mirror does not yet persist Tally bill-allocation references. Accordingly this routine performs a transparent FIFO allocation over the actual creditor-ledger movements. Debit-side payments, sales and credit adjustments settle the oldest credit balance first. """ from datetime import date as _date, timedelta as _timedelta current = _creditor_movements( node_code=node_code, accounting_payload=accounting_payload, from_date=fy_start, to_date=fy_end, limit=limit, ) later = {"ledgers": [], "rows": []} if follow_up_accounting_payload is not None and follow_up_start and follow_up_end: later = _creditor_movements( node_code=node_code, accounting_payload=follow_up_accounting_payload, from_date=follow_up_start, to_date=follow_up_end, limit=limit, ) fy_start_date = _date.fromisoformat(fy_start) fy_end_date = _date.fromisoformat(fy_end) opening_date = fy_start_date - _timedelta(days=1) ledger_meta = {str(x.get("ledger_name") or "").casefold(): x for x in (current.get("ledgers") or [])} current_rows = current.get("rows") or [] later_rows = later.get("rows") or [] by_ledger: dict[str, list[dict[str, Any]]] = {} for row in current_rows: name = str(row.get("ledger_name") or "").strip() if name: by_ledger.setdefault(name, []).append(row) later_by_ledger: dict[str, list[dict[str, Any]]] = {} for row in later_rows: name = str(row.get("ledger_name") or "").strip() if name: later_by_ledger.setdefault(name, []).append(row) parties: list[dict[str, Any]] = [] details: list[dict[str, Any]] = [] for party_name in sorted({str(x.get("ledger_name") or "").strip() for x in current.get("ledgers") or [] if str(x.get("ledger_name") or "").strip()}, key=str.casefold): meta = ledger_meta.get(party_name.casefold()) or {} opening_raw = float(meta.get("opening_balance") or 0) opening_credit = max(0.0, opening_raw) lots: list[dict[str, Any]] = [] if opening_credit > 0.009: lots.append({ "party_name": party_name, "source": "Opening Balance", "voucher_date": opening_date.isoformat(), "voucher_type": "Opening", "voucher_number": "", "reference": "", "original_credit": opening_credit, "remaining": opening_credit, "outstanding_at_fy_end": 0.0, "paid_subsequently": 0.0, "final_payment_date": "", }) def settle(amount: float, paid_on: str, track_later: bool = False): remaining = max(0.0, float(amount or 0)) for lot in lots: if remaining <= 0.009: break available = float(lot.get("remaining") or 0) if available <= 0.009: continue applied = min(available, remaining) lot["remaining"] = round(available - applied, 2) remaining = round(remaining - applied, 2) if track_later and lot.get("outstanding_at_fy_end", 0) > 0: eligible = max(0.0, float(lot["outstanding_at_fy_end"]) - float(lot.get("paid_subsequently") or 0)) later_applied = min(eligible, applied) if later_applied > 0: lot["paid_subsequently"] = round(float(lot.get("paid_subsequently") or 0) + later_applied, 2) if float(lot["outstanding_at_fy_end"]) - float(lot["paid_subsequently"]) <= 0.009: lot["final_payment_date"] = paid_on for row in sorted(by_ledger.get(party_name, []), key=lambda r: (str(r.get("voucher_date") or ""), int(r.get("line_no") or 0))): amount = abs(float(row.get("amount") or 0)) if amount <= 0.009: continue side = str(row.get("dr_cr") or "").upper() if side == "CR": lots.append({ "party_name": party_name, "source": "Voucher", "voucher_date": str(row.get("voucher_date") or ""), "voucher_type": str(row.get("voucher_type") or ""), "voucher_number": str(row.get("voucher_number") or ""), "reference": str(row.get("reference") or ""), "original_credit": amount, "remaining": amount, "outstanding_at_fy_end": 0.0, "paid_subsequently": 0.0, "final_payment_date": "", }) elif side == "DR": settle(amount, str(row.get("voucher_date") or ""), False) closing_lots = [lot for lot in lots if float(lot.get("remaining") or 0) > 0.009] if not closing_lots: continue for lot in closing_lots: lot["outstanding_at_fy_end"] = round(float(lot["remaining"]), 2) # Trace subsequent debit settlements only. Later credits create new liabilities # and must not alter the settlement status of FY-end lots. for row in sorted(later_by_ledger.get(party_name, []), key=lambda r: (str(r.get("voucher_date") or ""), int(r.get("line_no") or 0))): if str(row.get("dr_cr") or "").upper() == "DR": settle(abs(float(row.get("amount") or 0)), str(row.get("voucher_date") or ""), True) within_180 = over_180 = paid_later = still_unpaid = 0.0 final_dates: list[str] = [] for lot in closing_lots: lot_date = _date.fromisoformat(str(lot["voucher_date"])) age_days = (fy_end_date - lot_date).days bucket = ">180 Days" if age_days > 180 else "≤180 Days" outstanding = round(float(lot["outstanding_at_fy_end"]), 2) paid = round(min(outstanding, float(lot.get("paid_subsequently") or 0)), 2) after = round(max(0.0, outstanding - paid), 2) if bucket == ">180 Days": over_180 += outstanding paid_later += paid still_unpaid += after else: within_180 += outstanding if lot.get("final_payment_date"): final_dates.append(str(lot["final_payment_date"])) details.append({ **{k: lot.get(k) for k in ("party_name","source","voucher_date","voucher_type","voucher_number","reference","original_credit","final_payment_date")}, "outstanding_at_fy_end": outstanding, "age_days": age_days, "age_bucket": bucket, "paid_subsequently": paid, "balance_after_follow_up": after, "allocation_basis": "FIFO from Accounting Mirror movements", }) closing = round(within_180 + over_180, 2) parties.append({ "party_name": party_name, "within_180": round(within_180,2), "over_180": round(over_180,2), "closing_balance": closing, "over_180_paid_later": round(paid_later,2), "over_180_still_unpaid": round(still_unpaid,2), "final_payment_date": max(final_dates) if final_dates else "", }) summary = { "party_count": len(parties), "total_closing": round(sum(x["closing_balance"] for x in parties),2), "within_180": round(sum(x["within_180"] for x in parties),2), "over_180": round(sum(x["over_180"] for x in parties),2), "over_180_paid_later": round(sum(x["over_180_paid_later"] for x in parties),2), "over_180_still_unpaid": round(sum(x["over_180_still_unpaid"] for x in parties),2), } return { "summary": summary, "parties": parties, "details": details, "allocation_basis": "FIFO from Accounting Mirror movements; debit-side payments/sales/credit adjustments settle oldest balances first", "current_mirror": current.get("mirror") or {}, "follow_up_mirror": later.get("mirror") or {}, }