diff --git a/app/core/startup.py b/app/core/startup.py index 695226e..b59aa73 100644 --- a/app/core/startup.py +++ b/app/core/startup.py @@ -337,6 +337,7 @@ ROLE_PERMISSION_MAP = { "accounting.tally.connect", "accounting.tally.map_company", "accounting.tally.sync_masters", + "accounting.tally.sync_transactions", "accounting.act.initialize", "clients.view.own_only", "employees.dashboard.view", @@ -971,3 +972,4 @@ def on_startup(app: FastAPI) -> None: start_notification_scheduler() + diff --git a/app/modules/accounting/act_store.py b/app/modules/accounting/act_store.py index 2f32a18..fb7535a 100644 --- a/app/modules/accounting/act_store.py +++ b/app/modules/accounting/act_store.py @@ -8,7 +8,7 @@ import os import sqlite3 from typing import Iterator, Sequence -ACT_SCHEMA_VERSION = 3 +ACT_SCHEMA_VERSION = 4 class AccountingActStoreError(RuntimeError): @@ -32,7 +32,8 @@ class AccountingActStore: Phase 1 provides accounting storage and read-only Tally discovery. Phase 2 adds durable client/registration -> Tally company mapping keyed by Tally GUID. Phase 3 adds read-only Tally accounting master snapshot tables. - Voucher/transaction sync and write-back remain out of scope. + Phase 4 adds read-only voucher/transaction tables. Tally write-back remains + out of scope. """ def __init__(self, root: str | Path) -> None: @@ -164,6 +165,42 @@ class AccountingActStore: id INTEGER PRIMARY KEY AUTOINCREMENT, tally_guid TEXT NOT NULL, company_name TEXT NOT NULL, master_guid TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, parent TEXT NOT NULL DEFAULT '', category TEXT NOT NULL DEFAULT '', synced_at_utc TEXT NOT NULL, payload_json TEXT NOT NULL DEFAULT '{}' ); + CREATE TABLE IF NOT EXISTS tally_vouchers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, tally_guid TEXT NOT NULL, company_name TEXT NOT NULL, + voucher_guid TEXT NOT NULL DEFAULT '', master_id TEXT NOT NULL DEFAULT '', alter_id TEXT NOT NULL DEFAULT '', voucher_key TEXT NOT NULL DEFAULT '', + voucher_date TEXT NOT NULL DEFAULT '', effective_date TEXT NOT NULL DEFAULT '', voucher_type_name TEXT NOT NULL DEFAULT '', voucher_number TEXT NOT NULL DEFAULT '', + reference TEXT NOT NULL DEFAULT '', narration TEXT NOT NULL DEFAULT '', party_ledger_name TEXT NOT NULL DEFAULT '', is_cancelled TEXT NOT NULL DEFAULT '', + is_optional TEXT NOT NULL DEFAULT '', is_post_dated TEXT NOT NULL DEFAULT '', persisted_view TEXT NOT NULL DEFAULT '', synced_at_utc TEXT NOT NULL, raw_xml TEXT NOT NULL DEFAULT '' + ); + CREATE TABLE IF NOT EXISTS tally_voucher_ledger_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, voucher_id INTEGER NOT NULL, line_no INTEGER NOT NULL, ledger_name TEXT NOT NULL DEFAULT '', amount REAL NOT NULL DEFAULT 0, + is_deemed_positive TEXT NOT NULL DEFAULT '', ledger_from_item TEXT NOT NULL DEFAULT '', remove_zero_entries TEXT NOT NULL DEFAULT '', payload_json TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY(voucher_id) REFERENCES tally_vouchers(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS tally_voucher_inventory_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, voucher_id INTEGER NOT NULL, line_no INTEGER NOT NULL, stock_item_name TEXT NOT NULL DEFAULT '', is_deemed_positive TEXT NOT NULL DEFAULT '', + actual_qty TEXT NOT NULL DEFAULT '', billed_qty TEXT NOT NULL DEFAULT '', rate TEXT NOT NULL DEFAULT '', amount REAL NOT NULL DEFAULT 0, godown_name TEXT NOT NULL DEFAULT '', + batch_name TEXT NOT NULL DEFAULT '', tracking_number TEXT NOT NULL DEFAULT '', order_number TEXT NOT NULL DEFAULT '', payload_json TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY(voucher_id) REFERENCES tally_vouchers(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS tally_bill_allocations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, ledger_entry_id INTEGER NOT NULL, line_no INTEGER NOT NULL, name TEXT NOT NULL DEFAULT '', bill_type TEXT NOT NULL DEFAULT '', + amount REAL NOT NULL DEFAULT 0, bill_credit_period TEXT NOT NULL DEFAULT '', payload_json TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY(ledger_entry_id) REFERENCES tally_voucher_ledger_entries(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS tally_cost_centre_allocations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, ledger_entry_id INTEGER NOT NULL, line_no INTEGER NOT NULL, category TEXT NOT NULL DEFAULT '', cost_centre TEXT NOT NULL DEFAULT '', + amount REAL NOT NULL DEFAULT 0, payload_json TEXT NOT NULL DEFAULT '{}', FOREIGN KEY(ledger_entry_id) REFERENCES tally_voucher_ledger_entries(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS tally_bank_allocations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, ledger_entry_id INTEGER NOT NULL, line_no INTEGER NOT NULL, transaction_type TEXT NOT NULL DEFAULT '', instrument_number TEXT NOT NULL DEFAULT '', + instrument_date TEXT NOT NULL DEFAULT '', bank_name TEXT NOT NULL DEFAULT '', payment_favouring TEXT NOT NULL DEFAULT '', unique_reference_number TEXT NOT NULL DEFAULT '', + amount REAL NOT NULL DEFAULT 0, payload_json TEXT NOT NULL DEFAULT '{}', FOREIGN KEY(ledger_entry_id) REFERENCES tally_voucher_ledger_entries(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS ix_tally_vouchers_company_date ON tally_vouchers(tally_guid, voucher_date); + CREATE INDEX IF NOT EXISTS ix_tally_voucher_ledger_entries_voucher ON tally_voucher_ledger_entries(voucher_id, line_no); + CREATE INDEX IF NOT EXISTS ix_tally_voucher_inventory_entries_voucher ON tally_voucher_inventory_entries(voucher_id, line_no); + CREATE INDEX IF NOT EXISTS ix_tally_groups_company ON tally_groups(tally_guid, name); CREATE INDEX IF NOT EXISTS ix_tally_ledgers_company ON tally_ledgers(tally_guid, name); CREATE INDEX IF NOT EXISTS ix_tally_voucher_types_company ON tally_voucher_types(tally_guid, name); diff --git a/app/modules/accounting/templates/accounting/tally.html b/app/modules/accounting/templates/accounting/tally.html index 69ce67e..b5ed518 100644 --- a/app/modules/accounting/templates/accounting/tally.html +++ b/app/modules/accounting/templates/accounting/tally.html @@ -1,6 +1,10 @@ {% extends "ui/templates/base/layout.html" %} {% block content %} {% if synced %}
Tally accounting masters synchronized successfully.
{% endif %} + + {% if transactions_synced %} +
Tally accounting transactions synchronized successfully.
+ {% endif %}
@@ -192,6 +196,54 @@ {% endif %} + {% if accounting and accounting.exists and tally and tally.connected %} +
+
+
+

Phase 4 · Accounting Transaction Sync

+

Read vouchers and their accounting allocations from the selected company currently open in TallyPrime. The selected company must already be mapped to this ERP client.

+
+ Read-only +
+ +
+ + + + + + +
+ + {% set tx = accounting.latest_transaction_sync if accounting else none %} +
+
Latest Sync
{{ tx.completed_at_utc if tx and tx.completed_at_utc else 'Not synced yet' }}
+
Period
{% if tx and tx.details %}{{ tx.details.date_from or '-' }} → {{ tx.details.date_to or '-' }}{% else %}-{% endif %}
+
Vouchers
{% if tx and tx.details and tx.details.counts %}{{ tx.details.counts.vouchers or 0 }}{% else %}0{% endif %}
+
Rows Stored
{{ tx.rows_processed if tx else 0 }}
+
+ +
+ Stored locally in the client's .act database: vouchers, ledger entries, inventory entries, bill allocations, cost-centre allocations and bank allocations. Raw voucher XML is preserved. Re-syncing a date range replaces only that company's vouchers in the selected range. No Tally data is modified. +
+
+ {% endif %} + {% if accounting and accounting.exists %}
@@ -279,7 +331,7 @@ {% endif %}
- Phase 3 synchronizes accounting masters only. Voucher/transaction sync and Tally write-back remain disabled and are reserved for later phases. + Phase 4 synchronizes accounting transactions read-only. Tally write-back remains disabled and is reserved for Phase 6.
{% endblock %} diff --git a/app/modules/accounting/ui.py b/app/modules/accounting/ui.py index 34e49e6..3d1c532 100644 --- a/app/modules/accounting/ui.py +++ b/app/modules/accounting/ui.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import date, datetime, timezone from urllib.parse import quote from fastapi import APIRouter, Form, Request @@ -115,6 +115,7 @@ def tally_tool( mapped: int = 0, unmapped: int = 0, synced: int = 0, + transactions_synced: int = 0, error: str = "", ): db = CommonSessionLocal() @@ -149,7 +150,7 @@ def tally_tool( try: response_data = request_agent_command( node.node_code, - "phase3_status", + "phase4_status", payload, timeout_seconds=20, ) @@ -160,11 +161,16 @@ def tally_tool( except Exception as exc: command_error = str(exc) + today = date.today() + fy_start_year = today.year if today.month >= 4 else today.year - 1 + transaction_date_from = date(fy_start_year, 4, 1).isoformat() + transaction_date_to = today.isoformat() + return _render( request, db, user, - title="Tally Accounting Masters", + title="Tally Accounting", clients=clients, selected_client=selected_client, registrations=registrations, @@ -175,6 +181,9 @@ def tally_tool( mapped=bool(mapped), unmapped=bool(unmapped), synced=bool(synced), + transactions_synced=bool(transactions_synced), + transaction_date_from=transaction_date_from, + transaction_date_to=transaction_date_to, command_error=command_error, ) finally: @@ -408,3 +417,55 @@ def sync_tally_masters( ) finally: db.close() + + +@router.post("/sync-transactions") +def sync_tally_transactions( + request: Request, + client_id: int = Form(...), + tally_guid: str = Form(...), + date_from: str = Form(...), + date_to: str = Form(...), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_partner(request, db, "accounting.tally.sync_transactions") + if response: + return response + client, _clients, scope = _find_visible_client(db, request, user, client_id) + if not client: + return _denied() + try: + start = date.fromisoformat(str(date_from or "")) + end = date.fromisoformat(str(date_to or "")) + if start > end: + raise ValueError("From Date cannot be after To Date.") + if (end - start).days > 370: + raise ValueError("Transaction sync is limited to 371 days per run. Sync additional periods separately.") + except Exception as exc: + return RedirectResponse(url=f"/tools/tally?client_id={client.id}&error={quote(str(exc))}", status_code=303) + node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) + if not node or not _node_online(node): + return RedirectResponse(url=f"/tools/tally?client_id={client.id}&error={quote('ERP Local Agent is offline for the active branch.')}", status_code=303) + try: + result = request_agent_command( + node.node_code, + "accounting_sync_transactions", + { + "client_id": int(client.id), + "tally_guid": str(tally_guid or "").strip(), + "date_from": start.isoformat(), + "date_to": end.isoformat(), + "requested_by_user_id": int(user.id), + }, + timeout_seconds=300, + ) + if not result.get("ok"): + raise RuntimeError(str(result.get("error") or "Tally transaction synchronization failed.")) + except Exception as exc: + return RedirectResponse(url=f"/tools/tally?client_id={client.id}&error={quote(str(exc))}", status_code=303) + return RedirectResponse(url=f"/tools/tally?client_id={client.id}&refresh=1&transactions_synced=1", status_code=303) + finally: + db.close() diff --git a/app/modules/core/rbac/permissions_registry.py b/app/modules/core/rbac/permissions_registry.py index 50140cf..de9d9f6 100644 --- a/app/modules/core/rbac/permissions_registry.py +++ b/app/modules/core/rbac/permissions_registry.py @@ -151,6 +151,7 @@ PERMISSIONS = { "accounting.tally.connect": "Connect to Tally Through Local Agent", "accounting.tally.map_company": "Map ERP Client or Registration to Tally Company", "accounting.tally.sync_masters": "Synchronize Tally Accounting Masters", + "accounting.tally.sync_transactions": "Synchronize Tally Accounting Transactions", "accounting.act.initialize": "Initialize Client Accounting ACT Storage", "notice_cases.view": "View Notice and Case Management", @@ -181,3 +182,4 @@ def expand_permission_codes(code: str) -> list[str]: return codes + diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 707d372..92770fb 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.4.0" +ERP_LOCAL_AGENT_VERSION = "1.5.0" 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) @@ -53,7 +53,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" - "Phase 3 adds read-only Tally accounting master synchronization.\n" + "Phase 3 master synchronization is preserved. Phase 4 adds read-only date-scoped Tally voucher/transaction synchronization.\n" "Dashboard: http://127.0.0.1:8788\n" ) _write_zip_bytes(dst, "README_ERP_LOCAL_AGENT.txt", text.encode("utf-8")) diff --git a/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt b/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt index 0e01ffd..ce43e63 100644 --- a/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt +++ b/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt @@ -20,3 +20,5 @@ Client accounting .act databases remain separate under the configured STORAGE_RO Phase 2: client/registration to Tally company mapping is supported using Tally GUID. Phase 3: read-only Tally accounting master sync (Groups, Ledgers, Voucher Types, Stock Groups/Categories/Items, Units and Cost Centres/Categories) into client .act storage. + +Version 1.5.0: Phase 4 read-only Tally transaction synchronization (vouchers, ledger/inventory entries, bill/cost-centre/bank allocations) with raw voucher XML preservation. 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 4a07c3b..7cda226 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,2 @@ -__version__ = "1.4.0" +__version__ = "1.5.0" AGENT_NAME = "ERP Local Agent" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py index ea6770e..198a9fd 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py @@ -7,7 +7,7 @@ import sqlite3 from typing import Sequence -SCHEMA_VERSION = "3" +SCHEMA_VERSION = "4" MASTER_TABLES = { "groups": "tally_groups", @@ -30,7 +30,9 @@ class LocalAccountingStore: """Client-scoped SQLite .act accounting store. Phase 3 preserves Phase 1/2 metadata and mappings and adds company-scoped - read-only Tally master snapshots. Existing .act files are upgraded in place. + read-only Tally master snapshots. Phase 4 adds date-scoped vouchers and their + ledger, inventory, bill, cost-centre and bank allocations. Existing .act files + are upgraded in place. """ def __init__(self, storage_root: Path): @@ -176,6 +178,75 @@ class LocalAccountingStore: for table in MASTER_TABLES.values(): db.executescript(self._master_table_ddl(table)) + db.executescript( + """ + CREATE TABLE IF NOT EXISTS tally_vouchers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tally_guid TEXT NOT NULL, company_name TEXT NOT NULL, + voucher_guid TEXT NOT NULL DEFAULT '', master_id TEXT NOT NULL DEFAULT '', + alter_id TEXT NOT NULL DEFAULT '', voucher_key TEXT NOT NULL DEFAULT '', + voucher_date TEXT NOT NULL DEFAULT '', effective_date TEXT NOT NULL DEFAULT '', + voucher_type_name TEXT NOT NULL DEFAULT '', voucher_number TEXT NOT NULL DEFAULT '', + reference TEXT NOT NULL DEFAULT '', narration TEXT NOT NULL DEFAULT '', + party_ledger_name TEXT NOT NULL DEFAULT '', is_cancelled TEXT NOT NULL DEFAULT '', + is_optional TEXT NOT NULL DEFAULT '', is_post_dated TEXT NOT NULL DEFAULT '', + persisted_view TEXT NOT NULL DEFAULT '', synced_at_utc TEXT NOT NULL, raw_xml TEXT NOT NULL DEFAULT '' + ); + CREATE INDEX IF NOT EXISTS ix_tally_vouchers_company_date ON tally_vouchers(tally_guid, voucher_date); + CREATE INDEX IF NOT EXISTS ix_tally_vouchers_guid ON tally_vouchers(tally_guid, voucher_guid); + CREATE INDEX IF NOT EXISTS ix_tally_vouchers_number ON tally_vouchers(tally_guid, voucher_type_name, voucher_number); + + CREATE TABLE IF NOT EXISTS tally_voucher_ledger_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, voucher_id INTEGER NOT NULL, line_no INTEGER NOT NULL, + ledger_name TEXT NOT NULL DEFAULT '', amount REAL NOT NULL DEFAULT 0, + is_deemed_positive TEXT NOT NULL DEFAULT '', ledger_from_item TEXT NOT NULL DEFAULT '', + remove_zero_entries TEXT NOT NULL DEFAULT '', payload_json TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY(voucher_id) REFERENCES tally_vouchers(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS ix_tally_voucher_ledger_entries_voucher ON tally_voucher_ledger_entries(voucher_id, line_no); + CREATE INDEX IF NOT EXISTS ix_tally_voucher_ledger_entries_ledger ON tally_voucher_ledger_entries(ledger_name); + + CREATE TABLE IF NOT EXISTS tally_voucher_inventory_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, voucher_id INTEGER NOT NULL, line_no INTEGER NOT NULL, + stock_item_name TEXT NOT NULL DEFAULT '', is_deemed_positive TEXT NOT NULL DEFAULT '', + actual_qty TEXT NOT NULL DEFAULT '', billed_qty TEXT NOT NULL DEFAULT '', rate TEXT NOT NULL DEFAULT '', + amount REAL NOT NULL DEFAULT 0, godown_name TEXT NOT NULL DEFAULT '', batch_name TEXT NOT NULL DEFAULT '', + tracking_number TEXT NOT NULL DEFAULT '', order_number TEXT NOT NULL DEFAULT '', + payload_json TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY(voucher_id) REFERENCES tally_vouchers(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS ix_tally_voucher_inventory_entries_voucher ON tally_voucher_inventory_entries(voucher_id, line_no); + CREATE INDEX IF NOT EXISTS ix_tally_voucher_inventory_entries_stock ON tally_voucher_inventory_entries(stock_item_name); + + CREATE TABLE IF NOT EXISTS tally_bill_allocations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, ledger_entry_id INTEGER NOT NULL, line_no INTEGER NOT NULL, + name TEXT NOT NULL DEFAULT '', bill_type TEXT NOT NULL DEFAULT '', amount REAL NOT NULL DEFAULT 0, + bill_credit_period TEXT NOT NULL DEFAULT '', payload_json TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY(ledger_entry_id) REFERENCES tally_voucher_ledger_entries(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS ix_tally_bill_allocations_ledger_entry ON tally_bill_allocations(ledger_entry_id, line_no); + + CREATE TABLE IF NOT EXISTS tally_cost_centre_allocations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, ledger_entry_id INTEGER NOT NULL, line_no INTEGER NOT NULL, + category TEXT NOT NULL DEFAULT '', cost_centre TEXT NOT NULL DEFAULT '', amount REAL NOT NULL DEFAULT 0, + payload_json TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY(ledger_entry_id) REFERENCES tally_voucher_ledger_entries(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS ix_tally_cost_centre_allocations_ledger_entry ON tally_cost_centre_allocations(ledger_entry_id, line_no); + CREATE INDEX IF NOT EXISTS ix_tally_cost_centre_allocations_name ON tally_cost_centre_allocations(cost_centre); + + CREATE TABLE IF NOT EXISTS tally_bank_allocations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, ledger_entry_id INTEGER NOT NULL, line_no INTEGER NOT NULL, + transaction_type TEXT NOT NULL DEFAULT '', instrument_number TEXT NOT NULL DEFAULT '', + instrument_date TEXT NOT NULL DEFAULT '', bank_name TEXT NOT NULL DEFAULT '', + payment_favouring TEXT NOT NULL DEFAULT '', unique_reference_number TEXT NOT NULL DEFAULT '', + amount REAL NOT NULL DEFAULT 0, payload_json TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY(ledger_entry_id) REFERENCES tally_voucher_ledger_entries(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS ix_tally_bank_allocations_ledger_entry ON tally_bank_allocations(ledger_entry_id, line_no); + """ + ) + now = _utc_now_iso() meta = { "schema_version": SCHEMA_VERSION, @@ -337,6 +408,109 @@ class LocalAccountingStore: pass raise + def replace_transaction_snapshot(self, client_id: int, *, mapping: dict, transactions: dict, requested_by_user_id: int | None = None) -> dict: + if not self.exists(client_id): + raise ValueError("Accounting storage is not initialized for this client.") + tally_guid = str(mapping.get("tally_guid") or "").strip() + company_name = str(mapping.get("company_name") or "").strip() + mapping_id = int(mapping.get("id")) + date_from = str(transactions.get("date_from") or "").strip() + date_to = str(transactions.get("date_to") or "").strip() + vouchers = transactions.get("vouchers") or [] + if not tally_guid or not company_name: + raise ValueError("Active Tally mapping is incomplete.") + if not date_from or not date_to: + raise ValueError("Transaction sync date range is missing.") + + started = _utc_now_iso() + run_id = None + counts = {"vouchers": 0, "ledger_entries": 0, "inventory_entries": 0, "bill_allocations": 0, "cost_centre_allocations": 0, "bank_allocations": 0} + try: + with self.connect(client_id) as db: + cur = db.execute( + """INSERT INTO tally_sync_runs(sync_type, tally_guid, company_name, mapping_id, requested_by_user_id, status, started_at_utc, rows_processed, details_json) + VALUES ('transactions', ?, ?, ?, ?, 'running', ?, 0, ?)""", + (tally_guid, company_name, mapping_id, requested_by_user_id, started, json.dumps({"date_from": date_from, "date_to": date_to}, separators=(",", ":"))), + ) + run_id = int(cur.lastrowid) + + old_ids = [int(row[0]) for row in db.execute( + "SELECT id FROM tally_vouchers WHERE tally_guid=? AND voucher_date>=? AND voucher_date<=?", + (tally_guid, date_from, date_to), + ).fetchall()] + for voucher_id in old_ids: + db.execute("DELETE FROM tally_vouchers WHERE id=?", (voucher_id,)) + + synced_at = _utc_now_iso() + for voucher in vouchers: + cur = db.execute( + """INSERT INTO tally_vouchers( + tally_guid, company_name, voucher_guid, master_id, alter_id, voucher_key, + voucher_date, effective_date, voucher_type_name, voucher_number, reference, + narration, party_ledger_name, is_cancelled, is_optional, is_post_dated, + persisted_view, synced_at_utc, raw_xml + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (tally_guid, company_name, str(voucher.get("guid") or ""), str(voucher.get("master_id") or ""), + str(voucher.get("alter_id") or ""), str(voucher.get("voucher_key") or ""), str(voucher.get("date") or ""), + str(voucher.get("effective_date") or ""), str(voucher.get("voucher_type_name") or ""), str(voucher.get("voucher_number") or ""), + str(voucher.get("reference") or ""), str(voucher.get("narration") or ""), str(voucher.get("party_ledger_name") or ""), + str(voucher.get("is_cancelled") or ""), str(voucher.get("is_optional") or ""), str(voucher.get("is_post_dated") or ""), + str(voucher.get("persisted_view") or ""), synced_at, str(voucher.get("raw_xml") or "")), + ) + voucher_id = int(cur.lastrowid) + counts["vouchers"] += 1 + + for line_no, entry in enumerate(voucher.get("ledger_entries") or [], 1): + payload = json.dumps(entry, ensure_ascii=False, separators=(",", ":")) + cur_le = db.execute( + """INSERT INTO tally_voucher_ledger_entries(voucher_id, line_no, ledger_name, amount, is_deemed_positive, ledger_from_item, remove_zero_entries, payload_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (voucher_id, line_no, str(entry.get("ledger_name") or ""), float(entry.get("amount") or 0), str(entry.get("is_deemed_positive") or ""), str(entry.get("ledger_from_item") or ""), str(entry.get("remove_zero_entries") or ""), payload), + ) + ledger_entry_id = int(cur_le.lastrowid) + counts["ledger_entries"] += 1 + for child_no, bill in enumerate(entry.get("bill_allocations") or [], 1): + db.execute("""INSERT INTO tally_bill_allocations(ledger_entry_id, line_no, name, bill_type, amount, bill_credit_period, payload_json) VALUES (?, ?, ?, ?, ?, ?, ?)""", + (ledger_entry_id, child_no, str(bill.get("name") or ""), str(bill.get("bill_type") or ""), float(bill.get("amount") or 0), str(bill.get("bill_credit_period") or ""), json.dumps(bill, ensure_ascii=False, separators=(",", ":")))) + counts["bill_allocations"] += 1 + for child_no, cc in enumerate(entry.get("cost_centre_allocations") or [], 1): + db.execute("""INSERT INTO tally_cost_centre_allocations(ledger_entry_id, line_no, category, cost_centre, amount, payload_json) VALUES (?, ?, ?, ?, ?, ?)""", + (ledger_entry_id, child_no, str(cc.get("category") or ""), str(cc.get("cost_centre") or ""), float(cc.get("amount") or 0), json.dumps(cc, ensure_ascii=False, separators=(",", ":")))) + counts["cost_centre_allocations"] += 1 + for child_no, bank in enumerate(entry.get("bank_allocations") or [], 1): + db.execute("""INSERT INTO tally_bank_allocations(ledger_entry_id, line_no, transaction_type, instrument_number, instrument_date, bank_name, payment_favouring, unique_reference_number, amount, payload_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (ledger_entry_id, child_no, str(bank.get("transaction_type") or ""), str(bank.get("instrument_number") or ""), str(bank.get("instrument_date") or ""), str(bank.get("bank_name") or ""), str(bank.get("payment_favouring") or ""), str(bank.get("unique_reference_number") or ""), float(bank.get("amount") or 0), json.dumps(bank, ensure_ascii=False, separators=(",", ":")))) + counts["bank_allocations"] += 1 + + for line_no, entry in enumerate(voucher.get("inventory_entries") or [], 1): + db.execute( + """INSERT INTO tally_voucher_inventory_entries(voucher_id, line_no, stock_item_name, is_deemed_positive, actual_qty, billed_qty, rate, amount, godown_name, batch_name, tracking_number, order_number, payload_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (voucher_id, line_no, str(entry.get("stock_item_name") or ""), str(entry.get("is_deemed_positive") or ""), str(entry.get("actual_qty") or ""), str(entry.get("billed_qty") or ""), str(entry.get("rate") or ""), float(entry.get("amount") or 0), str(entry.get("godown_name") or ""), str(entry.get("batch_name") or ""), str(entry.get("tracking_number") or ""), str(entry.get("order_number") or ""), json.dumps(entry, ensure_ascii=False, separators=(",", ":"))), + ) + counts["inventory_entries"] += 1 + + total = sum(counts.values()) + details = {"date_from": date_from, "date_to": date_to, "counts": counts, "schema_version": SCHEMA_VERSION} + db.execute("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)) + return {"sync_run_id": run_id, "status": "completed", "company_name": company_name, "tally_guid": tally_guid, "date_from": date_from, "date_to": date_to, "counts": counts, "rows_processed": sum(counts.values())} + except Exception as exc: + if run_id is not None: + try: + with self.connect(client_id) as db: + db.execute("UPDATE tally_sync_runs SET status='failed', completed_at_utc=?, error_message=? WHERE id=?", (_utc_now_iso(), str(exc), run_id)) + except Exception: + pass + raise + + @staticmethod + def _transaction_counts(db: sqlite3.Connection, tally_guid: str) -> dict[str, int]: + voucher_count = int(db.execute("SELECT COUNT(*) FROM tally_vouchers WHERE tally_guid=?", (tally_guid,)).fetchone()[0]) + ledger_count = int(db.execute("SELECT COUNT(*) FROM tally_voucher_ledger_entries e JOIN tally_vouchers v ON v.id=e.voucher_id WHERE v.tally_guid=?", (tally_guid,)).fetchone()[0]) + inventory_count = int(db.execute("SELECT COUNT(*) FROM tally_voucher_inventory_entries e JOIN tally_vouchers v ON v.id=e.voucher_id WHERE v.tally_guid=?", (tally_guid,)).fetchone()[0]) + return {"vouchers": voucher_count, "ledger_entries": ledger_count, "inventory_entries": inventory_count} + def _master_counts(self, db: sqlite3.Connection, tally_guid: str) -> dict[str, int]: result = {} for key, table in MASTER_TABLES.items(): @@ -346,7 +520,7 @@ class LocalAccountingStore: def snapshot(self, client_id: int) -> dict: path = self.db_path(client_id) if not path.is_file(): - return {"exists": False, "db_path": str(path), "metadata": {}, "latest_connection": None, "companies": [], "mappings": [], "latest_master_sync": None} + return {"exists": False, "db_path": str(path), "metadata": {}, "latest_connection": None, "companies": [], "mappings": [], "latest_master_sync": None, "latest_transaction_sync": None} self.initialize(client_id) with self.connect(client_id) as db: meta_rows = db.execute("SELECT key, value FROM act_meta ORDER BY key").fetchall() @@ -361,12 +535,14 @@ class LocalAccountingStore: ).fetchall() latest = db.execute("SELECT checked_at_utc, connected, tally_url, company_count, error_message FROM tally_connection_history ORDER BY id DESC LIMIT 1").fetchone() latest_sync = db.execute("SELECT id, sync_type, tally_guid, company_name, mapping_id, requested_by_user_id, status, started_at_utc, completed_at_utc, rows_processed, error_message, details_json FROM tally_sync_runs WHERE sync_type='masters' ORDER BY id DESC LIMIT 1").fetchone() + latest_transaction_sync = db.execute("SELECT id, sync_type, tally_guid, company_name, mapping_id, requested_by_user_id, status, started_at_utc, completed_at_utc, rows_processed, error_message, details_json FROM tally_sync_runs WHERE sync_type='transactions' ORDER BY id DESC LIMIT 1").fetchone() loaded_guids = {str(row["guid"] or "") for row in companies if int(row["is_currently_loaded"] or 0)} mapped = [] for row in mappings: item = dict(row) item["currently_loaded"] = bool(item.get("tally_guid") and item["tally_guid"] in loaded_guids) item["master_counts"] = self._master_counts(db, str(item.get("tally_guid") or "")) + item["transaction_counts"] = self._transaction_counts(db, str(item.get("tally_guid") or "")) mapped.append(item) latest_sync_dict = dict(latest_sync) if latest_sync else None if latest_sync_dict and latest_sync_dict.get("details_json"): @@ -374,6 +550,12 @@ class LocalAccountingStore: latest_sync_dict["details"] = json.loads(latest_sync_dict["details_json"]) except Exception: latest_sync_dict["details"] = {} + latest_transaction_sync_dict = dict(latest_transaction_sync) if latest_transaction_sync else None + if latest_transaction_sync_dict and latest_transaction_sync_dict.get("details_json"): + try: + latest_transaction_sync_dict["details"] = json.loads(latest_transaction_sync_dict["details_json"]) + except Exception: + latest_transaction_sync_dict["details"] = {} return { "exists": True, "db_path": str(path), @@ -382,4 +564,5 @@ class LocalAccountingStore: "companies": [dict(row) for row in companies], "mappings": mapped, "latest_master_sync": latest_sync_dict, + "latest_transaction_sync": latest_transaction_sync_dict, } 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 5009860..aed86ca 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 @@ -23,7 +23,7 @@ class AgentCommandProcessor: error: str | None = None ok = False try: - if action in {"tally_status", "phase1_status", "phase2_status", "phase3_status"}: + if action in {"tally_status", "phase1_status", "phase2_status", "phase3_status", "phase4_status"}: result = self._status(payload) elif action == "accounting_initialize": result = self._initialize(payload) @@ -33,6 +33,8 @@ class AgentCommandProcessor: result = self._unmap_company(payload) elif action == "accounting_sync_masters": result = self._sync_masters(payload) + elif action == "accounting_sync_transactions": + result = self._sync_transactions(payload) else: raise ValueError(f"Unsupported local-agent command: {action}") ok = True @@ -46,6 +48,7 @@ class AgentCommandProcessor: "name": "ERP Local Agent", "version": __version__, "tally_capability": True, "accounting_act_capability": True, "tally_mapping_capability": True, "tally_master_sync_capability": True, + "tally_transaction_sync_capability": True, } def _status(self, payload: dict[str, Any]) -> dict[str, Any]: @@ -128,3 +131,44 @@ class AgentCommandProcessor: sync = self.store.replace_master_snapshot(client_id, mapping={**mapping, "company_name": company_name}, masters=masters, requested_by_user_id=int(requested_by_user_id) if requested_by_user_id not in (None, "") else None) self.logger.info("Tally master sync completed client_id=%s company=%s rows=%s", client_id, company_name, sync.get("rows_processed")) return {"synced": True, "sync": sync, "accounting": self.store.snapshot(client_id), "tally": tally_status, "agent": self._agent_info()} + + + # Phase 4 transaction sync is intentionally read-only and mapping-gated. + def _sync_transactions(self, payload: dict[str, Any]) -> dict[str, Any]: + from datetime import date + client_id = int(payload.get("client_id")) + requested_guid = str(payload.get("tally_guid") or "").strip() + date_from = str(payload.get("date_from") or "").strip() + date_to = str(payload.get("date_to") or "").strip() + requested_by_user_id = payload.get("requested_by_user_id") + if not requested_guid: + raise ValueError("Select a currently open Tally company before synchronizing transactions.") + try: + start = date.fromisoformat(date_from) + end = date.fromisoformat(date_to) + except Exception as exc: + raise ValueError("Select valid From Date and To Date for transaction sync.") from exc + if start > end: + raise ValueError("Transaction sync From Date cannot be after To Date.") + if (end - start).days > 370: + raise ValueError("Transaction sync is limited to 371 days per run. Sync additional periods separately.") + if not self.store.exists(client_id): + raise ValueError("Accounting storage is not initialized for this client.") + mapping = self.store.get_active_mapping_by_guid(client_id, requested_guid) + tally_status = self.tally.status() + if not tally_status.get("connected"): + raise ValueError(str(tally_status.get("error") or "TallyPrime is not connected.")) + company = next((row for row in (tally_status.get("companies") or []) if str(row.get("guid") or "").strip() == requested_guid), None) + if not company: + raise ValueError("The selected mapped Tally company is not currently open in TallyPrime. Open it in Tally and refresh the ERP page.") + company_name = str(company.get("name") or mapping.get("company_name") or "").strip() + transactions = self.tally.fetch_accounting_transactions(company_name, date_from, date_to) + self.store.record_tally_status(client_id, tally_status) + sync = self.store.replace_transaction_snapshot( + client_id, + mapping={**mapping, "company_name": company_name}, + transactions=transactions, + requested_by_user_id=int(requested_by_user_id) if requested_by_user_id not in (None, "") else None, + ) + self.logger.info("Tally transaction sync completed client_id=%s company=%s period=%s..%s rows=%s", client_id, company_name, date_from, date_to, sync.get("rows_processed")) + return {"synced": True, "sync": sync, "accounting": self.store.snapshot(client_id), "tally": tally_status, "agent": self._agent_info()} diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/sync.py b/app/modules/documents/local_agent_runtime/erp_local_agent/sync.py index 572eda6..06569fc 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/sync.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/sync.py @@ -49,7 +49,7 @@ class StorageAgent: "free_bytes": free, "agent_version": __version__, "agent_name": "ERP Local Agent", - "capabilities": ["storage", "tally", "accounting_act", "local_dashboard", "manual_updates"], + "capabilities": ["storage", "tally", "accounting_act", "tally_master_sync", "tally_transaction_sync", "local_dashboard", "manual_updates"], "agent_time_utc": datetime.now(timezone.utc).isoformat(), } self.client.heartbeat(payload) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py b/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py index 0a4e9e1..dfd8846 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py @@ -54,6 +54,38 @@ def _to_number(value: str) -> float: return 0.0 +def _normalize_iso_date(value: str) -> str: + text = str(value or "").strip() + if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", text): + raise ValueError("Date must be in YYYY-MM-DD format.") + from datetime import date + try: + return date.fromisoformat(text).isoformat() + except Exception as exc: + raise ValueError(f"Invalid date: {text}") from exc + + +def _tally_date(value: str) -> str: + return _normalize_iso_date(value).replace("-", "") + + +def _parse_tally_date(value: str) -> str: + text = re.sub(r"[^0-9]", "", str(value or "")) + if len(text) == 8: + try: + # Tally XML normally exports YYYYMMDD. + from datetime import date + return date(int(text[0:4]), int(text[4:6]), int(text[6:8])).isoformat() + except Exception: + return str(value or "").strip() + return str(value or "").strip() + + +def _direct_children(element: ET.Element, names: Iterable[str]) -> list[ET.Element]: + wanted = {str(name).upper() for name in names} + return [child for child in list(element) if _tag(child) in wanted] + + @dataclass(frozen=True) class TallyCompany: name: str @@ -67,9 +99,9 @@ class TallyCompany: class TallyLiveConnector: """Read-only TallyPrime XML/HTTP connector used by the ERP Local Agent. - Phase 3 adds master discovery for the currently loaded Tally companies. Every - master export is scoped with SVCURRENTCOMPANY and does not create or modify - any Tally data. + Phase 3 adds master discovery for the currently loaded Tally companies. Phase 4 + adds date-scoped read-only voucher/transaction export. Every export is scoped + with SVCURRENTCOMPANY and never creates, alters or deletes Tally data. """ MASTER_SPECS = { @@ -226,6 +258,137 @@ class TallyLiveConnector: result[key] = self.export_master_collection(company_name, key) return result + def export_vouchers(self, company_name: str, date_from: str, date_to: str) -> list[dict]: + company = str(company_name or "").strip() + if not company: + raise ValueError("Tally company name is required for transaction sync.") + start = _normalize_iso_date(date_from) + end = _normalize_iso_date(date_to) + from datetime import date + if date.fromisoformat(start) > date.fromisoformat(end): + raise ValueError("Transaction sync From Date cannot be after To Date.") + if (date.fromisoformat(end) - date.fromisoformat(start)).days > 370: + raise ValueError("Transaction sync is limited to 371 days per run. Sync additional periods separately.") + xml = f""" +
1ExportCollectionARRRAccountingVouchers
+ + {self._static_variables(company)}{_tally_date(start)}{_tally_date(end)} + + VoucherGUID,MasterID,AlterID,VoucherKey,Date,EffectiveDate,VoucherTypeName,VoucherNumber,Reference,Narration,PartyLedgerName,IsCancelled,IsOptional,IsPostDated,PersistedView,AllLedgerEntries.*,LedgerEntries.*,AllInventoryEntries.*,InventoryEntries.*,BillAllocations.*,CategoryAllocations.*,CostCentreAllocations.*,BankAllocations.* + + +
""" + return self._parse_vouchers(self._post_xml(xml)) + + def fetch_accounting_transactions(self, company_name: str, date_from: str, date_to: str) -> dict: + start = _normalize_iso_date(date_from) + end = _normalize_iso_date(date_to) + vouchers = self.export_vouchers(company_name, start, end) + return {"date_from": start, "date_to": end, "vouchers": vouchers} + + @staticmethod + def _parse_vouchers(xml_text: str) -> list[dict]: + cleaned = _clean_xml_response(xml_text) + if not cleaned.strip(): + return [] + try: + root = ET.fromstring(cleaned.encode("utf-8")) + except Exception as exc: + raise ValueError(f"Tally returned invalid XML while reading vouchers: {exc}") from exc + + vouchers: list[dict] = [] + ledger_tags = {"ALLLEDGERENTRIES.LIST", "LEDGERENTRIES.LIST"} + inventory_tags = {"ALLINVENTORYENTRIES.LIST", "INVENTORYENTRIES.LIST"} + for voucher in root.iter(): + if _tag(voucher) != "VOUCHER": + continue + raw_xml = ET.tostring(voucher, encoding="unicode") + ledger_entries: list[dict] = [] + inventory_entries: list[dict] = [] + + for entry in _direct_children(voucher, ledger_tags): + bills = [] + banks = [] + cost_centres = [] + for bill in _direct_children(entry, {"BILLALLOCATIONS.LIST"}): + bills.append({ + "name": _child_text(bill, "NAME"), + "bill_type": _child_text(bill, "BILLTYPE"), + "amount": _to_number(_child_text(bill, "AMOUNT")), + "bill_credit_period": _child_text(bill, "BILLCREDITPERIOD"), + }) + for bank in _direct_children(entry, {"BANKALLOCATIONS.LIST"}): + banks.append({ + "transaction_type": _child_text(bank, "TRANSACTIONTYPE"), + "instrument_number": _child_text(bank, "INSTRUMENTNUMBER"), + "instrument_date": _parse_tally_date(_child_text(bank, "INSTRUMENTDATE")), + "bank_name": _child_text(bank, "BANKNAME"), + "payment_favouring": _child_text(bank, "PAYMENTFAVOURING"), + "unique_reference_number": _child_text(bank, "UNIQUEREFERENCENUMBER"), + "amount": _to_number(_child_text(bank, "AMOUNT")), + }) + for category in _direct_children(entry, {"CATEGORYALLOCATIONS.LIST"}): + category_name = _child_text(category, "CATEGORY") or _child_text(category, "NAME") + for centre in _direct_children(category, {"COSTCENTREALLOCATIONS.LIST"}): + cost_centres.append({ + "category": category_name, + "cost_centre": _child_text(centre, "NAME"), + "amount": _to_number(_child_text(centre, "AMOUNT")), + }) + # Some Tally exports return COSTCENTREALLOCATIONS directly under ledger entry. + for centre in _direct_children(entry, {"COSTCENTREALLOCATIONS.LIST"}): + cost_centres.append({ + "category": "", + "cost_centre": _child_text(centre, "NAME"), + "amount": _to_number(_child_text(centre, "AMOUNT")), + }) + ledger_entries.append({ + "ledger_name": _child_text(entry, "LEDGERNAME"), + "amount": _to_number(_child_text(entry, "AMOUNT")), + "is_deemed_positive": _child_text(entry, "ISDEEMEDPOSITIVE"), + "ledger_from_item": _child_text(entry, "LEDGERFROMITEM"), + "remove_zero_entries": _child_text(entry, "REMOVEZEROENTRIES"), + "bill_allocations": bills, + "cost_centre_allocations": cost_centres, + "bank_allocations": banks, + }) + + for entry in _direct_children(voucher, inventory_tags): + inventory_entries.append({ + "stock_item_name": _child_text(entry, "STOCKITEMNAME"), + "is_deemed_positive": _child_text(entry, "ISDEEMEDPOSITIVE"), + "actual_qty": _child_text(entry, "ACTUALQTY"), + "billed_qty": _child_text(entry, "BILLEDQTY"), + "rate": _child_text(entry, "RATE"), + "amount": _to_number(_child_text(entry, "AMOUNT")), + "godown_name": _child_text(entry, "GODOWNNAME"), + "batch_name": _child_text(entry, "BATCHNAME"), + "tracking_number": _child_text(entry, "TRACKINGNUMBER"), + "order_number": _child_text(entry, "ORDERNO"), + }) + + vouchers.append({ + "guid": _child_text(voucher, "GUID"), + "master_id": _child_text(voucher, "MASTERID"), + "alter_id": _child_text(voucher, "ALTERID"), + "voucher_key": _child_text(voucher, "VOUCHERKEY"), + "date": _parse_tally_date(_child_text(voucher, "DATE")), + "effective_date": _parse_tally_date(_child_text(voucher, "EFFECTIVEDATE")), + "voucher_type_name": _child_text(voucher, "VOUCHERTYPENAME"), + "voucher_number": _child_text(voucher, "VOUCHERNUMBER"), + "reference": _child_text(voucher, "REFERENCE"), + "narration": _child_text(voucher, "NARRATION"), + "party_ledger_name": _child_text(voucher, "PARTYLEDGERNAME"), + "is_cancelled": _child_text(voucher, "ISCANCELLED"), + "is_optional": _child_text(voucher, "ISOPTIONAL"), + "is_post_dated": _child_text(voucher, "ISPOSTDATED"), + "persisted_view": _child_text(voucher, "PERSISTEDVIEW"), + "ledger_entries": ledger_entries, + "inventory_entries": inventory_entries, + "raw_xml": raw_xml, + }) + return vouchers + @staticmethod def _parse_master_rows(xml_text: str, expected_tag: str) -> list[dict]: cleaned = _clean_xml_response(xml_text) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/tunnel.py b/app/modules/documents/local_agent_runtime/erp_local_agent/tunnel.py index 73f4000..d09bc02 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/tunnel.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/tunnel.py @@ -76,7 +76,7 @@ class StorageAgentTunnel: "jobs_seen": len(jobs), "requests_seen": len(requests_), "commands_seen": len(commands), - "capabilities": ["storage", "tally", "accounting_act", "local_dashboard", "manual_updates"], + "capabilities": ["storage", "tally", "accounting_act", "tally_master_sync", "tally_transaction_sync", "local_dashboard", "manual_updates"], "agent_time_utc": self._now(), })) continue diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/updater.py b/app/modules/documents/local_agent_runtime/erp_local_agent/updater.py index 64fb6b1..9ef2728 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/updater.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/updater.py @@ -122,10 +122,12 @@ class AgentUpdater: script_path.write_text(self._powershell_update_script(staged, latest), encoding="utf-8") self._record("install_requested", latest, "pending", package_path=str(staged)) flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "DETACHED_PROCESS", 0) - subprocess.Popen( + process = subprocess.Popen( ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(script_path), "-ParentPid", str(os.getpid())], cwd=str(self.install_dir), creationflags=flags, close_fds=True, ) + if process.pid <= 0: + raise RuntimeError("Could not launch the ERP Local Agent update installer.") self.logger.warning("ERP Local Agent update %s installation requested by local user; restarting.", latest) time.sleep(0.3) os._exit(0)