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
+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()