Restore proven ledger master extraction and add Tally group hierarchy

This commit is contained in:
A R R R Associates
2026-09-08 20:41:47 +05:30
parent 614cab163f
commit f927b50ef1
6 changed files with 15 additions and 289 deletions
+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.26.5" ERP_LOCAL_AGENT_VERSION = "1.26.6"
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.26.5" __version__ = "1.26.6"
AGENT_NAME = "ERP Local Agent" AGENT_NAME = "ERP Local Agent"
@@ -249,185 +249,6 @@ class AccountingMirrorEngine:
finally: finally:
db.close() db.close()
@staticmethod
def _copy_table_rows(source_path: Path, target_path: Path, table: str) -> int:
if not source_path.is_file() or not target_path.is_file():
return 0
src = sqlite3.connect(source_path, timeout=30)
dst = sqlite3.connect(target_path, timeout=30)
src.row_factory = sqlite3.Row
try:
try:
src_cols = [r[1] for r in src.execute(f"PRAGMA table_info({table})").fetchall()]
dst_cols = [r[1] for r in dst.execute(f"PRAGMA table_info({table})").fetchall()]
common = [c for c in dst_cols if c in src_cols]
if not common:
return 0
rows = src.execute(f"SELECT {','.join(common)} FROM {table}").fetchall()
if not rows:
return 0
dst.execute(f"DELETE FROM {table}")
placeholders = ",".join("?" for _ in common)
dst.executemany(
f"INSERT INTO {table} ({','.join(common)}) VALUES ({placeholders})",
[tuple(row[c] for c in common) for row in rows],
)
dst.commit()
return len(rows)
except sqlite3.Error:
return 0
finally:
dst.close()
src.close()
@staticmethod
def _recover_ledger_master_from_accounting_db(accounting_db: Path, target_path: Path,
company_guid: str, company_name: str) -> int:
if not accounting_db.is_file() or not target_path.is_file():
return 0
src = sqlite3.connect(accounting_db, timeout=30)
src.row_factory = sqlite3.Row
dst = sqlite3.connect(target_path, timeout=30)
try:
try:
rows = src.execute(
"""SELECT master_guid,name,parent,opening_balance,closing_balance,
is_revenue,gst_registration_type
FROM tally_ledgers
WHERE tally_guid=?
ORDER BY name""",
(str(company_guid or "").strip(),),
).fetchall()
if not rows and company_name:
rows = src.execute(
"""SELECT master_guid,name,parent,opening_balance,closing_balance,
is_revenue,gst_registration_type
FROM tally_ledgers
WHERE lower(trim(company_name))=lower(trim(?))
ORDER BY name""",
(str(company_name or "").strip(),),
).fetchall()
if not rows:
return 0
company = dst.execute(
"SELECT company_instance_id,company_guid FROM company_master ORDER BY synced_at DESC LIMIT 1"
).fetchone()
if not company:
return 0
instance = company[0]
guid = company[1] or company_guid
dst.execute("DELETE FROM ledger_master WHERE company_instance_id=?", (instance,))
now = _utc_now()
inserted = 0
for idx, row in enumerate(rows, 1):
master_guid = _text(row["master_guid"]) or f"RECOVERED_LEDGER_{idx:08d}"
dst.execute(
"""INSERT OR REPLACE INTO ledger_master(
company_instance_id,company_guid,ledger_guid,alter_id,ledger_name,
parent_group,opening_balance,closing_balance,is_revenue,party_gstin,
gst_registration_type,synced_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
(
instance, guid, master_guid, 0, _text(row["name"]),
_text(row["parent"]), _number(row["opening_balance"]),
_number(row["closing_balance"]), 1 if str(row["is_revenue"]).strip().lower() in ("1","yes","true") else 0,
"", _text(row["gst_registration_type"]), now,
),
)
inserted += 1
dst.commit()
return inserted
except sqlite3.Error:
return 0
finally:
dst.close()
src.close()
@staticmethod
def _recover_group_master_from_accounting_db(accounting_db: Path, target_path: Path,
company_guid: str, company_name: str) -> int:
if not accounting_db.is_file() or not target_path.is_file():
return 0
src = sqlite3.connect(accounting_db, timeout=30)
src.row_factory = sqlite3.Row
dst = sqlite3.connect(target_path, timeout=30)
try:
try:
rows = src.execute(
"""SELECT master_guid,name,parent,reserved_name,is_revenue,
is_deemed_positive,is_active
FROM tally_groups WHERE tally_guid=? ORDER BY name""",
(str(company_guid or "").strip(),),
).fetchall()
if not rows and company_name:
rows = src.execute(
"""SELECT master_guid,name,parent,reserved_name,is_revenue,
is_deemed_positive,is_active
FROM tally_groups
WHERE lower(trim(company_name))=lower(trim(?))
ORDER BY name""",
(str(company_name or "").strip(),),
).fetchall()
if not rows:
return 0
dst.execute("""CREATE TABLE IF NOT EXISTS group_master(
master_guid TEXT,name TEXT,parent TEXT,reserved_name TEXT,
is_revenue TEXT,is_deemed_positive TEXT,is_active TEXT)""")
dst.execute("DELETE FROM group_master")
for row in rows:
dst.execute(
"INSERT INTO group_master VALUES (?,?,?,?,?,?,?)",
tuple(_text(row[k]) for k in (
"master_guid","name","parent","reserved_name",
"is_revenue","is_deemed_positive","is_active"
)),
)
dst.commit()
return len(rows)
except sqlite3.Error:
return 0
finally:
dst.close()
src.close()
def _recover_missing_masters(self, paths: MirrorPaths, candidate: Path,
expected_company_guid: str, expected_company_name: str) -> dict[str, int]:
recovered = {"ledgers": 0, "groups": 0}
summary = self._summary(candidate)
counts = summary.get("counts") or {}
if int(counts.get("ledger_master") or 0) > 0:
return recovered
# First prefer the currently published mirror/cache because it preserves
# the exact Accounting Mirror schema.
candidates = []
if paths.current_mirror.is_file() and paths.current_mirror.resolve() != candidate.resolve():
candidates.append(paths.current_mirror)
if paths.cache_root.is_dir():
candidates.extend(
p for p in sorted(paths.cache_root.glob("*.act"), key=lambda x: x.stat().st_mtime, reverse=True)
if p.resolve() != candidate.resolve() and p not in candidates
)
for previous in candidates:
previous_summary = self._summary(previous)
previous_counts = previous_summary.get("counts") or {}
if int(previous_counts.get("ledger_master") or 0) > 0:
recovered["ledgers"] = self._copy_table_rows(previous, candidate, "ledger_master")
if int(previous_counts.get("group_master") or 0) > 0:
recovered["groups"] = self._copy_table_rows(previous, candidate, "group_master")
if recovered["ledgers"] > 0:
return recovered
# Final local recovery source: the client Accounting .act snapshot.
recovered["ledgers"] = self._recover_ledger_master_from_accounting_db(
paths.accounting_db, candidate, expected_company_guid, expected_company_name
)
recovered["groups"] = self._recover_group_master_from_accounting_db(
paths.accounting_db, candidate, expected_company_guid, expected_company_name
)
return recovered
def _cache_file(self, paths: MirrorPaths, company_name: str, company_guid: str = "") -> Path: def _cache_file(self, paths: MirrorPaths, company_name: str, company_guid: str = "") -> Path:
digest = hashlib.sha256((_text(company_guid) + "|" + _text(company_name).casefold()).encode()).hexdigest()[:12] digest = hashlib.sha256((_text(company_guid) + "|" + _text(company_name).casefold()).encode()).hexdigest()[:12]
return paths.cache_root / f"{_safe_key(company_name)}_{digest}.act" return paths.cache_root / f"{_safe_key(company_name)}_{digest}.act"
@@ -496,11 +317,7 @@ class AccountingMirrorEngine:
"COMPANY - ROWS READ:": (14, "Company read"), "COMPANY - ROWS READ:": (14, "Company read"),
"LEDGER MASTER - BEFORE QUERY": (15, "Reading ledgers"), "LEDGER MASTER - BEFORE QUERY": (15, "Reading ledgers"),
"LEDGER MASTER - ROWS READ:": (25, "Ledgers read"), "LEDGER MASTER - ROWS READ:": (25, "Ledgers read"),
"LEDGER MASTER FALLBACK - BEFORE QUERY": (18, "Retrying ledgers from built-in Tally table"), "STOCK MASTER - BEFORE QUERY": (26, "Reading stock items"),
"LEDGER MASTER FALLBACK - ROWS READ:": (25, "Ledgers read from built-in Tally table"),
"GROUP MASTER - BEFORE QUERY": (25, "Reading group hierarchy"),
"GROUP MASTER - ROWS READ:": (26, "Group hierarchy read"),
"STOCK MASTER - BEFORE QUERY": (27, "Reading stock items"),
"STOCK MASTER - ROWS READ:": (34, "Stock items read"), "STOCK MASTER - ROWS READ:": (34, "Stock items read"),
"STOCK HSN MASTER - BEFORE QUERY": (35, "Reading HSN details"), "STOCK HSN MASTER - BEFORE QUERY": (35, "Reading HSN details"),
"STOCK HSN MASTER - ROWS READ:": (43, "HSN details read"), "STOCK HSN MASTER - ROWS READ:": (43, "HSN details read"),
@@ -568,32 +385,6 @@ class AccountingMirrorEngine:
self._add_auxiliary_masters(candidate, output_dir) self._add_auxiliary_masters(candidate, output_dir)
summary = self._summary(candidate) summary = self._summary(candidate)
counts = summary.get("counts") or {}
recovery_counts = {"ledgers": 0, "groups": 0}
if int(counts.get("ledger_master") or 0) == 0:
recovery_counts = self._recover_missing_masters(
paths, candidate, expected_company_guid, expected_company_name
)
summary = self._summary(candidate)
counts = summary.get("counts") or {}
if recovery_counts.get("ledgers"):
validation_message = (
f"Recovered {recovery_counts['ledgers']} ledger masters"
+ (f" and {recovery_counts['groups']} groups" if recovery_counts.get("groups") else "")
+ " from the last valid local Accounting master snapshot."
)
report(94, "Recovered master data", validation_message, rows=dict(rows))
ledger_count = int(counts.get("ledger_master") or 0)
voucher_line_count = int(counts.get("voucher_ledger_entry") or 0)
voucher_count = int(counts.get("voucher") or 0)
if ledger_count == 0 and (voucher_line_count > 0 or voucher_count > 0):
raise RuntimeError(
"Accounting Mirror master extraction is incomplete: Tally returned voucher data "
f"({voucher_count} vouchers / {voucher_line_count} voucher lines) but zero ledger masters. "
"The incomplete mirror was NOT published. Repair/refresh the Accounting Mirror TDL and retry."
)
company = summary.get("company") or {} company = summary.get("company") or {}
actual_name = _text(company.get("company_name")) actual_name = _text(company.get("company_name"))
actual_guid = _text(company.get("company_guid")) actual_guid = _text(company.get("company_guid"))
@@ -652,7 +443,6 @@ class AccountingMirrorEngine:
"odbc_company_guid": actual_guid, "odbc_company_guid": actual_guid,
"company_cache_path": str(cache), "company_cache_path": str(cache),
"validation_warnings": validation_warnings, "validation_warnings": validation_warnings,
"master_recovery": recovery_counts,
"extractor_log_tail": log[-1500:], "loader_log_tail": llog[-1500:], "extractor_log_tail": log[-1500:], "loader_log_tail": llog[-1500:],
**self.status(client_id), **self.status(client_id),
} }
@@ -654,23 +654,8 @@ class LocalAccountingStore:
run_id = int(cur.lastrowid) run_id = int(cur.lastrowid)
total = 0 total = 0
synced_at = _utc_now_iso() synced_at = _utc_now_iso()
preserved_empty_master_keys: list[str] = []
for key, table in MASTER_TABLES.items(): for key, table in MASTER_TABLES.items():
rows = masters.get(key) or [] rows = masters.get(key) or []
existing_count = int(
db.execute(
f"SELECT COUNT(*) FROM {table} WHERE tally_guid=?",
(tally_guid,),
).fetchone()[0]
)
# A zero-row Ledger/Group refresh is never authoritative when
# a valid local master snapshot already exists. Preserve it
# instead of destroying data needed by Depreciation/TDS/Cash.
if key in ("ledgers", "groups") and not rows and existing_count > 0:
counts[key] = existing_count
total += existing_count
preserved_empty_master_keys.append(key)
continue
db.execute(f"DELETE FROM {table} WHERE tally_guid=?", (tally_guid,)) db.execute(f"DELETE FROM {table} WHERE tally_guid=?", (tally_guid,))
for row in rows: for row in rows:
payload = json.dumps(row, ensure_ascii=False, separators=(",", ":")) payload = json.dumps(row, ensure_ascii=False, separators=(",", ":"))
@@ -687,11 +672,7 @@ class LocalAccountingStore:
) )
counts[key] = len(rows) counts[key] = len(rows)
total += len(rows) total += len(rows)
details = { details = {"counts": counts, "schema_version": SCHEMA_VERSION}
"counts": counts,
"schema_version": SCHEMA_VERSION,
"preserved_empty_master_keys": preserved_empty_master_keys,
}
db.execute( db.execute(
"UPDATE tally_sync_runs SET status='completed', completed_at_utc=?, rows_processed=?, details_json=? WHERE id=?", "UPDATE tally_sync_runs SET status='completed', completed_at_utc=?, rows_processed=?, details_json=? WHERE id=?",
(_utc_now_iso(), total, json.dumps(details, ensure_ascii=False, separators=(",", ":")), run_id), (_utc_now_iso(), total, json.dumps(details, ensure_ascii=False, separators=(",", ":")), run_id),
@@ -19,6 +19,11 @@
Fetch : Name, Parent, GUID, AlterID, OpeningBalance, ClosingBalance, IsRevenue, PartyGSTIN, GSTRegistrationType Fetch : Name, Parent, GUID, AlterID, OpeningBalance, ClosingBalance, IsRevenue, PartyGSTIN, GSTRegistrationType
Is ODBC Table : Yes Is ODBC Table : Yes
[Collection : ERPAMGroupMaster]
Type : Group
Fetch : Name, Parent, GUID
Is ODBC Table : Yes
[Collection : ERPAMStockMaster] [Collection : ERPAMStockMaster]
Type : StockItem Type : StockItem
Fetch : Name, Parent, GUID, AlterID, BaseUnits, OpeningBalance, OpeningValue, GSTApplicable, GSTRepUoM, GSTTypeofSupply, GSTItemHSNCodeEx Fetch : Name, Parent, GUID, AlterID, BaseUnits, OpeningBalance, OpeningValue, GSTApplicable, GSTRepUoM, GSTTypeofSupply, GSTItemHSNCodeEx
@@ -58,38 +58,6 @@ class TallyAccountingMirrorV301
Console.Out.Flush(); Console.Out.Flush();
} }
static bool TryExtract(OdbcConnection cn, string label, string sql, string path,
out int rows, out double sec)
{
rows = 0;
sec = 0;
try {
Extract(cn, label, sql, path, out rows, out sec);
return true;
}
catch(Exception ex) {
Console.WriteLine(label + " - OPTIONAL QUERY FAILED: " + ex.Message);
Console.Out.Flush();
return false;
}
}
static bool TryExtractFirst(OdbcConnection cn, string label, string[] sqls, string path,
out int rows, out double sec)
{
rows = 0;
sec = 0;
foreach(string sql in sqls) {
int r; double s;
if (TryExtract(cn, label, sql, path, out r, out s) && r > 0) {
rows = r;
sec = s;
return true;
}
}
return false;
}
static int Main(string[] args) static int Main(string[] args)
{ {
string dsn="TallyODBC64_9000", outDir="mirror_output_v3_0_1"; string dsn="TallyODBC64_9000", outDir="mirror_output_v3_0_1";
@@ -120,29 +88,11 @@ class TallyAccountingMirrorV301
"SELECT $GUID,$AlterID,$Name,$Parent,$OpeningBalance,$ClosingBalance,$IsRevenue,$PartyGSTIN,$GSTRegistrationType FROM ERPAMLedgerMaster", "SELECT $GUID,$AlterID,$Name,$Parent,$OpeningBalance,$ClosingBalance,$IsRevenue,$PartyGSTIN,$GSTRegistrationType FROM ERPAMLedgerMaster",
Path.Combine(outDir,"ledger_master.csv"),out l,out sl); Path.Combine(outDir,"ledger_master.csv"),out l,out sl);
// Some TallyPrime/ODBC builds expose vouchers from the custom // Group hierarchy only. Ledger extraction above is intentionally
// collection but unexpectedly return zero Ledger objects. A // the same proven ERPAMLedgerMaster path used before.
// company with voucher-ledger rows cannot legitimately have no int groupRows; double groupSec;
// ledger masters, so retry through Tally's built-in Ledger table. Extract(cn,"GROUP MASTER",
if (l == 0) { "SELECT $GUID,$Name,$Parent FROM ERPAMGroupMaster",
Console.WriteLine("LEDGER MASTER - PRIMARY COLLECTION EMPTY; TRYING BUILT-IN LEDGER TABLE");
TryExtractFirst(cn, "LEDGER MASTER FALLBACK",
new string[] {
"SELECT $GUID,$AlterID,$Name,$Parent,$OpeningBalance,$ClosingBalance,$IsRevenue,$PartyGSTIN,$GSTRegistrationType FROM Ledger",
"SELECT $GUID,$AlterID,$Name,$Parent,$OpeningBalance,$ClosingBalance,$IsRevenue,$PartyGSTIN,$GSTRegistrationType FROM Ledgers"
},
Path.Combine(outDir,"ledger_master.csv"), out l, out sl);
}
// Group hierarchy is required for recursive Fixed Assets and
// Capital/Partner-current-account classification. It is optional
// at extraction time so older builds do not break the whole mirror.
int groupRows = 0; double groupSec = 0;
TryExtractFirst(cn, "GROUP MASTER",
new string[] {
"SELECT $GUID,$Name,$Parent,$ReservedName,$IsRevenue,$IsDeemedPositive,$IsActive FROM Group",
"SELECT $GUID,$Name,$Parent,$ReservedName,$IsRevenue,$IsDeemedPositive,$IsActive FROM Groups"
},
Path.Combine(outDir,"group_master.csv"),out groupRows,out groupSec); Path.Combine(outDir,"group_master.csv"),out groupRows,out groupSec);
Extract(cn,"STOCK MASTER", Extract(cn,"STOCK MASTER",