Fix accounting mirror extraction for selected financial year

This commit is contained in:
A R R R Associates
2026-09-09 21:49:44 +05:30
parent 1a7563c9da
commit 2872b67671
4 changed files with 269 additions and 5 deletions
+2 -2
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path
import zipfile
ERP_LOCAL_AGENT_VERSION = "1.26.12"
ERP_LOCAL_AGENT_VERSION = "1.26.13"
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)
@@ -65,7 +65,7 @@ def _build_zip(*, env_text: str | None, include_env: bool, include_admin_readme:
text = (
f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n"
"Existing storage, WebSocket tunnel, dashboard, Tally mapping and client .act functionality are preserved.\n"
"Existing master/transaction sync and accounting tools are preserved. Existing controlled write-back now persists approved instructions in the client .act and transfers them through the shared .NET batch writer with Tally read-back verification.\nAccounting Mirror uses the proven v3.0.1 ODBC runtime for ledgers, vouchers, stock movement, HSN and GST rates.\nTally TDL is exposed through a stable local path and localhost URL so later agent updates can repair/replace the TDL without changing the Tally link.\nFull Accounting Export runs asynchronously from the ERP, publishes live progress in both ERP and the Local Agent dashboard, and refreshes the client SQLite accounting datasets after the ODBC mirror is built.\nCash Payment Compliance reads only the local SQLite Accounting Mirror after mirroring; it no longer starts a Tally extraction job or requires TallyPrime during review.\n"
"Existing master/transaction sync and accounting tools are preserved. Existing controlled write-back now persists approved instructions in the client .act and transfers them through the shared .NET batch writer with Tally read-back verification.\nAccounting Mirror uses the proven v3.0.1 ODBC runtime for ledgers, vouchers, stock movement, HSN and GST rates.\nTally TDL is exposed through a stable local path and localhost URL so later agent updates can repair/replace the TDL without changing the Tally link.\nFull Accounting Export runs asynchronously from the ERP, publishes live progress in both ERP and the Local Agent dashboard, and refreshes the client SQLite accounting datasets after the ODBC mirror is built.\nCash Payment Compliance reads only the local SQLite Accounting Mirror after mirroring; it no longer starts a Tally extraction job or requires TallyPrime during review.\nFull Accounting Export now enforces the ERP-selected financial-year transaction period. If Tally ODBC is restricted to a different active Tally period, the existing read-only exact-date Tally exporter corrects only the mirror transaction tables while preserving ODBC masters.\n"
"Dashboard: http://127.0.0.1:8788\nDesktop dashboard is single-instance: repeated shortcut clicks focus the existing window without starting another Local Agent.\nUpdate dashboard shows check, download, install, restart and completion progress.\n"
)
_write_zip_bytes(dst, "README_ERP_LOCAL_AGENT.txt", text.encode("utf-8"))
@@ -1,2 +1 @@
__version__ = "1.26.12"
AGENT_NAME = "ERP Local Agent"
__version__ = "1.26.13"
@@ -574,6 +574,213 @@ class AccountingMirrorEngine:
report(98, "Mirror ready", "Accounting Mirror database is ready for ERP synchronization.", rows=dict(rows), mirror=result.get("mirror") or {})
return result
def replace_transaction_period_from_vouchers(
self,
*,
client_id: int,
company_name: str,
company_guid: str,
date_from: str,
date_to: str,
vouchers: list[dict[str, Any]],
) -> dict[str, Any]:
"""Replace only the mirror transaction tables with an explicitly dated export.
Tally ODBC Type:Voucher collections follow Tally's active period. The ERP
full-export screen, however, carries an explicit financial-year period. If
those periods differ, the existing read-only XML voucher exporter is used
as a correctness fallback and this method writes those exact-period rows
into the already-built ODBC mirror. Master tables remain untouched.
"""
paths = self.paths(client_id)
db_path = paths.current_mirror
if not db_path.is_file():
raise RuntimeError("Accounting Mirror database is not available for transaction-period correction.")
start = datetime.fromisoformat(str(date_from)).date()
end = datetime.fromisoformat(str(date_to)).date()
if start > end:
raise ValueError("From Date cannot be after To Date.")
db = sqlite3.connect(db_path, timeout=60)
db.row_factory = sqlite3.Row
now = _utc_now()
try:
company = db.execute(
"SELECT * FROM company_master ORDER BY synced_at DESC LIMIT 1"
).fetchone()
if not company:
raise RuntimeError("Accounting Mirror company master is missing.")
instance = _text(company["company_instance_id"])
stored_guid = _text(company["company_guid"])
effective_guid = _text(company_guid) or stored_guid
effective_name = _text(company_name) or _text(company["company_name"])
stock_rows = db.execute(
"SELECT stock_item_guid,product_name,base_uom,current_hsn,current_gst_rate "
"FROM stock_item_master"
).fetchall()
stock_by_name = {
_text(r["product_name"]).casefold(): dict(r) for r in stock_rows if _text(r["product_name"])
}
db.execute("BEGIN IMMEDIATE")
db.execute("DELETE FROM inventory_movement")
db.execute("DELETE FROM voucher_ledger_entry")
db.execute("DELETE FROM voucher")
voucher_count = 0
ledger_count = 0
inventory_count = 0
seen_dates: list[str] = []
max_alter = 0
for seq, voucher in enumerate(vouchers or [], start=1):
vdate = _text(voucher.get("date") or voucher.get("effective_date"))
try:
parsed = datetime.fromisoformat(vdate).date()
except Exception:
continue
if parsed < start or parsed > end:
continue
vtype = _text(voucher.get("voucher_type_name"))
vnum = _text(voucher.get("voucher_number"))
party = _text(voucher.get("party_ledger_name"))
ref = _text(voucher.get("reference"))
narration = _text(voucher.get("narration"))
master_id = int(_number(voucher.get("master_id")) or 0)
alter_id = int(_number(voucher.get("alter_id")) or 0)
max_alter = max(max_alter, alter_id)
guid = _text(voucher.get("guid"))
if not guid:
identity = "|".join([
effective_guid, vdate, vtype, vnum, str(master_id), str(alter_id), ref, party, str(seq)
])
guid = "ERPXML-" + hashlib.sha256(identity.encode("utf-8")).hexdigest()[:32]
entries = list(voucher.get("ledger_entries") or [])
party_amount = 0.0
largest = 0.0
for entry in entries:
amt = float(entry.get("amount") or 0.0)
largest = max(largest, abs(amt))
if party and _text(entry.get("ledger_name")).casefold() == party.casefold():
party_amount = abs(amt)
voucher_amount = party_amount or largest
db.execute(
"""INSERT OR REPLACE INTO voucher(
company_instance_id,company_guid,voucher_guid,alter_id,master_id,voucher_date,
voucher_type,voucher_number,party_ledger,narration,reference,voucher_amount,
is_cancelled,is_optional,synced_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
instance, effective_guid, guid, alter_id, master_id, vdate, vtype, vnum, party,
narration, ref, voucher_amount,
1 if _text(voucher.get("is_cancelled")).casefold() in {"yes","true","1"} else 0,
1 if _text(voucher.get("is_optional")).casefold() in {"yes","true","1"} else 0,
now,
),
)
voucher_count += 1
seen_dates.append(vdate)
for line_no, entry in enumerate(entries, start=1):
raw = float(entry.get("amount") or 0.0)
deemed = _text(entry.get("is_deemed_positive"))
if raw < 0:
drcr = "DR"
elif raw > 0:
drcr = "CR"
else:
drcr = "DR" if deemed.casefold() in {"yes","true","1"} else "CR"
db.execute(
"""INSERT INTO voucher_ledger_entry(
company_instance_id,company_guid,voucher_guid,line_no,alter_id,master_id,
voucher_date,voucher_type,voucher_number,party_ledger,ledger_name,dr_cr,
amount,raw_amount,raw_is_deemed_positive
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
instance, effective_guid, guid, line_no, alter_id, master_id, vdate, vtype,
vnum, party, _text(entry.get("ledger_name")), drcr, abs(raw), raw, deemed,
),
)
ledger_count += 1
for line_no, entry in enumerate(list(voucher.get("inventory_entries") or []), start=1):
stock_name = _text(entry.get("stock_item_name"))
stock = stock_by_name.get(stock_name.casefold(), {})
raw_value = float(entry.get("amount") or 0.0)
deemed = _text(entry.get("is_deemed_positive"))
qty_text = _text(entry.get("billed_qty"))
actual_text = _text(entry.get("actual_qty"))
rate_text = _text(entry.get("rate"))
qty = abs(_number(qty_text)) if qty_text else None
actual_qty = abs(_number(actual_text)) if actual_text else None
rate = _number(rate_text) if rate_text else None
vt = vtype.casefold()
if "sales" in vt:
direction, sign = "OUTWARD", -1.0
elif "purchase" in vt:
direction, sign = "INWARD", 1.0
else:
sign = -1.0 if deemed.casefold() in {"yes","true","1"} else 1.0
direction = "OUTWARD" if sign < 0 else "INWARD"
db.execute(
"""INSERT INTO inventory_movement(
company_instance_id,company_guid,voucher_guid,line_no,alter_id,master_id,
voucher_date,voucher_type,voucher_number,party_ledger,stock_item_name,
stock_item_guid,hsn,gst_rate,cgst_rate,sgst_rate,igst_rate,cess_rate,
taxability,billed_qty_text,billed_qty,actual_qty_text,actual_qty,uom,
rate_text,rate,value,direction,signed_qty,signed_value,raw_is_deemed_positive
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
instance, effective_guid, guid, line_no, alter_id, master_id, vdate, vtype,
vnum, party, stock_name, _text(stock.get("stock_item_guid")),
_text(stock.get("current_hsn")), stock.get("current_gst_rate"),
None, None, None, None, "", qty_text, qty, actual_text, actual_qty,
_text(stock.get("base_uom")), rate_text, rate, abs(raw_value), direction,
(qty * sign) if qty is not None else None, abs(raw_value) * sign, deemed,
),
)
inventory_count += 1
period_from = min(seen_dates) if seen_dates else str(date_from)
period_to = max(seen_dates) if seen_dates else str(date_to)
try:
db.execute(
"""UPDATE sync_state
SET period_from=?,period_to=?,max_voucher_alter_id=?,voucher_count=?,
voucher_ledger_count=?,inventory_count=?,last_sync_at=?,sync_mode=?
WHERE company_instance_id=?""",
(
period_from, period_to, max_alter, voucher_count, ledger_count, inventory_count,
now, "ODBC_MASTERS_XML_EXACT_PERIOD_TRANSACTIONS", instance,
),
)
except sqlite3.Error:
pass
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
# Keep the GUID/name cache aligned with the corrected current mirror.
cache = self._cache_file(paths, effective_name, effective_guid)
shutil.copy2(paths.current_mirror, cache)
return {
"date_from": str(date_from),
"date_to": str(date_to),
"voucher_count": voucher_count,
"voucher_ledger_count": ledger_count,
"inventory_count": inventory_count,
"mirror_db_path": str(paths.current_mirror),
"mirror": self._summary(paths.current_mirror),
}
def company_db(self, client_id: int, company_name: str, company_guid: str = "") -> Path:
paths = self.paths(client_id)
requested_name = _text(company_name)
@@ -4076,7 +4076,64 @@ class AgentCommandProcessor:
)
mirror_company_name = str(mirror_result.get("company_name") or company_name).strip() or company_name
validation_warnings = list(mirror_result.get("validation_warnings") or [])
publish(status="running", percent=98, stage="Updating client accounting database", message="Refreshing the existing client .act transaction snapshot from the completed ODBC mirror. Master analysis remains available directly from the mirror without clearing any existing auxiliary masters.", mirror_db_path=str(mirror_result.get("mirror_db_path") or ""), warnings=validation_warnings)
# Tally ODBC Type:Voucher collections follow the period currently active
# in TallyPrime. The ERP full-export screen carries an explicit period,
# which is authoritative for a financial-year mirror. If ODBC returned
# a different period (or no rows for the requested dates), correct only
# the transaction tables through the existing read-only XML exporter.
# Masters remain the proven ODBC mirror data.
mirror_summary = (mirror_result.get("mirror") or {})
voucher_period = mirror_summary.get("voucher_period") or {}
odbc_from = str(voucher_period.get("from_date") or "").strip()
odbc_to = str(voucher_period.get("to_date") or "").strip()
requested_transactions = self.tally.mirror.transactions(
client_id, mirror_company_name, date_from, date_to, tally_guid
)
requested_vouchers = list(requested_transactions.get("vouchers") or [])
period_mismatch = False
try:
if odbc_from and odbc_to:
od_from = _dt_date.fromisoformat(odbc_from)
od_to = _dt_date.fromisoformat(odbc_to)
req_from = _dt_date.fromisoformat(date_from)
req_to = _dt_date.fromisoformat(date_to)
period_mismatch = od_from < req_from or od_to > req_to
except Exception:
period_mismatch = True
odbc_voucher_count = int((mirror_summary.get("counts") or {}).get("voucher") or 0)
needs_exact_period = period_mismatch or (odbc_voucher_count > 0 and not requested_vouchers)
exact_period_result = None
if needs_exact_period:
publish(
status="running", percent=97, stage="Correcting selected financial year",
message=(
"Tally ODBC returned voucher period "
+ ((odbc_from + " to " + odbc_to) if odbc_from and odbc_to else "outside the requested dates")
+ ". Re-reading transactions for exactly " + date_from + " to " + date_to
+ " using the existing read-only Tally exporter."
),
mirror_db_path=str(mirror_result.get("mirror_db_path") or ""),
warnings=validation_warnings,
)
exact_vouchers = self.tally.live.export_vouchers(mirror_company_name, date_from, date_to)
exact_period_result = self.tally.mirror.replace_transaction_period_from_vouchers(
client_id=client_id,
company_name=mirror_company_name,
company_guid=tally_guid,
date_from=date_from,
date_to=date_to,
vouchers=exact_vouchers,
)
mirror_result = {**mirror_result, **self.tally.mirror.status(client_id)}
mirror_summary = (mirror_result.get("mirror") or {})
validation_warnings.append(
"ODBC transaction period did not match the ERP-selected financial year; "
"transaction tables were corrected using the exact requested dates."
)
publish(status="running", percent=98, stage="Updating client accounting database", message="Refreshing the existing client .act transaction snapshot from the completed Accounting Mirror. Master analysis remains available directly from the mirror without clearing any existing auxiliary masters.", mirror_db_path=str(mirror_result.get("mirror_db_path") or ""), warnings=validation_warnings)
transactions = self.tally.mirror.transactions(client_id, mirror_company_name, date_from, date_to, tally_guid)
transaction_sync = self.store.replace_transaction_snapshot(
client_id, mapping={**mapping, "company_name": mirror_company_name}, transactions=transactions,
@@ -4091,6 +4148,7 @@ class AgentCommandProcessor:
finished_at_utc=datetime.now(timezone.utc).isoformat(), error="", warnings=validation_warnings,
rows=counts, mirror=mirror_summary,
import_diagnostics=mirror_result.get("import_diagnostics") or {},
exact_period_correction=exact_period_result or {},
mirror_db_path=str(mirror_result.get("mirror_db_path") or ""),
accounting_db_path=str(self.store.db_path(client_id)),
transaction_sync=transaction_sync,