Use common SQLite master for cash payment workflow

This commit is contained in:
A R R R Associates
2026-09-04 22:23:17 +05:30
parent 7fadbb9f4b
commit 8536dc0457
5 changed files with 278 additions and 71 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path
import zipfile
ERP_LOCAL_AGENT_VERSION = "1.22.21"
ERP_LOCAL_AGENT_VERSION = "1.22.22"
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)
@@ -1,2 +1,2 @@
__version__ = "1.22.21"
__version__ = "1.22.22"
AGENT_NAME = "ERP Local Agent"
@@ -179,6 +179,8 @@ class AgentCommandProcessor:
"cash_payment_open_company_selection_capability": True,
"cash_payment_opening_balance_master_path_capability": True,
"cash_payment_selected_company_resolver_capability": True,
"cash_payment_common_act_master_capability": True,
"cash_payment_sqlite_first_ledger_capability": True,
"opening_balance_balance_sheet_only_capability": True,
"tally_writeback_capability": True,
}
@@ -743,18 +745,19 @@ class AgentCommandProcessor:
def _cash_payment_cash_ledgers(self, payload: dict[str, Any]) -> dict[str, Any]:
"""Return Cash-in-Hand ledgers using the proven Opening Balance master path.
"""Return Cash ledgers from the common client .act SQLite master snapshot.
Cash Payment no longer uses a separate Tally collection or a separate
ledger-master job merely to populate the selector. It deliberately reuses
the same open-company resolution and ``fetch_accounting_masters`` call used
by Opening Balance Review, then filters those already-returned masters.
The legacy async full-ledger cache functions remain untouched for backward
compatibility and for any older callers.
The normal Cash Payment selector does not contact TallyPrime. It reuses the
same common ``tally_groups`` and ``tally_ledgers`` tables populated by the
accounting master/opening-balance workflow. A live Tally master refresh is
performed only when the user explicitly requests Refresh Master Data, or
when no usable local snapshot exists for the selected company.
"""
client_id = int(payload.get("client_id"))
expected_company_name = str(payload.get("company_name") or "").strip()
expected_guid = str(payload.get("tally_guid") or "").strip()
requested_guid = str(payload.get("tally_guid") or "").strip()
ledger_scope = str(payload.get("ledger_scope") or "cash").strip().lower()
force_refresh = bool(payload.get("force_refresh"))
if ledger_scope not in {"cash", "all"}:
ledger_scope = "cash"
@@ -763,29 +766,143 @@ class AgentCommandProcessor:
"job_id": progress_id,
"job_kind": "cash_ledger_discovery",
"status": "running",
"stage": "Reading Tally masters using Opening Balance mechanism",
"stage": "Reading common ledger master from local SQLite",
"percent": 20,
"company_name": expected_company_name,
"ledger_count": 0,
"source": "Local .act SQLite",
})
def _read_local_snapshot(tally_guid: str, company_name: str) -> tuple[str, str, list[dict[str, Any]], list[dict[str, Any]], str]:
with self.store.connect(client_id) as db:
guid = str(tally_guid or "").strip()
name = str(company_name or "").strip()
if guid:
count_row = db.execute(
"SELECT COUNT(*) FROM tally_ledgers WHERE tally_guid=?",
(guid,),
).fetchone()
if not count_row or int(count_row[0] or 0) == 0:
guid = ""
if not guid and name:
guid_row = db.execute(
"""SELECT tally_guid, company_name, MAX(synced_at_utc) AS synced_at
FROM tally_ledgers
WHERE lower(trim(company_name))=lower(trim(?))
GROUP BY tally_guid, company_name
ORDER BY MAX(synced_at_utc) DESC
LIMIT 1""",
(name,),
).fetchone()
if guid_row:
guid = str(guid_row["tally_guid"] or "")
name = str(guid_row["company_name"] or name)
if not guid:
return "", name, [], [], ""
group_rows = [
dict(row) for row in db.execute(
"""SELECT master_guid AS guid, name, parent, reserved_name, synced_at_utc
FROM tally_groups
WHERE tally_guid=?
ORDER BY name COLLATE NOCASE""",
(guid,),
).fetchall()
]
ledger_rows = [
dict(row) for row in db.execute(
"""SELECT master_guid AS guid, name, parent, reserved_name,
opening_balance, closing_balance, synced_at_utc
FROM tally_ledgers
WHERE tally_guid=?
ORDER BY name COLLATE NOCASE""",
(guid,),
).fetchall()
]
sync_row = db.execute(
"""SELECT MAX(synced_at_utc) AS synced_at
FROM tally_ledgers
WHERE tally_guid=?""",
(guid,),
).fetchone()
synced_at = str((sync_row["synced_at"] if sync_row else "") or "")
company_row = db.execute(
"""SELECT company_name
FROM tally_ledgers
WHERE tally_guid=? AND trim(company_name)<>''
ORDER BY synced_at_utc DESC LIMIT 1""",
(guid,),
).fetchone()
if company_row:
name = str(company_row["company_name"] or name)
return guid, name, group_rows, ledger_rows, synced_at
try:
# Resolve by the exact company name selected by the user, exactly like
# Opening Balance Review. Do not use Tally's global CURRENTCOMPANY as
# a veto when multiple companies are open; Tally may report whichever
# company currently has UI focus.
company, resolved_guid, guid_refreshed = self._cash_payment_selected_company(payload)
resolved_guid, resolved_name, group_rows, ledger_rows, synced_at = _read_local_snapshot(
requested_guid, expected_company_name
)
refreshed = False
masters = self.tally.fetch_accounting_masters(company.name)
group_rows = list(masters.get("groups") or [])
ledger_rows = list(masters.get("ledgers") or [])
if force_refresh or not ledger_rows:
_cash_progress_snapshot({
"job_id": progress_id,
"job_kind": "cash_ledger_discovery",
"status": "running",
"stage": "Refreshing common accounting masters from TallyPrime",
"percent": 45,
"company_name": expected_company_name,
"ledger_count": len(ledger_rows),
"source": "TallyPrime → .act SQLite",
})
company, actual_guid, guid_refreshed = self._cash_payment_selected_company(payload)
actual_guid = str(actual_guid or requested_guid or "").strip()
if not actual_guid:
raise ValueError("Tally did not return a GUID for the selected company.")
masters = self.tally.fetch_accounting_masters(company.name)
mapping = self.store.get_active_mapping_by_guid(client_id, actual_guid)
self.store.replace_master_snapshot(
client_id,
mapping={**mapping, "tally_guid": actual_guid, "company_name": company.name},
masters=masters,
requested_by_user_id=(
int(payload.get("requested_by_user_id"))
if payload.get("requested_by_user_id") not in (None, "")
else None
),
)
refreshed = True
resolved_guid, resolved_name, group_rows, ledger_rows, synced_at = _read_local_snapshot(
actual_guid, company.name
)
if not ledger_rows:
raise ValueError("The refreshed Tally master snapshot did not contain any ledgers.")
else:
guid_refreshed = bool(
requested_guid and resolved_guid and requested_guid != resolved_guid
)
_cash_progress_snapshot({
"job_id": progress_id,
"job_kind": "cash_ledger_discovery",
"status": "running",
"stage": "Filtering Cash-in-Hand hierarchy from local SQLite",
"percent": 75,
"company_name": resolved_name or expected_company_name,
"ledger_count": len(ledger_rows),
"source": "Local .act SQLite",
})
# Build the complete Cash-in-Hand descendant-group hierarchy from the
# same Group masters already returned by Tally. This supports both
# ledgers directly under Cash-in-Hand and user-created child groups.
cash_group_keys = {"cash-in-hand", "cash in hand"}
changed = True
while changed:
changed = False
normalized_cash = {
x.casefold().replace("-", " ").strip() for x in cash_group_keys
}
for group in group_rows:
name = str(group.get("name") or "").strip()
parent = str(group.get("parent") or "").strip()
@@ -793,24 +910,34 @@ class AgentCommandProcessor:
continue
parent_key = parent.casefold().replace("-", " ").strip()
name_key = name.casefold().replace("-", " ").strip()
normalized_cash = {x.replace("-", " ") for x in cash_group_keys}
if parent_key in normalized_cash and name_key not in normalized_cash:
cash_group_keys.add(name_key)
changed = True
all_ledgers = self._cash_ledger_rows(ledger_rows)
normalized_groups = {
x.casefold().replace("-", " ").strip() for x in cash_group_keys
}
cash_ledgers: list[dict[str, Any]] = []
normalized_groups = {x.casefold().replace("-", " ").strip() for x in cash_group_keys}
for row in all_ledgers:
parent_key = str(row.get("parent") or "").casefold().replace("-", " ").strip()
reserved = str(row.get("reserved_name") or "").strip().casefold()
name_key = str(row.get("name") or "").strip().casefold()
is_cash = parent_key in normalized_groups or reserved == "cash" or name_key == "cash"
is_cash = (
parent_key in normalized_groups
or reserved == "cash"
or name_key == "cash"
)
row["is_cash_candidate"] = bool(is_cash)
if is_cash:
cash_ledgers.append(row)
all_ledgers.sort(key=lambda x: (not bool(x.get("is_cash_candidate")), str(x.get("name") or "").casefold()))
all_ledgers.sort(
key=lambda x: (
not bool(x.get("is_cash_candidate")),
str(x.get("name") or "").casefold(),
)
)
cash_ledgers.sort(key=lambda x: str(x.get("name") or "").casefold())
returned = all_ledgers if ledger_scope == "all" else cash_ledgers
@@ -818,25 +945,31 @@ class AgentCommandProcessor:
"job_id": progress_id,
"job_kind": "cash_ledger_discovery",
"status": "completed",
"stage": f"{len(returned)} ledger(s) ready from Tally master snapshot",
"stage": f"{len(returned)} ledger(s) ready from local SQLite",
"percent": 100,
"company_name": company.name,
"company_name": resolved_name or expected_company_name,
"ledger_count": len(returned),
"source": "Local .act SQLite",
})
return {
"status": "completed",
"company": company.as_dict(),
"company_name": company.name,
"company": {
"guid": resolved_guid,
"name": resolved_name or expected_company_name,
},
"company_name": resolved_name or expected_company_name,
"company_guid": resolved_guid,
"selected_guid_refreshed": guid_refreshed,
"selected_guid_refreshed": bool(guid_refreshed),
"ledgers": returned,
"cash_candidates": cash_ledgers,
"ledger_count": len(returned),
"total_ledgers_examined": len(all_ledgers),
"cash_ledger_count": len(cash_ledgers),
"cash_only": ledger_scope != "all",
"from_cache": False,
"source": "opening_balance_master_snapshot",
"from_cache": not refreshed,
"master_refreshed": refreshed,
"master_synced_at_utc": synced_at,
"source": "common_act_sqlite_master",
"read_only": True,
"agent": self._agent_info(),
}
@@ -845,10 +978,11 @@ class AgentCommandProcessor:
"job_id": progress_id,
"job_kind": "cash_ledger_discovery",
"status": "failed",
"stage": "Tally master snapshot failed",
"stage": "Cash ledger preparation failed",
"percent": 100,
"company_name": expected_company_name,
"ledger_count": 0,
"source": "Local .act SQLite",
"error": str(exc),
})
raise
@@ -1503,31 +1637,60 @@ class AgentCommandProcessor:
status="running", stage="Selected Tally company resolved by name", current_date=""
)
self._cash_cache_update(client_id, job_id, status="running", stage="Reading cash ledger masters", current_date="")
# One master request only, then a generous cooling gap before vouchers.
masters = None
master_error = None
for attempt in range(1, 3):
try:
masters = self.tally.export_master_collection(company_name, "ledgers") or []
self._cash_cache_update(client_id, job_id, tally_requests=(self._cash_cache_job_row(client_id, job_id) or {}).get("tally_requests", 0) + 1)
break
except Exception as exc:
master_error = exc
row = self._cash_cache_job_row(client_id, job_id) or {}
self._cash_cache_update(client_id, job_id, retry_count=int(row.get("retry_count") or 0) + 1, stage="Cooling after slow Tally response")
time.sleep(15.0)
if masters is None:
raise ValueError(f"TallyPrime did not return ledger masters after a slow retry. Details: {master_error}")
master_by_key = {str(x.get("name") or "").strip().casefold(): str(x.get("name") or "").strip() for x in masters if str(x.get("name") or "").strip()}
requested_ledgers = [str(x or "").strip() for x in (payload.get("cash_ledger_names") or []) if str(x or "").strip()]
# Ledger identity is already known from the common .act SQLite master
# snapshot used by Opening Balance/Depreciation. Do not ask TallyPrime
# for the ledger master again before voucher extraction.
self._cash_cache_update(
client_id,
job_id,
status="running",
stage="Confirming selected Cash ledger from local SQLite",
current_date="",
)
requested_ledgers = [
str(x or "").strip()
for x in (payload.get("cash_ledger_names") or [])
if str(x or "").strip()
]
if not requested_ledgers:
raise ValueError("Select at least one Cash-in-Hand ledger before starting extraction.")
with self.store.connect(client_id) as db:
local_rows = db.execute(
"""SELECT name
FROM tally_ledgers
WHERE tally_guid=?""",
(str(payload.get("tally_guid") or "").strip(),),
).fetchall()
if not local_rows and company_name:
local_rows = db.execute(
"""SELECT name
FROM tally_ledgers
WHERE lower(trim(company_name))=lower(trim(?))""",
(company_name,),
).fetchall()
master_by_key = {
str(row["name"] or "").strip().casefold(): str(row["name"] or "").strip()
for row in local_rows
if str(row["name"] or "").strip()
}
missing = [x for x in requested_ledgers if x.casefold() not in master_by_key]
if missing:
raise ValueError("The confirmed Cash ledger is no longer available in TallyPrime: " + ", ".join(missing))
raise ValueError(
"The selected Cash ledger is not present in the local accounting master snapshot: "
+ ", ".join(missing)
+ ". Click Refresh Master Data once and retry."
)
cash_ledgers = {x.casefold() for x in requested_ledgers}
confirmed_names = [master_by_key[x.casefold()] for x in requested_ledgers]
self._cash_cache_update(client_id, job_id, cash_ledgers_json=json.dumps(confirmed_names), stage="Cash ledger confirmed; cooling before voucher extraction")
time.sleep(max(5.0, pause_seconds))
self._cash_cache_update(
client_id,
job_id,
cash_ledgers_json=json.dumps(confirmed_names),
stage="Cash ledger confirmed from SQLite; preparing voucher extraction",
)
with self.store.connect(client_id) as db:
pending = db.execute(