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.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()
+39 -2
View File
@@ -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);
@@ -1,6 +1,10 @@
{% extends "ui/templates/base/layout.html" %}
{% 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 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="flex flex-wrap items-start justify-between gap-4">
<div>
@@ -192,6 +196,54 @@
</section>
{% 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 %}
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-100 px-5 py-4">
@@ -279,7 +331,7 @@
{% endif %}
<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>
{% endblock %}
+64 -3
View File
@@ -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()
@@ -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
+2 -2
View File
@@ -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"))
@@ -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.
@@ -1,2 +1,2 @@
__version__ = "1.4.0"
__version__ = "1.5.0"
AGENT_NAME = "ERP Local Agent"
@@ -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,
}
@@ -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()}
@@ -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)
@@ -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"""<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
def _parse_master_rows(xml_text: str, expected_tag: str) -> list[dict]:
cleaned = _clean_xml_response(xml_text)
@@ -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
@@ -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)