Add Phase 3 Tally accounting master synchronization
This commit is contained in:
@@ -336,6 +336,7 @@ ROLE_PERMISSION_MAP = {
|
|||||||
"accounting.tally.view",
|
"accounting.tally.view",
|
||||||
"accounting.tally.connect",
|
"accounting.tally.connect",
|
||||||
"accounting.tally.map_company",
|
"accounting.tally.map_company",
|
||||||
|
"accounting.tally.sync_masters",
|
||||||
"accounting.act.initialize",
|
"accounting.act.initialize",
|
||||||
"clients.view.own_only",
|
"clients.view.own_only",
|
||||||
"employees.dashboard.view",
|
"employees.dashboard.view",
|
||||||
@@ -969,3 +970,4 @@ def on_startup(app: FastAPI) -> None:
|
|||||||
# Phase 7O: start alert notification/escalation automation after schema and seed checks.
|
# Phase 7O: start alert notification/escalation automation after schema and seed checks.
|
||||||
start_notification_scheduler()
|
start_notification_scheduler()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import os
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
from typing import Iterator, Sequence
|
from typing import Iterator, Sequence
|
||||||
|
|
||||||
ACT_SCHEMA_VERSION = 2
|
ACT_SCHEMA_VERSION = 3
|
||||||
|
|
||||||
|
|
||||||
class AccountingActStoreError(RuntimeError):
|
class AccountingActStoreError(RuntimeError):
|
||||||
@@ -31,8 +31,8 @@ class AccountingActStore:
|
|||||||
|
|
||||||
Phase 1 provides accounting storage and read-only Tally discovery.
|
Phase 1 provides accounting storage and read-only Tally discovery.
|
||||||
Phase 2 adds durable client/registration -> Tally company mapping keyed by
|
Phase 2 adds durable client/registration -> Tally company mapping keyed by
|
||||||
Tally GUID. Accounting master/transaction sync and write-back remain out of
|
Tally GUID. Phase 3 adds read-only Tally accounting master snapshot tables.
|
||||||
scope.
|
Voucher/transaction sync and write-back remain out of scope.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, root: str | Path) -> None:
|
def __init__(self, root: str | Path) -> None:
|
||||||
@@ -136,6 +136,44 @@ class AccountingActStore:
|
|||||||
details_json TEXT NOT NULL DEFAULT '{}'
|
details_json TEXT NOT NULL DEFAULT '{}'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tally_groups (
|
||||||
|
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 '', synced_at_utc TEXT NOT NULL, payload_json TEXT NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tally_ledgers (
|
||||||
|
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 '', opening_balance REAL NOT NULL DEFAULT 0, closing_balance REAL NOT NULL DEFAULT 0, is_billwise_on TEXT NOT NULL DEFAULT '', gst_applicable TEXT NOT NULL DEFAULT '', synced_at_utc TEXT NOT NULL, payload_json TEXT NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tally_voucher_types (
|
||||||
|
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 '', numbering_method TEXT NOT NULL DEFAULT '', synced_at_utc TEXT NOT NULL, payload_json TEXT NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tally_stock_groups (
|
||||||
|
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 '', synced_at_utc TEXT NOT NULL, payload_json TEXT NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tally_stock_categories (
|
||||||
|
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 '', synced_at_utc TEXT NOT NULL, payload_json TEXT NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tally_stock_items (
|
||||||
|
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 '', base_units TEXT NOT NULL DEFAULT '', hsn_code TEXT NOT NULL DEFAULT '', synced_at_utc TEXT NOT NULL, payload_json TEXT NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tally_units (
|
||||||
|
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, original_name TEXT NOT NULL DEFAULT '', synced_at_utc TEXT NOT NULL, payload_json TEXT NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tally_cost_categories (
|
||||||
|
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, synced_at_utc TEXT NOT NULL, payload_json TEXT NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tally_cost_centres (
|
||||||
|
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 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);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_tally_stock_groups_company ON tally_stock_groups(tally_guid, name);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_tally_stock_categories_company ON tally_stock_categories(tally_guid, name);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_tally_stock_items_company ON tally_stock_items(tally_guid, name);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_tally_units_company ON tally_units(tally_guid, name);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_tally_cost_categories_company ON tally_cost_categories(tally_guid, name);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_tally_cost_centres_company ON tally_cost_centres(tally_guid, name);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_tally_companies_last_seen
|
CREATE INDEX IF NOT EXISTS ix_tally_companies_last_seen
|
||||||
ON tally_companies(last_seen_at_utc);
|
ON tally_companies(last_seen_at_utc);
|
||||||
CREATE INDEX IF NOT EXISTS ix_tally_company_mapping_client_active
|
CREATE INDEX IF NOT EXISTS ix_tally_company_mapping_client_active
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
{% extends "ui/templates/base/layout.html" %}
|
{% extends "ui/templates/base/layout.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% if synced %}<div class="mb-4 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Tally accounting masters synchronized successfully.</div>{% endif %}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Accounting</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Accounting</p>
|
||||||
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Tally Company Mapping</h1>
|
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Tally Accounting Masters</h1>
|
||||||
<p class="mt-1 text-sm text-slate-500">Phase 2 permanently maps an ERP client or registration to a loaded TallyPrime company using the Tally GUID.</p>
|
<p class="mt-1 text-sm text-slate-500">Phase 3 permanently maps an ERP client or registration to a loaded TallyPrime company using the Tally GUID.</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}&refresh=1{% else %}?refresh=1{% endif %}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Refresh Tally Companies</a>
|
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}&refresh=1{% else %}?refresh=1{% endif %}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Refresh Tally Companies</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,7 +123,7 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="text-sm font-medium text-slate-700">
|
<label class="text-sm font-medium text-slate-700">
|
||||||
Loaded Tally Company
|
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">
|
<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 Tally company</option>
|
<option value="">Select Tally company</option>
|
||||||
{% for company in tally.companies %}
|
{% for company in tally.companies %}
|
||||||
@@ -138,7 +139,7 @@
|
|||||||
<input type="checkbox" name="allow_gstin_mismatch" value="true" class="mt-1 rounded border-slate-300" />
|
<input type="checkbox" name="allow_gstin_mismatch" value="true" class="mt-1 rounded border-slate-300" />
|
||||||
<span class="text-sm text-slate-700">
|
<span class="text-sm text-slate-700">
|
||||||
Allow GSTIN mismatch after manual verification.
|
Allow GSTIN mismatch after manual verification.
|
||||||
<span class="block text-xs text-slate-500">For a GSTIN registration, Phase 2 blocks a mapping when both ERP and Tally contain different GSTINs unless this override is explicitly selected.</span>
|
<span class="block text-xs text-slate-500">For a GSTIN registration, Phase 3 blocks a mapping when both ERP and Tally contain different GSTINs unless this override is explicitly selected.</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -149,6 +150,47 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if accounting and accounting.exists and tally and tally.connected %}
|
||||||
|
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold text-slate-900">Phase 3 · Accounting Master Sync</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Select the company that is currently open/loaded in TallyPrime. The Local Agent verifies that the selected company is already mapped to this ERP client before reading masters.</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-masters" class="mt-5 grid gap-4 lg:grid-cols-[1fr_auto] 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">This dropdown is populated live from the companies currently loaded/open in the local TallyPrime instance.</span>
|
||||||
|
</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 Accounting Masters</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{% set last_sync = accounting.latest_master_sync if accounting else none %}
|
||||||
|
<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">{{ last_sync.completed_at_utc if last_sync and last_sync.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">Company</div><div class="mt-1 text-sm font-medium text-slate-900">{{ last_sync.company_name if last_sync else '-' }}</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">{{ last_sync.rows_processed if last_sync else 0 }}</div></div>
|
||||||
|
<div class="rounded-xl border border-slate-200 p-3"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Status</div><div class="mt-1 text-sm font-medium text-slate-900">{{ last_sync.status|title if last_sync else '-' }}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-xl bg-slate-50 px-4 py-3 text-xs text-slate-600">
|
||||||
|
Synced into the selected client's .act database: Groups, Ledgers, Voucher Types, Stock Groups, Stock Categories, Stock Items, Units, Cost Categories and Cost Centres. No voucher or transaction data is imported in Phase 3.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{% if accounting and accounting.exists %}
|
{% if accounting and accounting.exists %}
|
||||||
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||||
<div class="border-b border-slate-100 px-5 py-4">
|
<div class="border-b border-slate-100 px-5 py-4">
|
||||||
@@ -236,7 +278,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">
|
<div class="rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">
|
||||||
Phase 2 only stores company mappings. Ledger masters, vouchers, inventory and Tally write-back remain disabled.
|
Phase 3 synchronizes accounting masters only. Voucher/transaction sync and Tally write-back remain disabled and are reserved for later phases.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ def tally_tool(
|
|||||||
initialized: int = 0,
|
initialized: int = 0,
|
||||||
mapped: int = 0,
|
mapped: int = 0,
|
||||||
unmapped: int = 0,
|
unmapped: int = 0,
|
||||||
|
synced: int = 0,
|
||||||
error: str = "",
|
error: str = "",
|
||||||
):
|
):
|
||||||
db = CommonSessionLocal()
|
db = CommonSessionLocal()
|
||||||
@@ -148,7 +149,7 @@ def tally_tool(
|
|||||||
try:
|
try:
|
||||||
response_data = request_agent_command(
|
response_data = request_agent_command(
|
||||||
node.node_code,
|
node.node_code,
|
||||||
"phase2_status",
|
"phase3_status",
|
||||||
payload,
|
payload,
|
||||||
timeout_seconds=20,
|
timeout_seconds=20,
|
||||||
)
|
)
|
||||||
@@ -163,7 +164,7 @@ def tally_tool(
|
|||||||
request,
|
request,
|
||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
title="Tally Company Mapping",
|
title="Tally Accounting Masters",
|
||||||
clients=clients,
|
clients=clients,
|
||||||
selected_client=selected_client,
|
selected_client=selected_client,
|
||||||
registrations=registrations,
|
registrations=registrations,
|
||||||
@@ -173,6 +174,7 @@ def tally_tool(
|
|||||||
initialized=bool(initialized),
|
initialized=bool(initialized),
|
||||||
mapped=bool(mapped),
|
mapped=bool(mapped),
|
||||||
unmapped=bool(unmapped),
|
unmapped=bool(unmapped),
|
||||||
|
synced=bool(synced),
|
||||||
command_error=command_error,
|
command_error=command_error,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
@@ -358,3 +360,51 @@ def unmap_tally_company(
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync-masters")
|
||||||
|
def sync_tally_masters(
|
||||||
|
request: Request,
|
||||||
|
client_id: int = Form(...),
|
||||||
|
tally_guid: str = Form(...),
|
||||||
|
csrf_token: str = Form(...),
|
||||||
|
):
|
||||||
|
validate_csrf(request, csrf_token)
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user, response = _require_partner(request, db, "accounting.tally.sync_masters")
|
||||||
|
if response:
|
||||||
|
return response
|
||||||
|
client, _clients, scope = _find_visible_client(db, request, user, client_id)
|
||||||
|
if not client:
|
||||||
|
return _denied()
|
||||||
|
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_masters",
|
||||||
|
{
|
||||||
|
"client_id": int(client.id),
|
||||||
|
"tally_guid": str(tally_guid or "").strip(),
|
||||||
|
"requested_by_user_id": int(user.id),
|
||||||
|
},
|
||||||
|
timeout_seconds=120,
|
||||||
|
)
|
||||||
|
if not result.get("ok"):
|
||||||
|
raise RuntimeError(str(result.get("error") or "Tally master 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&synced=1",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ PERMISSIONS = {
|
|||||||
"accounting.tally.view": "View Tally Accounting Tool",
|
"accounting.tally.view": "View Tally Accounting Tool",
|
||||||
"accounting.tally.connect": "Connect to Tally Through Local Agent",
|
"accounting.tally.connect": "Connect to Tally Through Local Agent",
|
||||||
"accounting.tally.map_company": "Map ERP Client or Registration to Tally Company",
|
"accounting.tally.map_company": "Map ERP Client or Registration to Tally Company",
|
||||||
|
"accounting.tally.sync_masters": "Synchronize Tally Accounting Masters",
|
||||||
"accounting.act.initialize": "Initialize Client Accounting ACT Storage",
|
"accounting.act.initialize": "Initialize Client Accounting ACT Storage",
|
||||||
|
|
||||||
"notice_cases.view": "View Notice and Case Management",
|
"notice_cases.view": "View Notice and Case Management",
|
||||||
@@ -179,3 +180,4 @@ def expand_permission_codes(code: str) -> list[str]:
|
|||||||
codes.append(alias)
|
codes.append(alias)
|
||||||
return codes
|
return codes
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,9 @@ import io
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
ERP_LOCAL_AGENT_VERSION = "1.3.0"
|
ERP_LOCAL_AGENT_VERSION = "1.4.0"
|
||||||
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
||||||
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
||||||
|
|
||||||
# ZIP metadata is part of the ZIP byte stream. If writestr() is allowed to use
|
|
||||||
# the current clock time, two packages built from identical source files have
|
|
||||||
# different SHA256 hashes. Keep update packages reproducible so the SHA256
|
|
||||||
# returned by update-manifest is exactly the SHA256 of update-package.
|
|
||||||
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
@@ -23,24 +18,11 @@ def build_agent_env(*, erp_base_url: str, node_code: str, node_secret: str, stor
|
|||||||
erp_base_url = "https://" + erp_base_url[7:]
|
erp_base_url = "https://" + erp_base_url[7:]
|
||||||
storage_root = (storage_root or r"D:\AuditFirmStorage").strip()
|
storage_root = (storage_root or r"D:\AuditFirmStorage").strip()
|
||||||
return (
|
return (
|
||||||
f"ERP_BASE_URL={erp_base_url}\n"
|
f"ERP_BASE_URL={erp_base_url}\n" f"NODE_CODE={(node_code or '').strip()}\n" f"NODE_SECRET={(node_secret or '').strip()}\n"
|
||||||
f"NODE_CODE={(node_code or '').strip()}\n"
|
f"STORAGE_ROOT={storage_root}\n" f"TENANT_ID={'' if tenant_id is None else tenant_id}\n" f"AUDIT_FIRM_ID={'' if tenant_id is None else tenant_id}\n"
|
||||||
f"NODE_SECRET={(node_secret or '').strip()}\n"
|
f"BRANCH_ID={'' if branch_id is None else branch_id}\n" f"SYNC_INTERVAL_SECONDS={int(sync_interval_seconds or 30)}\n" f"POLL_INTERVAL_SECONDS={int(sync_interval_seconds or 30)}\n"
|
||||||
f"STORAGE_ROOT={storage_root}\n"
|
f"REQUEST_TIMEOUT_SECONDS={int(request_timeout_seconds or 60)}\n" f"TUNNEL_ENABLED={str(bool(tunnel_enabled)).lower()}\n" f"TUNNEL_RECONNECT_SECONDS={int(tunnel_reconnect_seconds or 10)}\n"
|
||||||
f"TENANT_ID={'' if tenant_id is None else tenant_id}\n"
|
"AUTO_UPDATE=true\nAUTO_INSTALL_UPDATES=false\nUPDATE_CHECK_INTERVAL_SECONDS=300\nDASHBOARD_ENABLED=true\nDASHBOARD_HOST=127.0.0.1\nDASHBOARD_PORT=8788\n"
|
||||||
f"AUDIT_FIRM_ID={'' if tenant_id is None else tenant_id}\n"
|
|
||||||
f"BRANCH_ID={'' if branch_id is None else branch_id}\n"
|
|
||||||
f"SYNC_INTERVAL_SECONDS={int(sync_interval_seconds or 30)}\n"
|
|
||||||
f"POLL_INTERVAL_SECONDS={int(sync_interval_seconds or 30)}\n"
|
|
||||||
f"REQUEST_TIMEOUT_SECONDS={int(request_timeout_seconds or 60)}\n"
|
|
||||||
f"TUNNEL_ENABLED={str(bool(tunnel_enabled)).lower()}\n"
|
|
||||||
f"TUNNEL_RECONNECT_SECONDS={int(tunnel_reconnect_seconds or 10)}\n"
|
|
||||||
"AUTO_UPDATE=true\n"
|
|
||||||
"AUTO_INSTALL_UPDATES=false\n"
|
|
||||||
"UPDATE_CHECK_INTERVAL_SECONDS=300\n"
|
|
||||||
"DASHBOARD_ENABLED=true\n"
|
|
||||||
"DASHBOARD_HOST=127.0.0.1\n"
|
|
||||||
"DASHBOARD_PORT=8788\n"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -48,8 +30,6 @@ def _zip_info(name: str) -> zipfile.ZipInfo:
|
|||||||
info = zipfile.ZipInfo(filename=name, date_time=_DETERMINISTIC_ZIP_TIMESTAMP)
|
info = zipfile.ZipInfo(filename=name, date_time=_DETERMINISTIC_ZIP_TIMESTAMP)
|
||||||
info.compress_type = zipfile.ZIP_DEFLATED
|
info.compress_type = zipfile.ZIP_DEFLATED
|
||||||
info.create_system = 3
|
info.create_system = 3
|
||||||
# Regular file with rw-r--r-- permissions. Stable metadata keeps ZIP bytes
|
|
||||||
# reproducible on every request and across Linux deployments.
|
|
||||||
info.external_attr = (0o100644 & 0xFFFF) << 16
|
info.external_attr = (0o100644 & 0xFFFF) << 16
|
||||||
return info
|
return info
|
||||||
|
|
||||||
@@ -61,50 +41,28 @@ def _write_zip_bytes(dst: zipfile.ZipFile, name: str, payload: bytes) -> None:
|
|||||||
def _build_zip(*, env_text: str | None, include_env: bool, include_admin_readme: bool) -> bytes:
|
def _build_zip(*, env_text: str | None, include_env: bool, include_admin_readme: bool) -> bytes:
|
||||||
if not RUNTIME_ROOT.exists():
|
if not RUNTIME_ROOT.exists():
|
||||||
raise RuntimeError(f"ERP Local Agent runtime is missing: {RUNTIME_ROOT}")
|
raise RuntimeError(f"ERP Local Agent runtime is missing: {RUNTIME_ROOT}")
|
||||||
|
|
||||||
buffer = io.BytesIO()
|
buffer = io.BytesIO()
|
||||||
with zipfile.ZipFile(
|
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as dst:
|
||||||
buffer,
|
|
||||||
"w",
|
|
||||||
compression=zipfile.ZIP_DEFLATED,
|
|
||||||
compresslevel=9,
|
|
||||||
) as dst:
|
|
||||||
for path in sorted(RUNTIME_ROOT.rglob("*"), key=lambda item: item.as_posix()):
|
for path in sorted(RUNTIME_ROOT.rglob("*"), key=lambda item: item.as_posix()):
|
||||||
if not path.is_file() or "__pycache__" in path.parts:
|
if not path.is_file() or "__pycache__" in path.parts:
|
||||||
continue
|
continue
|
||||||
name = path.relative_to(RUNTIME_ROOT).as_posix()
|
_write_zip_bytes(dst, path.relative_to(RUNTIME_ROOT).as_posix(), path.read_bytes())
|
||||||
_write_zip_bytes(dst, name, path.read_bytes())
|
|
||||||
|
|
||||||
if include_env and env_text is not None:
|
if include_env and env_text is not None:
|
||||||
_write_zip_bytes(dst, ".env", env_text.encode("utf-8"))
|
_write_zip_bytes(dst, ".env", env_text.encode("utf-8"))
|
||||||
|
|
||||||
if include_admin_readme:
|
if include_admin_readme:
|
||||||
readme = (
|
text = (
|
||||||
f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n"
|
f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n"
|
||||||
"Existing storage, WebSocket tunnel, Tally, accounting .act and local dashboard functionality are preserved.\n"
|
"Existing storage, WebSocket tunnel, dashboard, Tally mapping and client .act functionality are preserved.\n"
|
||||||
"Local dashboard: http://127.0.0.1:8788\n"
|
"Phase 3 adds read-only Tally accounting master synchronization.\n"
|
||||||
"The agent checks for updates automatically but installation is always initiated by the local user.\n"
|
"Dashboard: http://127.0.0.1:8788\n"
|
||||||
"The existing .env, data, logs, .venv and client storage are preserved during updates.\n"
|
|
||||||
)
|
)
|
||||||
_write_zip_bytes(dst, "README_ERP_LOCAL_AGENT.txt", readme.encode("utf-8"))
|
_write_zip_bytes(dst, "README_ERP_LOCAL_AGENT.txt", text.encode("utf-8"))
|
||||||
|
|
||||||
return buffer.getvalue()
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def build_preconfigured_agent_zip(*, env_text: str, include_admin_readme: bool = False) -> bytes:
|
def build_preconfigured_agent_zip(*, env_text: str, include_admin_readme: bool = False) -> bytes:
|
||||||
return _build_zip(
|
return _build_zip(env_text=env_text, include_env=True, include_admin_readme=include_admin_readme)
|
||||||
env_text=env_text,
|
|
||||||
include_env=True,
|
|
||||||
include_admin_readme=include_admin_readme,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_agent_update_zip() -> bytes:
|
def build_agent_update_zip() -> bytes:
|
||||||
# Update package deliberately excludes .env, .venv, data, logs and client files.
|
return _build_zip(env_text=None, include_env=False, include_admin_readme=False)
|
||||||
# The ZIP is deterministic so update-manifest and update-package always agree
|
|
||||||
# on SHA256 for the same deployed runtime/version.
|
|
||||||
return _build_zip(
|
|
||||||
env_text=None,
|
|
||||||
include_env=False,
|
|
||||||
include_admin_readme=False,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
ERP Local Agent 1.3.0
|
ERP Local Agent 1.4.0
|
||||||
|
|
||||||
Existing storage, WebSocket tunnel, Tally and client .act functionality are preserved.
|
Existing storage, WebSocket tunnel, Tally and client .act functionality are preserved.
|
||||||
|
|
||||||
@@ -18,3 +18,5 @@ Local operational database:
|
|||||||
Client accounting .act databases remain separate under the configured STORAGE_ROOT.
|
Client accounting .act databases remain separate under the configured STORAGE_ROOT.
|
||||||
|
|
||||||
Phase 2: client/registration to Tally company mapping is supported using Tally GUID.
|
Phase 2: client/registration to Tally company mapping is supported using Tally GUID.
|
||||||
|
|
||||||
|
Phase 3: read-only Tally accounting master sync (Groups, Ledgers, Voucher Types, Stock Groups/Categories/Items, Units and Cost Centres/Categories) into client .act storage.
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
__version__ = "1.3.0"
|
__version__ = "1.4.0"
|
||||||
AGENT_NAME = "ERP Local Agent"
|
AGENT_NAME = "ERP Local Agent"
|
||||||
|
|||||||
@@ -7,7 +7,19 @@ import sqlite3
|
|||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
|
|
||||||
SCHEMA_VERSION = "2"
|
SCHEMA_VERSION = "3"
|
||||||
|
|
||||||
|
MASTER_TABLES = {
|
||||||
|
"groups": "tally_groups",
|
||||||
|
"ledgers": "tally_ledgers",
|
||||||
|
"voucher_types": "tally_voucher_types",
|
||||||
|
"stock_groups": "tally_stock_groups",
|
||||||
|
"stock_categories": "tally_stock_categories",
|
||||||
|
"stock_items": "tally_stock_items",
|
||||||
|
"units": "tally_units",
|
||||||
|
"cost_categories": "tally_cost_categories",
|
||||||
|
"cost_centres": "tally_cost_centres",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _utc_now_iso() -> str:
|
def _utc_now_iso() -> str:
|
||||||
@@ -15,11 +27,10 @@ def _utc_now_iso() -> str:
|
|||||||
|
|
||||||
|
|
||||||
class LocalAccountingStore:
|
class LocalAccountingStore:
|
||||||
"""Client-scoped SQLite .act storage under the existing branch storage root.
|
"""Client-scoped SQLite .act accounting store.
|
||||||
|
|
||||||
Phase 2 persists ERP client/registration -> Tally company mappings using the
|
Phase 3 preserves Phase 1/2 metadata and mappings and adds company-scoped
|
||||||
Tally GUID as the durable identifier. Existing Phase 1 databases are upgraded
|
read-only Tally master snapshots. Existing .act files are upgraded in place.
|
||||||
in place without deleting accounting history.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, storage_root: Path):
|
def __init__(self, storage_root: Path):
|
||||||
@@ -43,7 +54,7 @@ class LocalAccountingStore:
|
|||||||
def connect(self, client_id: int):
|
def connect(self, client_id: int):
|
||||||
path = self.db_path(client_id)
|
path = self.db_path(client_id)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
db = sqlite3.connect(path, timeout=30)
|
db = sqlite3.connect(path, timeout=60)
|
||||||
db.row_factory = sqlite3.Row
|
db.row_factory = sqlite3.Row
|
||||||
db.execute("PRAGMA foreign_keys=ON")
|
db.execute("PRAGMA foreign_keys=ON")
|
||||||
db.execute("PRAGMA journal_mode=WAL")
|
db.execute("PRAGMA journal_mode=WAL")
|
||||||
@@ -69,81 +80,102 @@ class LocalAccountingStore:
|
|||||||
if name not in columns:
|
if name not in columns:
|
||||||
db.execute(f"ALTER TABLE tally_company_mapping ADD COLUMN {name} {ddl}")
|
db.execute(f"ALTER TABLE tally_company_mapping ADD COLUMN {name} {ddl}")
|
||||||
|
|
||||||
def initialize(
|
@staticmethod
|
||||||
self,
|
def _master_table_ddl(table: str) -> str:
|
||||||
client_id: int,
|
return f"""
|
||||||
client_name: str = "",
|
CREATE TABLE IF NOT EXISTS {table} (
|
||||||
tenant_id: int | None = None,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
created_by_user_id: int | None = None,
|
tally_guid TEXT NOT NULL,
|
||||||
) -> Path:
|
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 '',
|
||||||
|
reserved_name TEXT NOT NULL DEFAULT '',
|
||||||
|
base_units TEXT NOT NULL DEFAULT '',
|
||||||
|
additional_units TEXT NOT NULL DEFAULT '',
|
||||||
|
original_name TEXT NOT NULL DEFAULT '',
|
||||||
|
opening_balance REAL NOT NULL DEFAULT 0,
|
||||||
|
closing_balance REAL NOT NULL DEFAULT 0,
|
||||||
|
opening_value REAL NOT NULL DEFAULT 0,
|
||||||
|
opening_rate TEXT NOT NULL DEFAULT '',
|
||||||
|
numbering_method TEXT NOT NULL DEFAULT '',
|
||||||
|
tax_type TEXT NOT NULL DEFAULT '',
|
||||||
|
gst_applicable TEXT NOT NULL DEFAULT '',
|
||||||
|
gst_registration_type TEXT NOT NULL DEFAULT '',
|
||||||
|
gst_type_of_supply TEXT NOT NULL DEFAULT '',
|
||||||
|
hsn_code TEXT NOT NULL DEFAULT '',
|
||||||
|
is_revenue TEXT NOT NULL DEFAULT '',
|
||||||
|
is_deemed_positive TEXT NOT NULL DEFAULT '',
|
||||||
|
is_billwise_on TEXT NOT NULL DEFAULT '',
|
||||||
|
is_simple_unit TEXT NOT NULL DEFAULT '',
|
||||||
|
conversion TEXT NOT NULL DEFAULT '',
|
||||||
|
is_active TEXT NOT NULL DEFAULT '',
|
||||||
|
synced_at_utc TEXT NOT NULL,
|
||||||
|
payload_json TEXT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_{table}_company ON {table}(tally_guid, name);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_{table}_master_guid ON {table}(tally_guid, master_guid);
|
||||||
|
"""
|
||||||
|
|
||||||
|
def initialize(self, client_id: int, client_name: str = "", tenant_id: int | None = None, created_by_user_id: int | None = None) -> Path:
|
||||||
path = self.db_path(client_id)
|
path = self.db_path(client_id)
|
||||||
with self.connect(client_id) as db:
|
with self.connect(client_id) as db:
|
||||||
db.executescript(
|
db.executescript(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS act_meta (
|
CREATE TABLE IF NOT EXISTS act_meta (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at_utc TEXT NOT NULL
|
||||||
value TEXT NOT NULL,
|
|
||||||
updated_at_utc TEXT NOT NULL
|
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS tally_companies (
|
CREATE TABLE IF NOT EXISTS tally_companies (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
tally_guid TEXT NOT NULL DEFAULT '',
|
tally_guid TEXT NOT NULL DEFAULT '', company_name TEXT NOT NULL,
|
||||||
company_name TEXT NOT NULL,
|
gstin TEXT NOT NULL DEFAULT '', first_seen_at_utc TEXT NOT NULL,
|
||||||
gstin TEXT NOT NULL DEFAULT '',
|
last_seen_at_utc TEXT NOT NULL, is_currently_loaded INTEGER NOT NULL DEFAULT 0
|
||||||
first_seen_at_utc TEXT NOT NULL,
|
|
||||||
last_seen_at_utc TEXT NOT NULL,
|
|
||||||
is_currently_loaded INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS ix_tally_companies_guid ON tally_companies(tally_guid);
|
CREATE INDEX IF NOT EXISTS ix_tally_companies_guid ON tally_companies(tally_guid);
|
||||||
CREATE INDEX IF NOT EXISTS ix_tally_companies_name ON tally_companies(company_name);
|
CREATE INDEX IF NOT EXISTS ix_tally_companies_name ON tally_companies(company_name);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tally_company_mapping (
|
CREATE TABLE IF NOT EXISTS tally_company_mapping (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT, client_id INTEGER NOT NULL,
|
||||||
client_id INTEGER NOT NULL,
|
registration_id INTEGER NULL, registration_type_code TEXT NOT NULL DEFAULT '',
|
||||||
registration_id INTEGER NULL,
|
registration_number TEXT NOT NULL DEFAULT '', registration_legal_name TEXT NOT NULL DEFAULT '',
|
||||||
registration_type_code TEXT NOT NULL DEFAULT '',
|
registration_trade_name TEXT NOT NULL DEFAULT '', business_unit_id INTEGER,
|
||||||
registration_number TEXT NOT NULL DEFAULT '',
|
client_branch_id INTEGER, tally_guid TEXT NOT NULL, company_name TEXT NOT NULL,
|
||||||
registration_legal_name TEXT NOT NULL DEFAULT '',
|
gstin TEXT NOT NULL DEFAULT '', is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
registration_trade_name TEXT NOT NULL DEFAULT '',
|
created_at_utc TEXT NOT NULL, updated_at_utc TEXT NOT NULL,
|
||||||
business_unit_id INTEGER,
|
mapped_by_user_id INTEGER, unmapped_at_utc TEXT, unmapped_by_user_id INTEGER
|
||||||
client_branch_id INTEGER,
|
|
||||||
tally_guid TEXT NOT NULL,
|
|
||||||
company_name TEXT NOT NULL,
|
|
||||||
gstin TEXT NOT NULL DEFAULT '',
|
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at_utc TEXT NOT NULL,
|
|
||||||
updated_at_utc TEXT NOT NULL,
|
|
||||||
mapped_by_user_id INTEGER,
|
|
||||||
unmapped_at_utc TEXT,
|
|
||||||
unmapped_by_user_id INTEGER
|
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS ix_tally_company_mapping_client_active
|
CREATE INDEX IF NOT EXISTS ix_tally_company_mapping_client_active ON tally_company_mapping(client_id, is_active);
|
||||||
ON tally_company_mapping(client_id, is_active);
|
CREATE INDEX IF NOT EXISTS ix_tally_company_mapping_guid ON tally_company_mapping(tally_guid, is_active);
|
||||||
CREATE INDEX IF NOT EXISTS ix_tally_company_mapping_guid
|
|
||||||
ON tally_company_mapping(tally_guid, is_active);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tally_connection_history (
|
CREATE TABLE IF NOT EXISTS tally_connection_history (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT, checked_at_utc TEXT NOT NULL,
|
||||||
checked_at_utc TEXT NOT NULL,
|
connected INTEGER NOT NULL, tally_url TEXT NOT NULL DEFAULT '',
|
||||||
connected INTEGER NOT NULL,
|
company_count INTEGER NOT NULL DEFAULT 0, error_message TEXT NULL, payload_json TEXT NULL
|
||||||
tally_url TEXT NOT NULL DEFAULT '',
|
|
||||||
company_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
error_message TEXT NULL,
|
|
||||||
payload_json TEXT NULL
|
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS tally_sync_runs (
|
CREATE TABLE IF NOT EXISTS tally_sync_runs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
sync_type TEXT NOT NULL,
|
sync_type TEXT NOT NULL, tally_guid TEXT NOT NULL DEFAULT '', company_name TEXT NOT NULL DEFAULT '',
|
||||||
status TEXT NOT NULL,
|
mapping_id INTEGER, requested_by_user_id INTEGER,
|
||||||
started_at_utc TEXT NOT NULL,
|
status TEXT NOT NULL, started_at_utc TEXT NOT NULL, completed_at_utc TEXT NULL,
|
||||||
completed_at_utc TEXT NULL,
|
rows_processed INTEGER NOT NULL DEFAULT 0, error_message TEXT NULL, details_json TEXT NULL
|
||||||
rows_processed INTEGER NOT NULL DEFAULT 0,
|
|
||||||
error_message TEXT NULL,
|
|
||||||
details_json TEXT NULL
|
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
self._ensure_mapping_columns(db)
|
self._ensure_mapping_columns(db)
|
||||||
|
# Upgrade older tally_sync_runs created in Phase 1 without dropping history.
|
||||||
|
sync_columns = {row["name"] for row in db.execute("PRAGMA table_info(tally_sync_runs)").fetchall()}
|
||||||
|
for name, ddl in {
|
||||||
|
"tally_guid": "TEXT NOT NULL DEFAULT ''",
|
||||||
|
"company_name": "TEXT NOT NULL DEFAULT ''",
|
||||||
|
"mapping_id": "INTEGER",
|
||||||
|
"requested_by_user_id": "INTEGER",
|
||||||
|
}.items():
|
||||||
|
if name not in sync_columns:
|
||||||
|
db.execute(f"ALTER TABLE tally_sync_runs ADD COLUMN {name} {ddl}")
|
||||||
|
for table in MASTER_TABLES.values():
|
||||||
|
db.executescript(self._master_table_ddl(table))
|
||||||
|
|
||||||
now = _utc_now_iso()
|
now = _utc_now_iso()
|
||||||
meta = {
|
meta = {
|
||||||
"schema_version": SCHEMA_VERSION,
|
"schema_version": SCHEMA_VERSION,
|
||||||
@@ -175,8 +207,7 @@ class LocalAccountingStore:
|
|||||||
guid = str(company.get("guid") or "").strip()
|
guid = str(company.get("guid") or "").strip()
|
||||||
gstin = str(company.get("gstin") or "").strip().upper()
|
gstin = str(company.get("gstin") or "").strip().upper()
|
||||||
existing = db.execute(
|
existing = db.execute(
|
||||||
"SELECT id FROM tally_companies WHERE tally_guid=? AND company_name=? LIMIT 1",
|
"SELECT id FROM tally_companies WHERE tally_guid=? AND company_name=? LIMIT 1", (guid, name)
|
||||||
(guid, name),
|
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
db.execute(
|
db.execute(
|
||||||
@@ -192,26 +223,10 @@ class LocalAccountingStore:
|
|||||||
db.execute(
|
db.execute(
|
||||||
"""INSERT INTO tally_connection_history(checked_at_utc, connected, tally_url, company_count, error_message, payload_json)
|
"""INSERT INTO tally_connection_history(checked_at_utc, connected, tally_url, company_count, error_message, payload_json)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||||
(
|
(now, 1 if status.get("connected") else 0, str(status.get("url") or ""), int(status.get("company_count") or 0), str(status.get("error") or "") or None, json.dumps(status, ensure_ascii=False, separators=(",", ":"))),
|
||||||
now,
|
|
||||||
1 if status.get("connected") else 0,
|
|
||||||
str(status.get("url") or ""),
|
|
||||||
int(status.get("company_count") or 0),
|
|
||||||
str(status.get("error") or "") or None,
|
|
||||||
json.dumps(status, ensure_ascii=False, separators=(",", ":")),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def map_company(
|
def map_company(self, client_id: int, *, tally_guid: str, company_name: str, gstin: str = "", registration: dict | None = None, mapped_by_user_id: int | None = None) -> dict:
|
||||||
self,
|
|
||||||
client_id: int,
|
|
||||||
*,
|
|
||||||
tally_guid: str,
|
|
||||||
company_name: str,
|
|
||||||
gstin: str = "",
|
|
||||||
registration: dict | None = None,
|
|
||||||
mapped_by_user_id: int | None = None,
|
|
||||||
) -> dict:
|
|
||||||
if not self.exists(client_id):
|
if not self.exists(client_id):
|
||||||
raise ValueError("Accounting storage is not initialized for this client.")
|
raise ValueError("Accounting storage is not initialized for this client.")
|
||||||
guid = str(tally_guid or "").strip()
|
guid = str(tally_guid or "").strip()
|
||||||
@@ -220,136 +235,145 @@ class LocalAccountingStore:
|
|||||||
raise ValueError("Tally company GUID is required for permanent mapping.")
|
raise ValueError("Tally company GUID is required for permanent mapping.")
|
||||||
if not name:
|
if not name:
|
||||||
raise ValueError("Tally company name is required.")
|
raise ValueError("Tally company name is required.")
|
||||||
|
|
||||||
registration = registration or {}
|
registration = registration or {}
|
||||||
registration_id = registration.get("id")
|
registration_id = registration.get("id")
|
||||||
registration_id = int(registration_id) if registration_id not in (None, "") else None
|
registration_id = int(registration_id) if registration_id not in (None, "") else None
|
||||||
now = _utc_now_iso()
|
now = _utc_now_iso()
|
||||||
|
|
||||||
with self.connect(client_id) as db:
|
with self.connect(client_id) as db:
|
||||||
if registration_id is None:
|
if registration_id is None:
|
||||||
db.execute(
|
db.execute("UPDATE tally_company_mapping SET is_active=0, updated_at_utc=?, unmapped_at_utc=? WHERE client_id=? AND registration_id IS NULL AND is_active=1", (now, now, int(client_id)))
|
||||||
"""UPDATE tally_company_mapping
|
|
||||||
SET is_active=0, updated_at_utc=?, unmapped_at_utc=?
|
|
||||||
WHERE client_id=? AND registration_id IS NULL AND is_active=1""",
|
|
||||||
(now, now, int(client_id)),
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
db.execute(
|
db.execute("UPDATE tally_company_mapping SET is_active=0, updated_at_utc=?, unmapped_at_utc=? WHERE client_id=? AND registration_id=? AND is_active=1", (now, now, int(client_id), registration_id))
|
||||||
"""UPDATE tally_company_mapping
|
db.execute("UPDATE tally_company_mapping SET is_active=0, updated_at_utc=?, unmapped_at_utc=? WHERE client_id=? AND tally_guid=? AND is_active=1", (now, now, int(client_id), guid))
|
||||||
SET is_active=0, updated_at_utc=?, unmapped_at_utc=?
|
|
||||||
WHERE client_id=? AND registration_id=? AND is_active=1""",
|
|
||||||
(now, now, int(client_id), registration_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""UPDATE tally_company_mapping
|
|
||||||
SET is_active=0, updated_at_utc=?, unmapped_at_utc=?
|
|
||||||
WHERE client_id=? AND tally_guid=? AND is_active=1""",
|
|
||||||
(now, now, int(client_id), guid),
|
|
||||||
)
|
|
||||||
|
|
||||||
cur = db.execute(
|
cur = db.execute(
|
||||||
"""INSERT INTO tally_company_mapping(
|
"""INSERT INTO tally_company_mapping(client_id, registration_id, registration_type_code, registration_number,
|
||||||
client_id, registration_id, registration_type_code,
|
registration_legal_name, registration_trade_name, business_unit_id, client_branch_id,
|
||||||
registration_number, registration_legal_name,
|
tally_guid, company_name, gstin, is_active, created_at_utc, updated_at_utc, mapped_by_user_id)
|
||||||
registration_trade_name, business_unit_id, client_branch_id,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)""",
|
||||||
tally_guid, company_name, gstin, is_active,
|
(int(client_id), registration_id, str(registration.get("registration_type_code") or ""), str(registration.get("registration_number") or ""), str(registration.get("legal_name") or ""), str(registration.get("trade_name") or ""), registration.get("business_unit_id"), registration.get("client_branch_id"), guid, name, str(gstin or "").strip().upper(), now, now, mapped_by_user_id),
|
||||||
created_at_utc, updated_at_utc, mapped_by_user_id
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)""",
|
|
||||||
(
|
|
||||||
int(client_id),
|
|
||||||
registration_id,
|
|
||||||
str(registration.get("registration_type_code") or ""),
|
|
||||||
str(registration.get("registration_number") or ""),
|
|
||||||
str(registration.get("legal_name") or ""),
|
|
||||||
str(registration.get("trade_name") or ""),
|
|
||||||
registration.get("business_unit_id"),
|
|
||||||
registration.get("client_branch_id"),
|
|
||||||
guid,
|
|
||||||
name,
|
|
||||||
str(gstin or "").strip().upper(),
|
|
||||||
now,
|
|
||||||
now,
|
|
||||||
mapped_by_user_id,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
mapping_id = int(cur.lastrowid)
|
mapping_id = int(cur.lastrowid)
|
||||||
return self.get_mapping(client_id, mapping_id)
|
return self.get_mapping(client_id, mapping_id)
|
||||||
|
|
||||||
def get_mapping(self, client_id: int, mapping_id: int) -> dict:
|
def get_mapping(self, client_id: int, mapping_id: int) -> dict:
|
||||||
with self.connect(client_id) as db:
|
with self.connect(client_id) as db:
|
||||||
row = db.execute(
|
row = db.execute("SELECT * FROM tally_company_mapping WHERE id=? AND client_id=? LIMIT 1", (int(mapping_id), int(client_id))).fetchone()
|
||||||
"SELECT * FROM tally_company_mapping WHERE id=? AND client_id=? LIMIT 1",
|
|
||||||
(int(mapping_id), int(client_id)),
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
if not row:
|
||||||
raise ValueError("Tally company mapping was not found.")
|
raise ValueError("Tally company mapping was not found.")
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
|
def get_active_mapping_by_guid(self, client_id: int, tally_guid: str) -> dict:
|
||||||
|
with self.connect(client_id) as db:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT * FROM tally_company_mapping WHERE client_id=? AND tally_guid=? AND is_active=1 ORDER BY id DESC LIMIT 1",
|
||||||
|
(int(client_id), str(tally_guid or "").strip()),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("The selected open Tally company is not mapped to this ERP client. Complete Phase 2 mapping first.")
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
def unmap_company(self, client_id: int, mapping_id: int, unmapped_by_user_id: int | None = None) -> dict:
|
def unmap_company(self, client_id: int, mapping_id: int, unmapped_by_user_id: int | None = None) -> dict:
|
||||||
if not self.exists(client_id):
|
if not self.exists(client_id):
|
||||||
raise ValueError("Accounting storage is not initialized for this client.")
|
raise ValueError("Accounting storage is not initialized for this client.")
|
||||||
now = _utc_now_iso()
|
now = _utc_now_iso()
|
||||||
with self.connect(client_id) as db:
|
with self.connect(client_id) as db:
|
||||||
row = db.execute(
|
row = db.execute("SELECT id FROM tally_company_mapping WHERE id=? AND client_id=? AND is_active=1", (int(mapping_id), int(client_id))).fetchone()
|
||||||
"SELECT id FROM tally_company_mapping WHERE id=? AND client_id=? AND is_active=1",
|
|
||||||
(int(mapping_id), int(client_id)),
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
if not row:
|
||||||
raise ValueError("Active Tally company mapping was not found.")
|
raise ValueError("Active Tally company mapping was not found.")
|
||||||
db.execute(
|
db.execute("UPDATE tally_company_mapping SET is_active=0, updated_at_utc=?, unmapped_at_utc=?, unmapped_by_user_id=? WHERE id=? AND client_id=?", (now, now, unmapped_by_user_id, int(mapping_id), int(client_id)))
|
||||||
"""UPDATE tally_company_mapping
|
|
||||||
SET is_active=0, updated_at_utc=?, unmapped_at_utc=?, unmapped_by_user_id=?
|
|
||||||
WHERE id=? AND client_id=?""",
|
|
||||||
(now, now, unmapped_by_user_id, int(mapping_id), int(client_id)),
|
|
||||||
)
|
|
||||||
return {"mapping_id": int(mapping_id), "unmapped": True}
|
return {"mapping_id": int(mapping_id), "unmapped": True}
|
||||||
|
|
||||||
|
def replace_master_snapshot(self, client_id: int, *, mapping: dict, masters: dict[str, list[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"))
|
||||||
|
if not tally_guid or not company_name:
|
||||||
|
raise ValueError("Active Tally mapping is incomplete.")
|
||||||
|
started = _utc_now_iso()
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
run_id = None
|
||||||
|
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)
|
||||||
|
VALUES ('masters', ?, ?, ?, ?, 'running', ?, 0)""",
|
||||||
|
(tally_guid, company_name, mapping_id, requested_by_user_id, started),
|
||||||
|
)
|
||||||
|
run_id = int(cur.lastrowid)
|
||||||
|
total = 0
|
||||||
|
synced_at = _utc_now_iso()
|
||||||
|
for key, table in MASTER_TABLES.items():
|
||||||
|
rows = masters.get(key) or []
|
||||||
|
db.execute(f"DELETE FROM {table} WHERE tally_guid=?", (tally_guid,))
|
||||||
|
for row in rows:
|
||||||
|
payload = json.dumps(row, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
db.execute(
|
||||||
|
f"""INSERT INTO {table}(
|
||||||
|
tally_guid, company_name, master_guid, name, parent, category, reserved_name,
|
||||||
|
base_units, additional_units, original_name, opening_balance, closing_balance,
|
||||||
|
opening_value, opening_rate, numbering_method, tax_type, gst_applicable,
|
||||||
|
gst_registration_type, gst_type_of_supply, hsn_code, is_revenue,
|
||||||
|
is_deemed_positive, is_billwise_on, is_simple_unit, conversion, is_active,
|
||||||
|
synced_at_utc, payload_json
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(tally_guid, company_name, str(row.get("guid") or ""), str(row.get("name") or ""), str(row.get("parent") or ""), str(row.get("category") or ""), str(row.get("reserved_name") or ""), str(row.get("base_units") or ""), str(row.get("additional_units") or ""), str(row.get("original_name") or ""), float(row.get("opening_balance") or 0), float(row.get("closing_balance") or 0), float(row.get("opening_value") or 0), str(row.get("opening_rate") or ""), str(row.get("numbering_method") or ""), str(row.get("tax_type") or ""), str(row.get("gst_applicable") or ""), str(row.get("gst_registration_type") or ""), str(row.get("gst_type_of_supply") or ""), str(row.get("hsn_code") or ""), str(row.get("is_revenue") or ""), str(row.get("is_deemed_positive") or ""), str(row.get("is_billwise_on") or ""), str(row.get("is_simple_unit") or ""), str(row.get("conversion") or ""), str(row.get("is_active") or ""), synced_at, payload),
|
||||||
|
)
|
||||||
|
counts[key] = len(rows)
|
||||||
|
total += len(rows)
|
||||||
|
details = {"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, "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
|
||||||
|
|
||||||
|
def _master_counts(self, db: sqlite3.Connection, tally_guid: str) -> dict[str, int]:
|
||||||
|
result = {}
|
||||||
|
for key, table in MASTER_TABLES.items():
|
||||||
|
result[key] = int(db.execute(f"SELECT COUNT(*) FROM {table} WHERE tally_guid=?", (tally_guid,)).fetchone()[0])
|
||||||
|
return result
|
||||||
|
|
||||||
def snapshot(self, client_id: int) -> dict:
|
def snapshot(self, client_id: int) -> dict:
|
||||||
path = self.db_path(client_id)
|
path = self.db_path(client_id)
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return {
|
return {"exists": False, "db_path": str(path), "metadata": {}, "latest_connection": None, "companies": [], "mappings": [], "latest_master_sync": None}
|
||||||
"exists": False,
|
|
||||||
"db_path": str(path),
|
|
||||||
"metadata": {},
|
|
||||||
"latest_connection": None,
|
|
||||||
"companies": [],
|
|
||||||
"mappings": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
# initialize() upgrades older Phase 1 schema in-place
|
|
||||||
self.initialize(client_id)
|
self.initialize(client_id)
|
||||||
with self.connect(client_id) as db:
|
with self.connect(client_id) as db:
|
||||||
meta_rows = db.execute("SELECT key, value FROM act_meta ORDER BY key").fetchall()
|
meta_rows = db.execute("SELECT key, value FROM act_meta ORDER BY key").fetchall()
|
||||||
companies = db.execute(
|
companies = db.execute("SELECT tally_guid AS guid, company_name AS name, gstin, first_seen_at_utc, last_seen_at_utc, is_currently_loaded FROM tally_companies ORDER BY is_currently_loaded DESC, company_name COLLATE NOCASE").fetchall()
|
||||||
"""SELECT tally_guid AS guid, company_name AS name, gstin,
|
|
||||||
first_seen_at_utc, last_seen_at_utc, is_currently_loaded
|
|
||||||
FROM tally_companies
|
|
||||||
ORDER BY is_currently_loaded DESC, company_name COLLATE NOCASE"""
|
|
||||||
).fetchall()
|
|
||||||
mappings = db.execute(
|
mappings = db.execute(
|
||||||
"""SELECT id, client_id, registration_id, registration_type_code,
|
"""SELECT id, client_id, registration_id, registration_type_code, registration_number,
|
||||||
registration_number, registration_legal_name,
|
registration_legal_name, registration_trade_name, business_unit_id, client_branch_id,
|
||||||
registration_trade_name, business_unit_id, client_branch_id,
|
tally_guid, company_name, gstin, is_active, created_at_utc AS mapped_at_utc,
|
||||||
tally_guid, company_name, gstin, is_active,
|
updated_at_utc, mapped_by_user_id, unmapped_at_utc, unmapped_by_user_id
|
||||||
created_at_utc AS mapped_at_utc, updated_at_utc,
|
FROM tally_company_mapping WHERE is_active=1
|
||||||
mapped_by_user_id, unmapped_at_utc, unmapped_by_user_id
|
ORDER BY CASE WHEN registration_id IS NULL THEN 0 ELSE 1 END, registration_type_code, registration_number, id"""
|
||||||
FROM tally_company_mapping
|
|
||||||
WHERE is_active=1
|
|
||||||
ORDER BY CASE WHEN registration_id IS NULL THEN 0 ELSE 1 END,
|
|
||||||
registration_type_code, registration_number, id"""
|
|
||||||
).fetchall()
|
).fetchall()
|
||||||
latest = db.execute(
|
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()
|
||||||
"SELECT checked_at_utc, connected, tally_url, company_count, error_message FROM tally_connection_history ORDER BY id DESC LIMIT 1"
|
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()
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
loaded_guids = {str(row["guid"] or "") for row in companies if int(row["is_currently_loaded"] or 0)}
|
loaded_guids = {str(row["guid"] or "") for row in companies if int(row["is_currently_loaded"] or 0)}
|
||||||
mapped = []
|
mapped = []
|
||||||
for row in mappings:
|
for row in mappings:
|
||||||
item = dict(row)
|
item = dict(row)
|
||||||
item["currently_loaded"] = bool(item.get("tally_guid") and item["tally_guid"] in loaded_guids)
|
item["currently_loaded"] = bool(item.get("tally_guid") and item["tally_guid"] in loaded_guids)
|
||||||
|
item["master_counts"] = self._master_counts(db, str(item.get("tally_guid") or ""))
|
||||||
mapped.append(item)
|
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"):
|
||||||
|
try:
|
||||||
|
latest_sync_dict["details"] = json.loads(latest_sync_dict["details_json"])
|
||||||
|
except Exception:
|
||||||
|
latest_sync_dict["details"] = {}
|
||||||
return {
|
return {
|
||||||
"exists": True,
|
"exists": True,
|
||||||
"db_path": str(path),
|
"db_path": str(path),
|
||||||
@@ -357,4 +381,5 @@ class LocalAccountingStore:
|
|||||||
"latest_connection": dict(latest) if latest else None,
|
"latest_connection": dict(latest) if latest else None,
|
||||||
"companies": [dict(row) for row in companies],
|
"companies": [dict(row) for row in companies],
|
||||||
"mappings": mapped,
|
"mappings": mapped,
|
||||||
|
"latest_master_sync": latest_sync_dict,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class AgentCommandProcessor:
|
|||||||
error: str | None = None
|
error: str | None = None
|
||||||
ok = False
|
ok = False
|
||||||
try:
|
try:
|
||||||
if action in {"tally_status", "phase1_status", "phase2_status"}:
|
if action in {"tally_status", "phase1_status", "phase2_status", "phase3_status"}:
|
||||||
result = self._status(payload)
|
result = self._status(payload)
|
||||||
elif action == "accounting_initialize":
|
elif action == "accounting_initialize":
|
||||||
result = self._initialize(payload)
|
result = self._initialize(payload)
|
||||||
@@ -31,28 +31,21 @@ class AgentCommandProcessor:
|
|||||||
result = self._map_company(payload)
|
result = self._map_company(payload)
|
||||||
elif action == "accounting_unmap_company":
|
elif action == "accounting_unmap_company":
|
||||||
result = self._unmap_company(payload)
|
result = self._unmap_company(payload)
|
||||||
|
elif action == "accounting_sync_masters":
|
||||||
|
result = self._sync_masters(payload)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported local-agent command: {action}")
|
raise ValueError(f"Unsupported local-agent command: {action}")
|
||||||
ok = True
|
ok = True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
error = str(exc)
|
error = str(exc)
|
||||||
self.logger.exception("Agent command failed action=%s command_id=%s: %s", action, command_id, exc)
|
self.logger.exception("Agent command failed action=%s command_id=%s: %s", action, command_id, exc)
|
||||||
return {
|
return {"type": "command_result", "command_id": command_id, "ok": ok, "result": result, "error": error, "agent_time_utc": datetime.now(timezone.utc).isoformat()}
|
||||||
"type": "command_result",
|
|
||||||
"command_id": command_id,
|
|
||||||
"ok": ok,
|
|
||||||
"result": result,
|
|
||||||
"error": error,
|
|
||||||
"agent_time_utc": datetime.now(timezone.utc).isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _agent_info(self) -> dict[str, Any]:
|
def _agent_info(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"name": "ERP Local Agent",
|
"name": "ERP Local Agent", "version": __version__,
|
||||||
"version": __version__,
|
"tally_capability": True, "accounting_act_capability": True,
|
||||||
"tally_capability": True,
|
"tally_mapping_capability": True, "tally_master_sync_capability": True,
|
||||||
"accounting_act_capability": True,
|
|
||||||
"tally_mapping_capability": True,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def _status(self, payload: dict[str, Any]) -> dict[str, Any]:
|
def _status(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
@@ -64,32 +57,17 @@ class AgentCommandProcessor:
|
|||||||
if self.store.exists(client_id):
|
if self.store.exists(client_id):
|
||||||
self.store.record_tally_status(client_id, tally_status)
|
self.store.record_tally_status(client_id, tally_status)
|
||||||
accounting = self.store.snapshot(client_id)
|
accounting = self.store.snapshot(client_id)
|
||||||
return {
|
return {"agent": self._agent_info(), "tally": tally_status, "accounting": accounting}
|
||||||
"agent": self._agent_info(),
|
|
||||||
"tally": tally_status,
|
|
||||||
"accounting": accounting,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _initialize(self, payload: dict[str, Any]) -> dict[str, Any]:
|
def _initialize(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
client_id = int(payload.get("client_id"))
|
client_id = int(payload.get("client_id"))
|
||||||
client_name = str(payload.get("client_name") or "").strip()
|
client_name = str(payload.get("client_name") or "").strip()
|
||||||
tenant_id = payload.get("tenant_id")
|
tenant_id = payload.get("tenant_id")
|
||||||
created_by_user_id = payload.get("requested_by_user_id")
|
created_by_user_id = payload.get("requested_by_user_id")
|
||||||
path = self.store.initialize(
|
path = self.store.initialize(client_id, client_name, int(tenant_id) if tenant_id not in (None, "") else None, int(created_by_user_id) if created_by_user_id not in (None, "") else None)
|
||||||
client_id,
|
|
||||||
client_name,
|
|
||||||
int(tenant_id) if tenant_id not in (None, "") else None,
|
|
||||||
int(created_by_user_id) if created_by_user_id not in (None, "") else None,
|
|
||||||
)
|
|
||||||
tally_status = self.tally.status()
|
tally_status = self.tally.status()
|
||||||
self.store.record_tally_status(client_id, tally_status)
|
self.store.record_tally_status(client_id, tally_status)
|
||||||
return {
|
return {"initialized": True, "db_path": str(path), "accounting": self.store.snapshot(client_id), "tally": tally_status, "agent": self._agent_info()}
|
||||||
"initialized": True,
|
|
||||||
"db_path": str(path),
|
|
||||||
"accounting": self.store.snapshot(client_id),
|
|
||||||
"tally": tally_status,
|
|
||||||
"agent": self._agent_info(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _map_company(self, payload: dict[str, Any]) -> dict[str, Any]:
|
def _map_company(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
client_id = int(payload.get("client_id"))
|
client_id = int(payload.get("client_id"))
|
||||||
@@ -99,74 +77,54 @@ class AgentCommandProcessor:
|
|||||||
requested_guid = str(payload.get("tally_guid") or "").strip()
|
requested_guid = str(payload.get("tally_guid") or "").strip()
|
||||||
registration = payload.get("registration") or None
|
registration = payload.get("registration") or None
|
||||||
allow_gstin_mismatch = bool(payload.get("allow_gstin_mismatch"))
|
allow_gstin_mismatch = bool(payload.get("allow_gstin_mismatch"))
|
||||||
|
|
||||||
if not requested_guid:
|
if not requested_guid:
|
||||||
raise ValueError("Select a Tally company before mapping.")
|
raise ValueError("Select a Tally company before mapping.")
|
||||||
|
|
||||||
if not self.store.exists(client_id):
|
if not self.store.exists(client_id):
|
||||||
self.store.initialize(
|
self.store.initialize(client_id, client_name, int(tenant_id) if tenant_id not in (None, "") else None, int(mapped_by_user_id) if mapped_by_user_id not in (None, "") else None)
|
||||||
client_id,
|
|
||||||
client_name,
|
|
||||||
int(tenant_id) if tenant_id not in (None, "") else None,
|
|
||||||
int(mapped_by_user_id) if mapped_by_user_id not in (None, "") else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
tally_status = self.tally.status()
|
tally_status = self.tally.status()
|
||||||
if not tally_status.get("connected"):
|
if not tally_status.get("connected"):
|
||||||
raise ValueError(str(tally_status.get("error") or "TallyPrime is not 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)
|
||||||
companies = tally_status.get("companies") or []
|
|
||||||
company = next(
|
|
||||||
(row for row in companies if str(row.get("guid") or "").strip() == requested_guid),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not company:
|
if not company:
|
||||||
raise ValueError("The selected Tally company is no longer loaded. Refresh Tally companies and try again.")
|
raise ValueError("The selected Tally company is no longer loaded. Refresh Tally companies and try again.")
|
||||||
|
|
||||||
company_name = str(company.get("name") or "").strip()
|
company_name = str(company.get("name") or "").strip()
|
||||||
company_gstin = str(company.get("gstin") or "").strip().upper()
|
company_gstin = str(company.get("gstin") or "").strip().upper()
|
||||||
if not company_name:
|
if not company_name:
|
||||||
raise ValueError("Tally returned an invalid company name.")
|
raise ValueError("Tally returned an invalid company name.")
|
||||||
if not requested_guid:
|
|
||||||
raise ValueError("Tally returned no GUID. Permanent mapping requires a Tally GUID.")
|
|
||||||
|
|
||||||
if registration:
|
if registration:
|
||||||
reg_type = str(registration.get("registration_type_code") or "").strip().upper()
|
reg_type = str(registration.get("registration_type_code") or "").strip().upper()
|
||||||
reg_number = str(registration.get("registration_number") or "").strip().upper()
|
reg_number = str(registration.get("registration_number") or "").strip().upper()
|
||||||
if reg_type == "GSTIN" and reg_number and company_gstin and reg_number != company_gstin and not allow_gstin_mismatch:
|
if reg_type == "GSTIN" and reg_number and company_gstin and reg_number != company_gstin and not allow_gstin_mismatch:
|
||||||
raise ValueError(
|
raise ValueError(f"GSTIN mismatch: ERP registration is {reg_number}, but Tally company reports {company_gstin}. Verify the company or explicitly allow the mismatch.")
|
||||||
f"GSTIN mismatch: ERP registration is {reg_number}, but Tally company reports {company_gstin}. "
|
|
||||||
"Verify the company or explicitly allow the mismatch."
|
|
||||||
)
|
|
||||||
|
|
||||||
self.store.record_tally_status(client_id, tally_status)
|
self.store.record_tally_status(client_id, tally_status)
|
||||||
mapping = self.store.map_company(
|
mapping = self.store.map_company(client_id, tally_guid=requested_guid, company_name=company_name, gstin=company_gstin, registration=registration, mapped_by_user_id=int(mapped_by_user_id) if mapped_by_user_id not in (None, "") else None)
|
||||||
client_id,
|
return {"mapped": True, "mapping": mapping, "accounting": self.store.snapshot(client_id), "tally": tally_status, "agent": self._agent_info()}
|
||||||
tally_guid=requested_guid,
|
|
||||||
company_name=company_name,
|
|
||||||
gstin=company_gstin,
|
|
||||||
registration=registration,
|
|
||||||
mapped_by_user_id=int(mapped_by_user_id) if mapped_by_user_id not in (None, "") else None,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"mapped": True,
|
|
||||||
"mapping": mapping,
|
|
||||||
"accounting": self.store.snapshot(client_id),
|
|
||||||
"tally": tally_status,
|
|
||||||
"agent": self._agent_info(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _unmap_company(self, payload: dict[str, Any]) -> dict[str, Any]:
|
def _unmap_company(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
client_id = int(payload.get("client_id"))
|
client_id = int(payload.get("client_id"))
|
||||||
mapping_id = int(payload.get("mapping_id"))
|
mapping_id = int(payload.get("mapping_id"))
|
||||||
unmapped_by_user_id = payload.get("unmapped_by_user_id")
|
unmapped_by_user_id = payload.get("unmapped_by_user_id")
|
||||||
result = self.store.unmap_company(
|
result = self.store.unmap_company(client_id, mapping_id, int(unmapped_by_user_id) if unmapped_by_user_id not in (None, "") else None)
|
||||||
client_id,
|
return {**result, "accounting": self.store.snapshot(client_id), "agent": self._agent_info()}
|
||||||
mapping_id,
|
|
||||||
int(unmapped_by_user_id) if unmapped_by_user_id not in (None, "") else None,
|
def _sync_masters(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
)
|
client_id = int(payload.get("client_id"))
|
||||||
return {
|
requested_guid = str(payload.get("tally_guid") or "").strip()
|
||||||
**result,
|
requested_by_user_id = payload.get("requested_by_user_id")
|
||||||
"accounting": self.store.snapshot(client_id),
|
if not requested_guid:
|
||||||
"agent": self._agent_info(),
|
raise ValueError("Select a currently open Tally company before synchronizing masters.")
|
||||||
}
|
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()
|
||||||
|
masters = self.tally.fetch_accounting_masters(company_name)
|
||||||
|
self.store.record_tally_status(client_id, tally_status)
|
||||||
|
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()}
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import re
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_TALLY_HOST = "127.0.0.1"
|
DEFAULT_TALLY_HOST = "127.0.0.1"
|
||||||
DEFAULT_TALLY_PORT = 9000
|
DEFAULT_TALLY_PORT = 9000
|
||||||
DEFAULT_TALLY_TIMEOUT_SECONDS = 8
|
DEFAULT_TALLY_TIMEOUT_SECONDS = 20
|
||||||
|
|
||||||
|
|
||||||
class TallyConnectionError(RuntimeError):
|
class TallyConnectionError(RuntimeError):
|
||||||
@@ -24,14 +25,35 @@ def _clean_xml_response(xml_text: str) -> str:
|
|||||||
return re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F]", "", xml_text)
|
return re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F]", "", xml_text)
|
||||||
|
|
||||||
|
|
||||||
|
def _tag(element: ET.Element) -> str:
|
||||||
|
return str(element.tag).split("}")[-1].upper()
|
||||||
|
|
||||||
|
|
||||||
def _child_text(element: ET.Element, tag_name: str) -> str:
|
def _child_text(element: ET.Element, tag_name: str) -> str:
|
||||||
wanted = tag_name.upper()
|
wanted = tag_name.upper()
|
||||||
for child in list(element):
|
for child in list(element):
|
||||||
if str(child.tag).split("}")[-1].upper() == wanted:
|
if _tag(child) == wanted:
|
||||||
return (child.text or "").strip()
|
return (child.text or "").strip()
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _first_text(element: ET.Element, names: Iterable[str]) -> str:
|
||||||
|
wanted = {str(name).upper() for name in names}
|
||||||
|
for node in element.iter():
|
||||||
|
if _tag(node) in wanted and node.text:
|
||||||
|
return node.text.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _to_number(value: str) -> float:
|
||||||
|
text = str(value or "").replace(",", "").strip()
|
||||||
|
text = re.sub(r"[^0-9.\-]", "", text)
|
||||||
|
try:
|
||||||
|
return float(text or 0)
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TallyCompany:
|
class TallyCompany:
|
||||||
name: str
|
name: str
|
||||||
@@ -43,7 +65,69 @@ class TallyCompany:
|
|||||||
|
|
||||||
|
|
||||||
class TallyLiveConnector:
|
class TallyLiveConnector:
|
||||||
"""Read-only TallyPrime XML/HTTP discovery connector."""
|
"""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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MASTER_SPECS = {
|
||||||
|
"groups": {
|
||||||
|
"collection": "ARRRAccountingGroups",
|
||||||
|
"type": "Group",
|
||||||
|
"tag": "GROUP",
|
||||||
|
"fetch": "Name,GUID,Parent,ReservedName,IsRevenue,IsDeemedPositive",
|
||||||
|
},
|
||||||
|
"ledgers": {
|
||||||
|
"collection": "ARRRAccountingLedgers",
|
||||||
|
"type": "Ledger",
|
||||||
|
"tag": "LEDGER",
|
||||||
|
"fetch": "Name,GUID,Parent,OpeningBalance,ClosingBalance,IsBillWiseOn,TaxType,GSTApplicable,GSTRegistrationType",
|
||||||
|
},
|
||||||
|
"voucher_types": {
|
||||||
|
"collection": "ARRRAccountingVoucherTypes",
|
||||||
|
"type": "VoucherType",
|
||||||
|
"tag": "VOUCHERTYPE",
|
||||||
|
"fetch": "Name,GUID,Parent,NumberingMethod,IsDeemedPositive,IsActive",
|
||||||
|
},
|
||||||
|
"stock_groups": {
|
||||||
|
"collection": "ARRRAccountingStockGroups",
|
||||||
|
"type": "StockGroup",
|
||||||
|
"tag": "STOCKGROUP",
|
||||||
|
"fetch": "Name,GUID,Parent,BaseUnits,IsAddable",
|
||||||
|
},
|
||||||
|
"stock_categories": {
|
||||||
|
"collection": "ARRRAccountingStockCategories",
|
||||||
|
"type": "StockCategory",
|
||||||
|
"tag": "STOCKCATEGORY",
|
||||||
|
"fetch": "Name,GUID,Parent",
|
||||||
|
},
|
||||||
|
"stock_items": {
|
||||||
|
"collection": "ARRRAccountingStockItems",
|
||||||
|
"type": "StockItem",
|
||||||
|
"tag": "STOCKITEM",
|
||||||
|
"fetch": "Name,GUID,Parent,Category,BaseUnits,AdditionalUnits,OpeningBalance,OpeningValue,OpeningRate,GSTApplicable,GSTTypeOfSupply,HSNCode",
|
||||||
|
},
|
||||||
|
"units": {
|
||||||
|
"collection": "ARRRAccountingUnits",
|
||||||
|
"type": "Unit",
|
||||||
|
"tag": "UNIT",
|
||||||
|
"fetch": "Name,GUID,OriginalName,IsSimpleUnit,BaseUnits,AdditionalUnits,Conversion",
|
||||||
|
},
|
||||||
|
"cost_categories": {
|
||||||
|
"collection": "ARRRAccountingCostCategories",
|
||||||
|
"type": "CostCategory",
|
||||||
|
"tag": "COSTCATEGORY",
|
||||||
|
"fetch": "Name,GUID,IsRevenue,IsNonRevenue",
|
||||||
|
},
|
||||||
|
"cost_centres": {
|
||||||
|
"collection": "ARRRAccountingCostCentres",
|
||||||
|
"type": "CostCentre",
|
||||||
|
"tag": "COSTCENTRE",
|
||||||
|
"fetch": "Name,GUID,Parent,Category",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self, host: str = DEFAULT_TALLY_HOST, port: int = DEFAULT_TALLY_PORT, timeout: int = DEFAULT_TALLY_TIMEOUT_SECONDS):
|
def __init__(self, host: str = DEFAULT_TALLY_HOST, port: int = DEFAULT_TALLY_PORT, timeout: int = DEFAULT_TALLY_TIMEOUT_SECONDS):
|
||||||
self.host = (host or DEFAULT_TALLY_HOST).strip()
|
self.host = (host or DEFAULT_TALLY_HOST).strip()
|
||||||
@@ -54,6 +138,10 @@ class TallyLiveConnector:
|
|||||||
def url(self) -> str:
|
def url(self) -> str:
|
||||||
return f"http://{self.host}:{self.port}"
|
return f"http://{self.host}:{self.port}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _xml_escape(value: str) -> str:
|
||||||
|
return html.escape(str(value or ""), quote=False)
|
||||||
|
|
||||||
def _post_xml(self, xml_text: str) -> str:
|
def _post_xml(self, xml_text: str) -> str:
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
self.url,
|
self.url,
|
||||||
@@ -69,20 +157,23 @@ class TallyLiveConnector:
|
|||||||
f"Could not connect to TallyPrime at {self.url}. Open TallyPrime and ensure its HTTP/XML server is available on port {self.port}. Details: {exc}"
|
f"Could not connect to TallyPrime at {self.url}. Open TallyPrime and ensure its HTTP/XML server is available on port {self.port}. Details: {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
def _static_variables(self, company_name: str = "") -> str:
|
||||||
|
base = "<SVEXPORTFORMAT>$$SysName:XML</SVEXPORTFORMAT>"
|
||||||
|
if not company_name:
|
||||||
|
return base
|
||||||
|
return base + f"<SVCURRENTCOMPANY>{self._xml_escape(company_name)}</SVCURRENTCOMPANY>"
|
||||||
|
|
||||||
def get_loaded_companies(self) -> list[TallyCompany]:
|
def get_loaded_companies(self) -> list[TallyCompany]:
|
||||||
xml = """<ENVELOPE>
|
xml = """<ENVELOPE>
|
||||||
<HEADER>
|
<HEADER>
|
||||||
<VERSION>1</VERSION>
|
<VERSION>1</VERSION><TALLYREQUEST>Export</TALLYREQUEST><TYPE>Collection</TYPE><ID>ARRRAccountingLoadedCompanies</ID>
|
||||||
<TALLYREQUEST>Export</TALLYREQUEST>
|
|
||||||
<TYPE>Collection</TYPE>
|
|
||||||
<ID>ARRRAccountingLoadedCompanies</ID>
|
|
||||||
</HEADER>
|
</HEADER>
|
||||||
<BODY>
|
<BODY><DESC>
|
||||||
<DESC>
|
|
||||||
<STATICVARIABLES><SVEXPORTFORMAT>$$SysName:XML</SVEXPORTFORMAT></STATICVARIABLES>
|
<STATICVARIABLES><SVEXPORTFORMAT>$$SysName:XML</SVEXPORTFORMAT></STATICVARIABLES>
|
||||||
<TDL><TDLMESSAGE><COLLECTION NAME="ARRRAccountingLoadedCompanies" ISMODIFY="No"><TYPE>Company</TYPE><FETCH>Name,GUID,GSTRegistrationNumber</FETCH></COLLECTION></TDLMESSAGE></TDL>
|
<TDL><TDLMESSAGE>
|
||||||
</DESC>
|
<COLLECTION NAME="ARRRAccountingLoadedCompanies" ISMODIFY="No"><TYPE>Company</TYPE><FETCH>Name,GUID,GSTRegistrationNumber</FETCH></COLLECTION>
|
||||||
</BODY>
|
</TDLMESSAGE></TDL>
|
||||||
|
</DESC></BODY>
|
||||||
</ENVELOPE>"""
|
</ENVELOPE>"""
|
||||||
return self._parse_companies(self._post_xml(xml))
|
return self._parse_companies(self._post_xml(xml))
|
||||||
|
|
||||||
@@ -109,6 +200,87 @@ class TallyLiveConnector:
|
|||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def export_master_collection(self, company_name: str, master_key: str) -> list[dict]:
|
||||||
|
key = str(master_key or "").strip()
|
||||||
|
spec = self.MASTER_SPECS.get(key)
|
||||||
|
if not spec:
|
||||||
|
raise ValueError(f"Unsupported Tally master collection: {key}")
|
||||||
|
if not str(company_name or "").strip():
|
||||||
|
raise ValueError("Tally company name is required for master sync.")
|
||||||
|
|
||||||
|
xml = f"""<ENVELOPE>
|
||||||
|
<HEADER><VERSION>1</VERSION><TALLYREQUEST>Export</TALLYREQUEST><TYPE>Collection</TYPE><ID>{spec['collection']}</ID></HEADER>
|
||||||
|
<BODY><DESC>
|
||||||
|
<STATICVARIABLES>{self._static_variables(company_name)}</STATICVARIABLES>
|
||||||
|
<TDL><TDLMESSAGE>
|
||||||
|
<COLLECTION NAME="{spec['collection']}" ISMODIFY="No"><TYPE>{spec['type']}</TYPE><FETCH>{spec['fetch']}</FETCH></COLLECTION>
|
||||||
|
</TDLMESSAGE></TDL>
|
||||||
|
</DESC></BODY>
|
||||||
|
</ENVELOPE>"""
|
||||||
|
raw = self._post_xml(xml)
|
||||||
|
return self._parse_master_rows(raw, spec["tag"])
|
||||||
|
|
||||||
|
def fetch_accounting_masters(self, company_name: str) -> dict[str, list[dict]]:
|
||||||
|
result: dict[str, list[dict]] = {}
|
||||||
|
for key in self.MASTER_SPECS:
|
||||||
|
result[key] = self.export_master_collection(company_name, key)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_master_rows(xml_text: str, expected_tag: 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 {expected_tag} masters: {exc}") from exc
|
||||||
|
|
||||||
|
rows: list[dict] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for element in root.iter():
|
||||||
|
if _tag(element) != expected_tag.upper():
|
||||||
|
continue
|
||||||
|
name = (
|
||||||
|
element.attrib.get("NAME")
|
||||||
|
or element.attrib.get("name")
|
||||||
|
or _child_text(element, "NAME")
|
||||||
|
).strip()
|
||||||
|
guid = _child_text(element, "GUID")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
identity = (guid.upper(), name.upper())
|
||||||
|
if identity in seen:
|
||||||
|
continue
|
||||||
|
seen.add(identity)
|
||||||
|
rows.append({
|
||||||
|
"name": name,
|
||||||
|
"guid": guid,
|
||||||
|
"parent": _child_text(element, "PARENT"),
|
||||||
|
"reserved_name": _child_text(element, "RESERVEDNAME"),
|
||||||
|
"category": _child_text(element, "CATEGORY"),
|
||||||
|
"base_units": _child_text(element, "BASEUNITS"),
|
||||||
|
"additional_units": _child_text(element, "ADDITIONALUNITS"),
|
||||||
|
"original_name": _child_text(element, "ORIGINALNAME"),
|
||||||
|
"opening_balance": _to_number(_child_text(element, "OPENINGBALANCE")),
|
||||||
|
"closing_balance": _to_number(_child_text(element, "CLOSINGBALANCE")),
|
||||||
|
"opening_value": _to_number(_child_text(element, "OPENINGVALUE")),
|
||||||
|
"opening_rate": _child_text(element, "OPENINGRATE"),
|
||||||
|
"numbering_method": _child_text(element, "NUMBERINGMETHOD"),
|
||||||
|
"tax_type": _child_text(element, "TAXTYPE"),
|
||||||
|
"gst_applicable": _child_text(element, "GSTAPPLICABLE"),
|
||||||
|
"gst_registration_type": _child_text(element, "GSTREGISTRATIONTYPE"),
|
||||||
|
"gst_type_of_supply": _child_text(element, "GSTTYPEOFSUPPLY"),
|
||||||
|
"hsn_code": _first_text(element, ("HSNCODE", "GSTHSNCODE")),
|
||||||
|
"is_revenue": _child_text(element, "ISREVENUE"),
|
||||||
|
"is_deemed_positive": _child_text(element, "ISDEEMEDPOSITIVE"),
|
||||||
|
"is_billwise_on": _child_text(element, "ISBILLWISEON"),
|
||||||
|
"is_simple_unit": _child_text(element, "ISSIMPLEUNIT"),
|
||||||
|
"conversion": _child_text(element, "CONVERSION"),
|
||||||
|
"is_active": _child_text(element, "ISACTIVE"),
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_companies(xml_text: str) -> list[TallyCompany]:
|
def _parse_companies(xml_text: str) -> list[TallyCompany]:
|
||||||
cleaned = _clean_xml_response(xml_text)
|
cleaned = _clean_xml_response(xml_text)
|
||||||
@@ -127,7 +299,7 @@ class TallyLiveConnector:
|
|||||||
companies: list[TallyCompany] = []
|
companies: list[TallyCompany] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for element in root.iter():
|
for element in root.iter():
|
||||||
if str(element.tag).split("}")[-1].upper() != "COMPANY":
|
if _tag(element) != "COMPANY":
|
||||||
continue
|
continue
|
||||||
name = (
|
name = (
|
||||||
element.attrib.get("NAME")
|
element.attrib.get("NAME")
|
||||||
|
|||||||
Reference in New Issue
Block a user