410 lines
15 KiB
Python
410 lines
15 KiB
Python
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 {},
|
|
}
|