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)