Use common SQLite master for cash payment workflow
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user