diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 134e31a..0f6bd39 100644 --- a/app/modules/documents/agent_package.py +++ b/app/modules/documents/agent_package.py @@ -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")) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index 882dae5..b961242 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py @@ -1,2 +1 @@ -__version__ = "1.26.12" -AGENT_NAME = "ERP Local Agent" +__version__ = "1.26.13" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_mirror.py b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_mirror.py index d8883af..f4afae4 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_mirror.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_mirror.py @@ -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) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py index 45b3325..86b9357 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py @@ -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,