Add Phase 4 Tally transaction synchronization

This commit is contained in:
A R R R Associates
2026-08-19 21:04:17 +05:30
parent 6551a20e05
commit 102520bb25
14 changed files with 567 additions and 19 deletions
+2
View File
@@ -337,6 +337,7 @@ ROLE_PERMISSION_MAP = {
"accounting.tally.connect", "accounting.tally.connect",
"accounting.tally.map_company", "accounting.tally.map_company",
"accounting.tally.sync_masters", "accounting.tally.sync_masters",
"accounting.tally.sync_transactions",
"accounting.act.initialize", "accounting.act.initialize",
"clients.view.own_only", "clients.view.own_only",
"employees.dashboard.view", "employees.dashboard.view",
@@ -971,3 +972,4 @@ def on_startup(app: FastAPI) -> None:
start_notification_scheduler() start_notification_scheduler()
+39 -2
View File
@@ -8,7 +8,7 @@ import os
import sqlite3 import sqlite3
from typing import Iterator, Sequence from typing import Iterator, Sequence
ACT_SCHEMA_VERSION = 3 ACT_SCHEMA_VERSION = 4
class AccountingActStoreError(RuntimeError): class AccountingActStoreError(RuntimeError):
@@ -32,7 +32,8 @@ class AccountingActStore:
Phase 1 provides accounting storage and read-only Tally discovery. Phase 1 provides accounting storage and read-only Tally discovery.
Phase 2 adds durable client/registration -> Tally company mapping keyed by Phase 2 adds durable client/registration -> Tally company mapping keyed by
Tally GUID. Phase 3 adds read-only Tally accounting master snapshot tables. 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: 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 '{}' 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_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_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); CREATE INDEX IF NOT EXISTS ix_tally_voucher_types_company ON tally_voucher_types(tally_guid, name);
@@ -1,6 +1,10 @@
{% extends "ui/templates/base/layout.html" %} {% extends "ui/templates/base/layout.html" %}
{% block content %} {% block content %}
{% if synced %}<div class="mb-4 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Tally accounting masters synchronized successfully.</div>{% endif %} {% if synced %}<div class="mb-4 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Tally accounting masters synchronized successfully.</div>{% endif %}
{% if transactions_synced %}
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Tally accounting transactions synchronized successfully.</div>
{% endif %}
<div class="space-y-6"> <div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex flex-wrap items-start justify-between gap-4">
<div> <div>
@@ -192,6 +196,54 @@
</section> </section>
{% endif %} {% endif %}
{% if accounting and accounting.exists and tally and tally.connected %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="font-semibold text-slate-900">Phase 4 · Accounting Transaction Sync</h2>
<p class="mt-1 text-sm text-slate-500">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.</p>
</div>
<span class="rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">Read-only</span>
</div>
<form method="post" action="/tools/tally/sync-transactions" class="mt-5 grid gap-4 lg:grid-cols-4 lg:items-end">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="client_id" value="{{ selected_client.id }}" />
<label class="text-sm font-medium text-slate-700">
Currently Open in TallyPrime
<select name="tally_guid" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="">Select open Tally company</option>
{% for company in tally.companies %}
{% if company.guid %}
<option value="{{ company.guid }}">{{ company.name }}{% if company.gstin %} · {{ company.gstin }}{% endif %}</option>
{% endif %}
{% endfor %}
</select>
<span class="mt-1 block text-xs font-normal text-slate-500">Live list from companies currently loaded/open in this computer's TallyPrime.</span>
</label>
<label class="text-sm font-medium text-slate-700">From Date
<input type="date" name="date_from" required value="{{ transaction_date_from }}" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm" />
</label>
<label class="text-sm font-medium text-slate-700">To Date
<input type="date" name="date_to" required value="{{ transaction_date_to }}" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm" />
</label>
<button type="submit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Sync Transactions</button>
</form>
{% set tx = accounting.latest_transaction_sync if accounting else none %}
<div class="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-xl border border-slate-200 p-3"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Latest Sync</div><div class="mt-1 text-sm font-medium text-slate-900">{{ tx.completed_at_utc if tx and tx.completed_at_utc else 'Not synced yet' }}</div></div>
<div class="rounded-xl border border-slate-200 p-3"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Period</div><div class="mt-1 text-sm font-medium text-slate-900">{% if tx and tx.details %}{{ tx.details.date_from or '-' }} → {{ tx.details.date_to or '-' }}{% else %}-{% endif %}</div></div>
<div class="rounded-xl border border-slate-200 p-3"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Vouchers</div><div class="mt-1 text-sm font-medium text-slate-900">{% if tx and tx.details and tx.details.counts %}{{ tx.details.counts.vouchers or 0 }}{% else %}0{% endif %}</div></div>
<div class="rounded-xl border border-slate-200 p-3"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Rows Stored</div><div class="mt-1 text-sm font-medium text-slate-900">{{ tx.rows_processed if tx else 0 }}</div></div>
</div>
<div class="mt-4 rounded-xl bg-slate-50 px-4 py-3 text-xs text-slate-600">
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.
</div>
</section>
{% endif %}
{% if accounting and accounting.exists %} {% if accounting and accounting.exists %}
<section class="overflow-hidden rounded-2xl bg-white shadow-soft"> <section class="overflow-hidden rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-100 px-5 py-4"> <div class="border-b border-slate-100 px-5 py-4">
@@ -279,7 +331,7 @@
{% endif %} {% endif %}
<div class="rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800"> <div class="rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">
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.
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+64 -3
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import date, datetime, timezone
from urllib.parse import quote from urllib.parse import quote
from fastapi import APIRouter, Form, Request from fastapi import APIRouter, Form, Request
@@ -115,6 +115,7 @@ def tally_tool(
mapped: int = 0, mapped: int = 0,
unmapped: int = 0, unmapped: int = 0,
synced: int = 0, synced: int = 0,
transactions_synced: int = 0,
error: str = "", error: str = "",
): ):
db = CommonSessionLocal() db = CommonSessionLocal()
@@ -149,7 +150,7 @@ def tally_tool(
try: try:
response_data = request_agent_command( response_data = request_agent_command(
node.node_code, node.node_code,
"phase3_status", "phase4_status",
payload, payload,
timeout_seconds=20, timeout_seconds=20,
) )
@@ -160,11 +161,16 @@ def tally_tool(
except Exception as exc: except Exception as exc:
command_error = str(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( return _render(
request, request,
db, db,
user, user,
title="Tally Accounting Masters", title="Tally Accounting",
clients=clients, clients=clients,
selected_client=selected_client, selected_client=selected_client,
registrations=registrations, registrations=registrations,
@@ -175,6 +181,9 @@ def tally_tool(
mapped=bool(mapped), mapped=bool(mapped),
unmapped=bool(unmapped), unmapped=bool(unmapped),
synced=bool(synced), synced=bool(synced),
transactions_synced=bool(transactions_synced),
transaction_date_from=transaction_date_from,
transaction_date_to=transaction_date_to,
command_error=command_error, command_error=command_error,
) )
finally: finally:
@@ -408,3 +417,55 @@ def sync_tally_masters(
) )
finally: finally:
db.close() 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()
@@ -151,6 +151,7 @@ PERMISSIONS = {
"accounting.tally.connect": "Connect to Tally Through Local Agent", "accounting.tally.connect": "Connect to Tally Through Local Agent",
"accounting.tally.map_company": "Map ERP Client or Registration to Tally Company", "accounting.tally.map_company": "Map ERP Client or Registration to Tally Company",
"accounting.tally.sync_masters": "Synchronize Tally Accounting Masters", "accounting.tally.sync_masters": "Synchronize Tally Accounting Masters",
"accounting.tally.sync_transactions": "Synchronize Tally Accounting Transactions",
"accounting.act.initialize": "Initialize Client Accounting ACT Storage", "accounting.act.initialize": "Initialize Client Accounting ACT Storage",
"notice_cases.view": "View Notice and Case Management", "notice_cases.view": "View Notice and Case Management",
@@ -181,3 +182,4 @@ def expand_permission_codes(code: str) -> list[str]:
return codes return codes
+2 -2
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path from pathlib import Path
import zipfile import zipfile
ERP_LOCAL_AGENT_VERSION = "1.4.0" ERP_LOCAL_AGENT_VERSION = "1.5.0"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent" ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
@@ -53,7 +53,7 @@ def _build_zip(*, env_text: str | None, include_env: bool, include_admin_readme:
text = ( text = (
f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n" f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n"
"Existing storage, WebSocket tunnel, dashboard, Tally mapping and client .act functionality are preserved.\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" "Dashboard: http://127.0.0.1:8788\n"
) )
_write_zip_bytes(dst, "README_ERP_LOCAL_AGENT.txt", text.encode("utf-8")) _write_zip_bytes(dst, "README_ERP_LOCAL_AGENT.txt", text.encode("utf-8"))
@@ -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 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. 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.
@@ -1,2 +1,2 @@
__version__ = "1.4.0" __version__ = "1.5.0"
AGENT_NAME = "ERP Local Agent" AGENT_NAME = "ERP Local Agent"
@@ -7,7 +7,7 @@ import sqlite3
from typing import Sequence from typing import Sequence
SCHEMA_VERSION = "3" SCHEMA_VERSION = "4"
MASTER_TABLES = { MASTER_TABLES = {
"groups": "tally_groups", "groups": "tally_groups",
@@ -30,7 +30,9 @@ class LocalAccountingStore:
"""Client-scoped SQLite .act accounting store. """Client-scoped SQLite .act accounting store.
Phase 3 preserves Phase 1/2 metadata and mappings and adds company-scoped 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): def __init__(self, storage_root: Path):
@@ -176,6 +178,75 @@ class LocalAccountingStore:
for table in MASTER_TABLES.values(): for table in MASTER_TABLES.values():
db.executescript(self._master_table_ddl(table)) 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() now = _utc_now_iso()
meta = { meta = {
"schema_version": SCHEMA_VERSION, "schema_version": SCHEMA_VERSION,
@@ -337,6 +408,109 @@ class LocalAccountingStore:
pass pass
raise 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]: def _master_counts(self, db: sqlite3.Connection, tally_guid: str) -> dict[str, int]:
result = {} result = {}
for key, table in MASTER_TABLES.items(): for key, table in MASTER_TABLES.items():
@@ -346,7 +520,7 @@ class LocalAccountingStore:
def snapshot(self, client_id: int) -> dict: def snapshot(self, client_id: int) -> dict:
path = self.db_path(client_id) path = self.db_path(client_id)
if not path.is_file(): 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) self.initialize(client_id)
with self.connect(client_id) as db: with self.connect(client_id) as db:
meta_rows = db.execute("SELECT key, value FROM act_meta ORDER BY key").fetchall() meta_rows = db.execute("SELECT key, value FROM act_meta ORDER BY key").fetchall()
@@ -361,12 +535,14 @@ class LocalAccountingStore:
).fetchall() ).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 = 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_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)} loaded_guids = {str(row["guid"] or "") for row in companies if int(row["is_currently_loaded"] or 0)}
mapped = [] mapped = []
for row in mappings: for row in mappings:
item = dict(row) item = dict(row)
item["currently_loaded"] = bool(item.get("tally_guid") and item["tally_guid"] in loaded_guids) 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["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) mapped.append(item)
latest_sync_dict = dict(latest_sync) if latest_sync else None latest_sync_dict = dict(latest_sync) if latest_sync else None
if latest_sync_dict and latest_sync_dict.get("details_json"): 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"]) latest_sync_dict["details"] = json.loads(latest_sync_dict["details_json"])
except Exception: except Exception:
latest_sync_dict["details"] = {} 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 { return {
"exists": True, "exists": True,
"db_path": str(path), "db_path": str(path),
@@ -382,4 +564,5 @@ class LocalAccountingStore:
"companies": [dict(row) for row in companies], "companies": [dict(row) for row in companies],
"mappings": mapped, "mappings": mapped,
"latest_master_sync": latest_sync_dict, "latest_master_sync": latest_sync_dict,
"latest_transaction_sync": latest_transaction_sync_dict,
} }
@@ -23,7 +23,7 @@ class AgentCommandProcessor:
error: str | None = None error: str | None = None
ok = False ok = False
try: 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) result = self._status(payload)
elif action == "accounting_initialize": elif action == "accounting_initialize":
result = self._initialize(payload) result = self._initialize(payload)
@@ -33,6 +33,8 @@ class AgentCommandProcessor:
result = self._unmap_company(payload) result = self._unmap_company(payload)
elif action == "accounting_sync_masters": elif action == "accounting_sync_masters":
result = self._sync_masters(payload) result = self._sync_masters(payload)
elif action == "accounting_sync_transactions":
result = self._sync_transactions(payload)
else: else:
raise ValueError(f"Unsupported local-agent command: {action}") raise ValueError(f"Unsupported local-agent command: {action}")
ok = True ok = True
@@ -46,6 +48,7 @@ class AgentCommandProcessor:
"name": "ERP Local Agent", "version": __version__, "name": "ERP Local Agent", "version": __version__,
"tally_capability": True, "accounting_act_capability": True, "tally_capability": True, "accounting_act_capability": True,
"tally_mapping_capability": True, "tally_master_sync_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]: 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) 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")) 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()} 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()}
@@ -49,7 +49,7 @@ class StorageAgent:
"free_bytes": free, "free_bytes": free,
"agent_version": __version__, "agent_version": __version__,
"agent_name": "ERP Local Agent", "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(), "agent_time_utc": datetime.now(timezone.utc).isoformat(),
} }
self.client.heartbeat(payload) self.client.heartbeat(payload)
@@ -54,6 +54,38 @@ def _to_number(value: str) -> float:
return 0.0 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) @dataclass(frozen=True)
class TallyCompany: class TallyCompany:
name: str name: str
@@ -67,9 +99,9 @@ class TallyCompany:
class TallyLiveConnector: class TallyLiveConnector:
"""Read-only TallyPrime XML/HTTP connector used by the ERP Local Agent. """Read-only TallyPrime XML/HTTP connector used by the ERP Local Agent.
Phase 3 adds master discovery for the currently loaded Tally companies. Every Phase 3 adds master discovery for the currently loaded Tally companies. Phase 4
master export is scoped with SVCURRENTCOMPANY and does not create or modify adds date-scoped read-only voucher/transaction export. Every export is scoped
any Tally data. with SVCURRENTCOMPANY and never creates, alters or deletes Tally data.
""" """
MASTER_SPECS = { MASTER_SPECS = {
@@ -226,6 +258,137 @@ class TallyLiveConnector:
result[key] = self.export_master_collection(company_name, key) result[key] = self.export_master_collection(company_name, key)
return result 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"""<ENVELOPE>
<HEADER><VERSION>1</VERSION><TALLYREQUEST>Export</TALLYREQUEST><TYPE>Collection</TYPE><ID>ARRRAccountingVouchers</ID></HEADER>
<BODY><DESC>
<STATICVARIABLES>{self._static_variables(company)}<SVFROMDATE>{_tally_date(start)}</SVFROMDATE><SVTODATE>{_tally_date(end)}</SVTODATE></STATICVARIABLES>
<TDL><TDLMESSAGE>
<COLLECTION NAME="ARRRAccountingVouchers" ISMODIFY="No"><TYPE>Voucher</TYPE><FETCH>GUID,MasterID,AlterID,VoucherKey,Date,EffectiveDate,VoucherTypeName,VoucherNumber,Reference,Narration,PartyLedgerName,IsCancelled,IsOptional,IsPostDated,PersistedView,AllLedgerEntries.*,LedgerEntries.*,AllInventoryEntries.*,InventoryEntries.*,BillAllocations.*,CategoryAllocations.*,CostCentreAllocations.*,BankAllocations.*</FETCH></COLLECTION>
</TDLMESSAGE></TDL>
</DESC></BODY>
</ENVELOPE>"""
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 @staticmethod
def _parse_master_rows(xml_text: str, expected_tag: str) -> list[dict]: def _parse_master_rows(xml_text: str, expected_tag: str) -> list[dict]:
cleaned = _clean_xml_response(xml_text) cleaned = _clean_xml_response(xml_text)
@@ -76,7 +76,7 @@ class StorageAgentTunnel:
"jobs_seen": len(jobs), "jobs_seen": len(jobs),
"requests_seen": len(requests_), "requests_seen": len(requests_),
"commands_seen": len(commands), "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(), "agent_time_utc": self._now(),
})) }))
continue continue
@@ -122,10 +122,12 @@ class AgentUpdater:
script_path.write_text(self._powershell_update_script(staged, latest), encoding="utf-8") script_path.write_text(self._powershell_update_script(staged, latest), encoding="utf-8")
self._record("install_requested", latest, "pending", package_path=str(staged)) self._record("install_requested", latest, "pending", package_path=str(staged))
flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "DETACHED_PROCESS", 0) 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())], ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(script_path), "-ParentPid", str(os.getpid())],
cwd=str(self.install_dir), creationflags=flags, close_fds=True, 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) self.logger.warning("ERP Local Agent update %s installation requested by local user; restarting.", latest)
time.sleep(0.3) time.sleep(0.3)
os._exit(0) os._exit(0)