Add Phase 4 Tally transaction synchronization
This commit is contained in:
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user