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
+11 -7
View File
@@ -252,7 +252,11 @@ def cash_payment_review(
@router.post("/ledgers") @router.post("/ledgers")
async def cash_payment_ledgers(request: Request): async def cash_payment_ledgers(request: Request):
"""Load Cash ledgers using the same name-resolved live Tally master snapshot path as Opening Balance Review.""" """Load Cash ledgers from the common client .act SQLite master snapshot.
Normal loads are local-only. A live Tally master refresh happens only when
the user explicitly clicks Refresh Master Data or when no local snapshot exists.
"""
form = await request.form() form = await request.form()
validate_csrf(request, str(form.get("csrf_token") or "")) validate_csrf(request, str(form.get("csrf_token") or ""))
db = CommonSessionLocal() db = CommonSessionLocal()
@@ -287,18 +291,18 @@ async def cash_payment_ledgers(request: Request):
"tally_guid": tally_guid, "tally_guid": tally_guid,
"company_name": company_name, "company_name": company_name,
} }
# Use the same proven live Tally master-snapshot mechanism used by # Reuse the common accounting master tables already stored in the
# Opening Balance Review. The Local Agent resolves the selected open # client .act SQLite file. This avoids repeatedly querying TallyPrime
# company, reads the standard Tally masters and returns either only # just to populate a ledger selector. The Local Agent performs a live
# Cash-in-Hand ledgers or all ledgers for explicit manual selection. # master refresh only when force_refresh is explicitly requested, or
# No separate cash-ledger cache/job is required for this selector. # when no usable local snapshot exists yet.
payload["ledger_scope"] = ledger_scope payload["ledger_scope"] = ledger_scope
payload["force_refresh"] = force_refresh payload["force_refresh"] = force_refresh
result = request_agent_command( result = request_agent_command(
node.node_code, node.node_code,
"accounting_cash_payment_cash_ledgers", "accounting_cash_payment_cash_ledgers",
payload, payload,
timeout_seconds=180, timeout_seconds=180 if force_refresh else 45,
) )
except Exception as exc: except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=502) return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
@@ -67,17 +67,23 @@
{% if selected_cash_ledger_name %}<option value="{{ selected_cash_ledger_name }}" selected>{{ selected_cash_ledger_name }}</option>{% endif %} {% if selected_cash_ledger_name %}<option value="{{ selected_cash_ledger_name }}" selected>{{ selected_cash_ledger_name }}</option>{% endif %}
</select> </select>
<button type="button" id="cash-load-ledgers" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700">Load Cash Ledgers</button> <button type="button" id="cash-load-ledgers" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700">Load Cash Ledgers</button>
<button type="button" id="cash-refresh-ledgers" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700">Refresh</button> <button type="button" id="cash-refresh-ledgers" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700">Refresh Master Data</button>
</div> </div>
<p id="cash-ledger-help" class="mt-1 text-xs text-slate-500">Uses the same live Tally master-reading mechanism as Opening Balance Review. By default only ledgers under Cash-in-Hand are shown; choose “All Tally ledgers” only for manual selection when required.</p> <p id="cash-ledger-help" class="mt-1 text-xs text-slate-500">Reads the common ledger master already stored in the client .act SQLite database. TallyPrime is contacted only when you click “Refresh Master Data” or when no local master snapshot exists yet.</p>
<div id="cash-ledger-progress" class="mt-2 hidden rounded-xl border border-sky-200 bg-sky-50 p-3"> <div id="cash-ledger-progress" class="mt-2 hidden rounded-xl border border-sky-200 bg-sky-50 p-3">
<div class="flex items-center justify-between text-xs font-semibold text-sky-900"><span id="cash-ledger-progress-stage">Preparing Cash ledger list…</span><span id="cash-ledger-progress-pct">0%</span></div> <div class="flex items-center justify-between text-xs font-semibold text-sky-900"><span id="cash-ledger-progress-stage">Preparing Cash ledger list…</span><span id="cash-ledger-progress-pct">0%</span></div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-sky-100"><div id="cash-ledger-progress-bar" class="h-full rounded-full bg-sky-600 transition-all duration-500" style="width:0%"></div></div> <div class="mt-2 h-2 overflow-hidden rounded-full bg-sky-100"><div id="cash-ledger-progress-bar" class="h-full rounded-full bg-sky-600 transition-all duration-500" style="width:0%"></div></div>
</div> </div>
<div class="mt-3 grid grid-cols-3 gap-2 text-[11px]">
<div id="cash-master-step-1" class="rounded-lg border border-slate-200 bg-slate-50 px-2 py-2 text-center"><div class="font-semibold">1. Local Master</div><div class="mt-1 text-slate-500">.act SQLite</div></div>
<div id="cash-master-step-2" class="rounded-lg border border-slate-200 bg-slate-50 px-2 py-2 text-center"><div class="font-semibold">2. Cash Filter</div><div class="mt-1 text-slate-500">Cash-in-Hand</div></div>
<div id="cash-master-step-3" class="rounded-lg border border-slate-200 bg-slate-50 px-2 py-2 text-center"><div class="font-semibold">3. Ledger Ready</div><div class="mt-1 text-slate-500">No Tally query</div></div>
</div>
<div id="cash-master-meta" class="mt-2 text-[11px] text-slate-500">Master source: local .act SQLite. Use Refresh Master Data only when Tally masters have changed.</div>
</div> </div>
<div><label class="text-xs font-semibold uppercase text-slate-500">From</label><input type="date" name="date_from" value="{{ date_from }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div> <div><label class="text-xs font-semibold uppercase text-slate-500">From</label><input type="date" name="date_from" value="{{ date_from }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div>
<div><label class="text-xs font-semibold uppercase text-slate-500">To</label><input type="date" name="date_to" value="{{ date_to }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div> <div><label class="text-xs font-semibold uppercase text-slate-500">To</label><input type="date" name="date_to" value="{{ date_to }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div>
<div class="md:col-span-2 xl:col-span-5"><button type="button" id="cash-review-button" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white" {% if not selected_client %}disabled{% endif %}>Review Cash Transactions</button><p class="mt-2 text-xs text-slate-500">Select the Tally company, choose a Cash-in-Hand ledger, and review its cash transactions for the selected period. The existing background extraction, threshold analysis and dual VPS/Local Agent progress are preserved.</p></div> <div class="md:col-span-2 xl:col-span-5"><button type="button" id="cash-review-button" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white" {% if not selected_client %}disabled{% endif %}>Review Cash Transactions</button><p class="mt-2 text-xs text-slate-500">Select the Tally company and Cash ledger from the local .act master snapshot. Only the selected ledger’s relevant transactions are then extracted from Tally into SQLite; compliance analysis runs from SQLite. Existing threshold rules, allocation workflow and dual VPS/Local Agent progress are preserved.</p></div>
</form> </form>
</div> </div>
@@ -191,7 +197,7 @@
<div id="erp-cash-step-4" class="rounded-lg bg-slate-100 px-3 py-2">4. Analyse</div> <div id="erp-cash-step-4" class="rounded-lg bg-slate-100 px-3 py-2">4. Analyse</div>
<div id="erp-cash-step-5" class="rounded-lg bg-slate-100 px-3 py-2">5. Complete</div> <div id="erp-cash-step-5" class="rounded-lg bg-slate-100 px-3 py-2">5. Complete</div>
</div> </div>
<p class="mt-4 text-xs text-slate-500">The confirmed Cash ledger is used throughout this job. The Local Agent reads Tally in safe one-day batches and stores only vouchers containing that ledger in the cash-review cache. You can close this ERP page; the background job continues and remains visible on the Local Agent dashboard.</p> <p class="mt-4 text-xs text-slate-500">The confirmed Cash ledger comes from the common local .act master snapshot. The Local Agent then reads Tally in safe one-day batches and stores only vouchers containing that ledger in SQLite. Analysis runs from SQLite. You can close this ERP page; the background job continues and remains visible on the Local Agent dashboard.</p>
<div id="cash-progress-error" class="mt-4 hidden rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900"></div> <div id="cash-progress-error" class="mt-4 hidden rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900"></div>
<div class="mt-4 flex justify-end gap-2"> <div class="mt-4 flex justify-end gap-2">
<button type="button" id="cash-progress-cancel" class="hidden rounded-xl border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-semibold text-amber-900">Cancel Extraction</button> <button type="button" id="cash-progress-cancel" class="hidden rounded-xl border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-semibold text-amber-900">Cancel Extraction</button>
@@ -232,8 +238,21 @@
const ledgerProgressBar = document.getElementById('cash-ledger-progress-bar'); const ledgerProgressBar = document.getElementById('cash-ledger-progress-bar');
const ledgerProgressPct = document.getElementById('cash-ledger-progress-pct'); const ledgerProgressPct = document.getElementById('cash-ledger-progress-pct');
const ledgerProgressStage = document.getElementById('cash-ledger-progress-stage'); const ledgerProgressStage = document.getElementById('cash-ledger-progress-stage');
const masterStep1 = document.getElementById('cash-master-step-1');
const masterStep2 = document.getElementById('cash-master-step-2');
const masterStep3 = document.getElementById('cash-master-step-3');
const masterMeta = document.getElementById('cash-master-meta');
let ledgerPollTimer = null; let ledgerPollTimer = null;
function paintMasterSteps(step) {
[masterStep1, masterStep2, masterStep3].forEach(function(el, idx) {
if (!el) return;
el.className = 'rounded-lg border px-2 py-2 text-center ' + (idx < step
? 'border-emerald-200 bg-emerald-50 text-emerald-900'
: 'border-slate-200 bg-slate-50 text-slate-700');
});
}
function renderLedgerOptions() { function renderLedgerOptions() {
const previous = ledgerSelect.value; const previous = ledgerSelect.value;
const rows = (ledgerScope && ledgerScope.value === 'all') ? allLedgerRows : cashLedgerRows; const rows = (ledgerScope && ledgerScope.value === 'all') ? allLedgerRows : cashLedgerRows;
@@ -267,6 +286,12 @@
from_cache: Boolean(data.from_cache) from_cache: Boolean(data.from_cache)
}; };
renderLedgerOptions(); renderLedgerOptions();
paintMasterSteps(3);
if (masterMeta) {
const synced = data.master_synced_at_utc ? new Date(data.master_synced_at_utc).toLocaleString() : 'time not available';
const source = data.master_refreshed ? 'TallyPrime → local .act SQLite (refreshed now)' : 'local .act SQLite';
masterMeta.textContent = 'Master source: ' + source + ' · Last master sync: ' + synced + ' · ' + String(data.total_ledgers_examined || data.ledger_count || 0) + ' ledger(s) in common snapshot.';
}
} }
function updateLedgerProgress(job) { function updateLedgerProgress(job) {
@@ -275,6 +300,9 @@
ledgerProgressBar.style.width=p+'%'; ledgerProgressBar.style.width=p+'%';
ledgerProgressPct.textContent=Math.round(p)+'%'; ledgerProgressPct.textContent=Math.round(p)+'%';
ledgerProgressStage.textContent=job.stage||job.status||'Working…'; ledgerProgressStage.textContent=job.stage||job.status||'Working…';
if (p >= 90) paintMasterSteps(3);
else if (p >= 60) paintMasterSteps(2);
else if (p >= 15) paintMasterSteps(1);
} }
async function pollLedgers(jobId, clientId, dateFrom) { async function pollLedgers(jobId, clientId, dateFrom) {
@@ -310,9 +338,20 @@
fd.set('ledger_scope', selectedScope); fd.set('ledger_scope', selectedScope);
if(forceRefresh) fd.set('force_refresh','1'); if(forceRefresh) fd.set('force_refresh','1');
ledgerButton.disabled = true; if(ledgerRefreshButton) ledgerRefreshButton.disabled=true; ledgerButton.disabled = true; if(ledgerRefreshButton) ledgerRefreshButton.disabled=true;
paintMasterSteps(0);
ledgerProgress.classList.remove('hidden');
ledgerProgressBar.style.width='15%'; ledgerProgressPct.textContent='15%';
if (forceRefresh) {
ledgerProgressStage.textContent='Refreshing common accounting masters from TallyPrime…';
ledgerHelp.textContent='Refreshing the shared .act master snapshot once, then filtering the Cash-in-Hand hierarchy locally.';
if (masterMeta) masterMeta.textContent='Master source: refreshing TallyPrime → local .act SQLite.';
} else {
ledgerProgressStage.textContent='Reading common ledger master from local SQLite…';
ledgerHelp.textContent = selectedScope === 'all' ledgerHelp.textContent = selectedScope === 'all'
? 'Reading all Tally ledgers using the proven master snapshot…' ? 'Reading all ledgers from the common local .act master snapshot…'
: 'Reading Tally masters and selecting Cash-in-Hand ledgers…'; : 'Reading local .act masters and selecting Cash-in-Hand ledgers…';
if (masterMeta) masterMeta.textContent='Master source: local .act SQLite. TallyPrime is not queried for this normal load.';
}
try { try {
const response = await fetch('/tools/accounting/cash-payments/ledgers', {method:'POST', body:fd, headers:{'Accept':'application/json'}}); const response = await fetch('/tools/accounting/cash-payments/ledgers', {method:'POST', body:fd, headers:{'Accept':'application/json'}});
const data = await response.json(); const data = await response.json();
@@ -323,8 +362,9 @@
ledgerProgress.classList.remove('hidden'); ledgerProgress.classList.remove('hidden');
ledgerProgressBar.style.width='100%'; ledgerProgressPct.textContent='100%'; ledgerProgressBar.style.width='100%'; ledgerProgressPct.textContent='100%';
ledgerProgressStage.textContent = data.cash_only ledgerProgressStage.textContent = data.cash_only
? String(data.ledger_count || 0)+' Cash-in-Hand ledger(s) ready' ? String(data.ledger_count || 0)+' Cash-in-Hand ledger(s) ready from SQLite'
: String(data.ledger_count || 0)+' ledger(s) ready'; : String(data.ledger_count || 0)+' ledger(s) ready from SQLite';
paintMasterSteps(3);
window.setTimeout(()=>ledgerProgress.classList.add('hidden'),1200); window.setTimeout(()=>ledgerProgress.classList.add('hidden'),1200);
return; return;
} }
+1 -1
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path from pathlib import Path
import zipfile import zipfile
ERP_LOCAL_AGENT_VERSION = "1.22.21" ERP_LOCAL_AGENT_VERSION = "1.22.22"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent" ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) _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" AGENT_NAME = "ERP Local Agent"
@@ -179,6 +179,8 @@ class AgentCommandProcessor:
"cash_payment_open_company_selection_capability": True, "cash_payment_open_company_selection_capability": True,
"cash_payment_opening_balance_master_path_capability": True, "cash_payment_opening_balance_master_path_capability": True,
"cash_payment_selected_company_resolver_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, "opening_balance_balance_sheet_only_capability": True,
"tally_writeback_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]: 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 The normal Cash Payment selector does not contact TallyPrime. It reuses the
ledger-master job merely to populate the selector. It deliberately reuses same common ``tally_groups`` and ``tally_ledgers`` tables populated by the
the same open-company resolution and ``fetch_accounting_masters`` call used accounting master/opening-balance workflow. A live Tally master refresh is
by Opening Balance Review, then filters those already-returned masters. performed only when the user explicitly requests Refresh Master Data, or
The legacy async full-ledger cache functions remain untouched for backward when no usable local snapshot exists for the selected company.
compatibility and for any older callers.
""" """
client_id = int(payload.get("client_id"))
expected_company_name = str(payload.get("company_name") or "").strip() 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() 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"}: if ledger_scope not in {"cash", "all"}:
ledger_scope = "cash" ledger_scope = "cash"
@@ -763,29 +766,143 @@ class AgentCommandProcessor:
"job_id": progress_id, "job_id": progress_id,
"job_kind": "cash_ledger_discovery", "job_kind": "cash_ledger_discovery",
"status": "running", "status": "running",
"stage": "Reading Tally masters using Opening Balance mechanism", "stage": "Reading common ledger master from local SQLite",
"percent": 20, "percent": 20,
"company_name": expected_company_name, "company_name": expected_company_name,
"ledger_count": 0, "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: try:
# Resolve by the exact company name selected by the user, exactly like resolved_guid, resolved_name, group_rows, ledger_rows, synced_at = _read_local_snapshot(
# Opening Balance Review. Do not use Tally's global CURRENTCOMPANY as requested_guid, expected_company_name
# a veto when multiple companies are open; Tally may report whichever )
# company currently has UI focus. refreshed = False
company, resolved_guid, guid_refreshed = self._cash_payment_selected_company(payload)
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) masters = self.tally.fetch_accounting_masters(company.name)
group_rows = list(masters.get("groups") or []) mapping = self.store.get_active_mapping_by_guid(client_id, actual_guid)
ledger_rows = list(masters.get("ledgers") or []) 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"} cash_group_keys = {"cash-in-hand", "cash in hand"}
changed = True changed = True
while changed: while changed:
changed = False changed = False
normalized_cash = {
x.casefold().replace("-", " ").strip() for x in cash_group_keys
}
for group in group_rows: for group in group_rows:
name = str(group.get("name") or "").strip() name = str(group.get("name") or "").strip()
parent = str(group.get("parent") or "").strip() parent = str(group.get("parent") or "").strip()
@@ -793,24 +910,34 @@ class AgentCommandProcessor:
continue continue
parent_key = parent.casefold().replace("-", " ").strip() parent_key = parent.casefold().replace("-", " ").strip()
name_key = name.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: if parent_key in normalized_cash and name_key not in normalized_cash:
cash_group_keys.add(name_key) cash_group_keys.add(name_key)
changed = True changed = True
all_ledgers = self._cash_ledger_rows(ledger_rows) 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]] = [] cash_ledgers: list[dict[str, Any]] = []
normalized_groups = {x.casefold().replace("-", " ").strip() for x in cash_group_keys}
for row in all_ledgers: for row in all_ledgers:
parent_key = str(row.get("parent") or "").casefold().replace("-", " ").strip() parent_key = str(row.get("parent") or "").casefold().replace("-", " ").strip()
reserved = str(row.get("reserved_name") or "").strip().casefold() reserved = str(row.get("reserved_name") or "").strip().casefold()
name_key = str(row.get("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) row["is_cash_candidate"] = bool(is_cash)
if is_cash: if is_cash:
cash_ledgers.append(row) 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()) cash_ledgers.sort(key=lambda x: str(x.get("name") or "").casefold())
returned = all_ledgers if ledger_scope == "all" else cash_ledgers returned = all_ledgers if ledger_scope == "all" else cash_ledgers
@@ -818,25 +945,31 @@ class AgentCommandProcessor:
"job_id": progress_id, "job_id": progress_id,
"job_kind": "cash_ledger_discovery", "job_kind": "cash_ledger_discovery",
"status": "completed", "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, "percent": 100,
"company_name": company.name, "company_name": resolved_name or expected_company_name,
"ledger_count": len(returned), "ledger_count": len(returned),
"source": "Local .act SQLite",
}) })
return { return {
"status": "completed", "status": "completed",
"company": company.as_dict(), "company": {
"company_name": company.name, "guid": resolved_guid,
"name": resolved_name or expected_company_name,
},
"company_name": resolved_name or expected_company_name,
"company_guid": resolved_guid, "company_guid": resolved_guid,
"selected_guid_refreshed": guid_refreshed, "selected_guid_refreshed": bool(guid_refreshed),
"ledgers": returned, "ledgers": returned,
"cash_candidates": cash_ledgers, "cash_candidates": cash_ledgers,
"ledger_count": len(returned), "ledger_count": len(returned),
"total_ledgers_examined": len(all_ledgers), "total_ledgers_examined": len(all_ledgers),
"cash_ledger_count": len(cash_ledgers), "cash_ledger_count": len(cash_ledgers),
"cash_only": ledger_scope != "all", "cash_only": ledger_scope != "all",
"from_cache": False, "from_cache": not refreshed,
"source": "opening_balance_master_snapshot", "master_refreshed": refreshed,
"master_synced_at_utc": synced_at,
"source": "common_act_sqlite_master",
"read_only": True, "read_only": True,
"agent": self._agent_info(), "agent": self._agent_info(),
} }
@@ -845,10 +978,11 @@ class AgentCommandProcessor:
"job_id": progress_id, "job_id": progress_id,
"job_kind": "cash_ledger_discovery", "job_kind": "cash_ledger_discovery",
"status": "failed", "status": "failed",
"stage": "Tally master snapshot failed", "stage": "Cash ledger preparation failed",
"percent": 100, "percent": 100,
"company_name": expected_company_name, "company_name": expected_company_name,
"ledger_count": 0, "ledger_count": 0,
"source": "Local .act SQLite",
"error": str(exc), "error": str(exc),
}) })
raise raise
@@ -1503,31 +1637,60 @@ class AgentCommandProcessor:
status="running", stage="Selected Tally company resolved by name", current_date="" 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="") # Ledger identity is already known from the common .act SQLite master
# One master request only, then a generous cooling gap before vouchers. # snapshot used by Opening Balance/Depreciation. Do not ask TallyPrime
masters = None # for the ledger master again before voucher extraction.
master_error = None self._cash_cache_update(
for attempt in range(1, 3): client_id,
try: job_id,
masters = self.tally.export_master_collection(company_name, "ledgers") or [] status="running",
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) stage="Confirming selected Cash ledger from local SQLite",
break current_date="",
except Exception as exc: )
master_error = exc requested_ledgers = [
row = self._cash_cache_job_row(client_id, job_id) or {} str(x or "").strip()
self._cash_cache_update(client_id, job_id, retry_count=int(row.get("retry_count") or 0) + 1, stage="Cooling after slow Tally response") for x in (payload.get("cash_ledger_names") or [])
time.sleep(15.0) if str(x or "").strip()
if masters is None: ]
raise ValueError(f"TallyPrime did not return ledger masters after a slow retry. Details: {master_error}") if not requested_ledgers:
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()} raise ValueError("Select at least one Cash-in-Hand ledger before starting extraction.")
requested_ledgers = [str(x or "").strip() for x in (payload.get("cash_ledger_names") or []) if str(x or "").strip()]
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] missing = [x for x in requested_ledgers if x.casefold() not in master_by_key]
if missing: 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} cash_ledgers = {x.casefold() for x in requested_ledgers}
confirmed_names = [master_by_key[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") self._cash_cache_update(
time.sleep(max(5.0, pause_seconds)) 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: with self.store.connect(client_id) as db:
pending = db.execute( pending = db.execute(