Add Phase 2 Tally company mapping

This commit is contained in:
A R R R Associates
2026-08-19 10:10:46 +05:30
parent 05a6b42cce
commit ad73df1a78
10 changed files with 867 additions and 85 deletions
+3 -1
View File
@@ -1,4 +1,4 @@
from __future__ import annotations from __future__ import annotations
from fastapi import FastAPI from fastapi import FastAPI
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
@@ -335,6 +335,7 @@ ROLE_PERMISSION_MAP = {
"documents.delete", "documents.delete",
"accounting.tally.view", "accounting.tally.view",
"accounting.tally.connect", "accounting.tally.connect",
"accounting.tally.map_company",
"accounting.act.initialize", "accounting.act.initialize",
"clients.view.own_only", "clients.view.own_only",
"employees.dashboard.view", "employees.dashboard.view",
@@ -967,3 +968,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()
+178 -8
View File
@@ -8,7 +8,7 @@ import os
import sqlite3 import sqlite3
from typing import Iterator, Sequence from typing import Iterator, Sequence
ACT_SCHEMA_VERSION = 1 ACT_SCHEMA_VERSION = 2
class AccountingActStoreError(RuntimeError): class AccountingActStoreError(RuntimeError):
@@ -29,10 +29,10 @@ def _safe_client_id(client_id: int) -> int:
class AccountingActStore: class AccountingActStore:
"""Client-scoped SQLite storage for ERP accounting data. """Client-scoped SQLite storage for ERP accounting data.
The `.act` extension is intentional; the underlying file format is SQLite. Phase 1 provides accounting storage and read-only Tally discovery.
Phase 1 stores metadata, discovered Tally companies, connection history and Phase 2 adds durable client/registration -> Tally company mapping keyed by
sync-run control records only. Transaction/master tables arrive in later Tally GUID. Accounting master/transaction sync and write-back remain out of
phases. scope.
""" """
def __init__(self, root: str | Path) -> None: def __init__(self, root: str | Path) -> None:
@@ -97,12 +97,21 @@ class AccountingActStore:
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL, client_id INTEGER NOT NULL,
registration_id INTEGER, registration_id INTEGER,
tally_guid TEXT NOT NULL DEFAULT '', registration_type_code TEXT NOT NULL DEFAULT '',
company_name TEXT NOT NULL DEFAULT '', registration_number TEXT NOT NULL DEFAULT '',
registration_legal_name TEXT NOT NULL DEFAULT '',
registration_trade_name TEXT NOT NULL DEFAULT '',
business_unit_id INTEGER,
client_branch_id INTEGER,
tally_guid TEXT NOT NULL,
company_name TEXT NOT NULL,
gstin TEXT NOT NULL DEFAULT '', gstin TEXT NOT NULL DEFAULT '',
is_active INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1,
mapped_at_utc TEXT NOT NULL, mapped_at_utc TEXT NOT NULL,
mapped_by_user_id INTEGER mapped_by_user_id INTEGER,
updated_at_utc TEXT NOT NULL,
unmapped_at_utc TEXT,
unmapped_by_user_id INTEGER
); );
CREATE TABLE IF NOT EXISTS tally_connection_history ( CREATE TABLE IF NOT EXISTS tally_connection_history (
@@ -129,12 +138,17 @@ class AccountingActStore:
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
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_connection_history_checked CREATE INDEX IF NOT EXISTS ix_tally_connection_history_checked
ON tally_connection_history(checked_at_utc); ON tally_connection_history(checked_at_utc);
CREATE INDEX IF NOT EXISTS ix_tally_sync_runs_started CREATE INDEX IF NOT EXISTS ix_tally_sync_runs_started
ON tally_sync_runs(started_at_utc); ON tally_sync_runs(started_at_utc);
""" """
) )
self._ensure_mapping_columns(db)
now = _utc_now_iso() now = _utc_now_iso()
metadata = { metadata = {
"schema_version": str(ACT_SCHEMA_VERSION), "schema_version": str(ACT_SCHEMA_VERSION),
@@ -157,6 +171,24 @@ class AccountingActStore:
) )
return path return path
@staticmethod
def _ensure_mapping_columns(db: sqlite3.Connection) -> None:
columns = {row["name"] for row in db.execute("PRAGMA table_info(tally_company_mapping)").fetchall()}
additions = {
"registration_type_code": "TEXT NOT NULL DEFAULT ''",
"registration_number": "TEXT NOT NULL DEFAULT ''",
"registration_legal_name": "TEXT NOT NULL DEFAULT ''",
"registration_trade_name": "TEXT NOT NULL DEFAULT ''",
"business_unit_id": "INTEGER",
"client_branch_id": "INTEGER",
"updated_at_utc": "TEXT",
"unmapped_at_utc": "TEXT",
"unmapped_by_user_id": "INTEGER",
}
for name, ddl in additions.items():
if name not in columns:
db.execute(f"ALTER TABLE tally_company_mapping ADD COLUMN {name} {ddl}")
def record_tally_status(self, client_id: int, status: dict) -> None: def record_tally_status(self, client_id: int, status: dict) -> None:
self.initialize(client_id) self.initialize(client_id)
now = _utc_now_iso() now = _utc_now_iso()
@@ -214,6 +246,123 @@ class AccountingActStore:
), ),
) )
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.initialize(client_id)
guid = str(tally_guid or "").strip()
name = str(company_name or "").strip()
if not guid:
raise AccountingActStoreError("Tally company GUID is required for permanent mapping.")
if not name:
raise AccountingActStoreError("Tally company name is required.")
registration = registration or {}
registration_id = registration.get("id")
registration_id = int(registration_id) if registration_id not in (None, "") else None
now = _utc_now_iso()
with self.connect(client_id) as db:
if registration_id is None:
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)),
)
else:
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),
)
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),
)
cursor = db.execute(
"""
INSERT INTO tally_company_mapping(
client_id, registration_id, registration_type_code,
registration_number, registration_legal_name,
registration_trade_name, business_unit_id, client_branch_id,
tally_guid, company_name, gstin, is_active,
mapped_at_utc, mapped_by_user_id, updated_at_utc
) 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,
mapped_by_user_id,
now,
),
)
mapping_id = int(cursor.lastrowid)
return self.get_mapping(client_id, mapping_id)
def unmap_company(self, client_id: int, mapping_id: int, *, unmapped_by_user_id: int | None = None) -> dict:
self.initialize(client_id)
now = _utc_now_iso()
with self.connect(client_id) as db:
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()
if not row:
raise AccountingActStoreError("Active Tally company mapping was not found.")
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)),
)
return {"mapping_id": int(mapping_id), "unmapped": True}
def get_mapping(self, client_id: int, mapping_id: int) -> dict:
with self.connect(client_id) as db:
row = db.execute(
"""
SELECT *
FROM tally_company_mapping
WHERE id=? AND client_id=?
LIMIT 1
""",
(int(mapping_id), int(client_id)),
).fetchone()
if not row:
raise AccountingActStoreError("Tally company mapping was not found.")
return dict(row)
def snapshot(self, client_id: int) -> dict: def snapshot(self, client_id: int) -> dict:
self.initialize(client_id) self.initialize(client_id)
with self.connect(client_id) as db: with self.connect(client_id) as db:
@@ -226,6 +375,20 @@ class AccountingActStore:
ORDER BY is_currently_loaded DESC, company_name COLLATE NOCASE ORDER BY is_currently_loaded DESC, company_name COLLATE NOCASE
""" """
).fetchall() ).fetchall()
mappings = db.execute(
"""
SELECT id, client_id, registration_id, registration_type_code,
registration_number, registration_legal_name,
registration_trade_name, business_unit_id, client_branch_id,
tally_guid, company_name, gstin, is_active,
mapped_at_utc, mapped_by_user_id, updated_at_utc,
unmapped_at_utc, unmapped_by_user_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()
latest = db.execute( latest = db.execute(
""" """
SELECT checked_at_utc, connected, tally_url, company_count, error_message SELECT checked_at_utc, connected, tally_url, company_count, error_message
@@ -233,10 +396,17 @@ class AccountingActStore:
ORDER BY id DESC LIMIT 1 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)}
mapping_rows = []
for row in mappings:
item = dict(row)
item["currently_loaded"] = bool(item.get("tally_guid") and item["tally_guid"] in loaded_guids)
mapping_rows.append(item)
return { return {
"db_path": str(self.db_path(client_id)), "db_path": str(self.db_path(client_id)),
"metadata": {row["key"]: row["value"] for row in meta_rows}, "metadata": {row["key"]: row["value"] for row in meta_rows},
"companies": [dict(row) for row in companies], "companies": [dict(row) for row in companies],
"mappings": mapping_rows,
"latest_connection": dict(latest) if latest else None, "latest_connection": dict(latest) if latest else None,
} }
@@ -4,78 +4,228 @@
<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 Connection</h1> <h1 class="mt-1 text-2xl font-semibold text-slate-900">Tally Company Mapping</h1>
<p class="mt-1 text-sm text-slate-500">Read-only Phase 1 connection through the existing ERP Local Agent. Tally port 9000 is never exposed to the internet.</p> <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>
</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">Test Tally Connection</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>
{% if initialized %} {% if initialized %}
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">Client accounting storage was initialized successfully.</div> <div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">Client accounting storage was initialized successfully.</div>
{% endif %} {% endif %}
{% if mapped %}
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">Tally company mapping was saved successfully.</div>
{% endif %}
{% if unmapped %}
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm font-medium text-slate-700">Tally company mapping was removed.</div>
{% endif %}
{% if command_error %} {% if command_error %}
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">{{ command_error }}</div> <div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">{{ command_error }}</div>
{% endif %} {% endif %}
{% set agent = live_result.agent if live_result else None %}
{% set tally = live_result.tally if live_result else None %}
{% set accounting = live_result.accounting if live_result else None %}
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-2xl bg-white p-5 shadow-soft"> <div class="rounded-2xl bg-white p-5 shadow-soft">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">ERP Local Agent</div> <div class="text-xs font-semibold uppercase tracking-wide text-slate-500">ERP Local Agent</div>
<div class="mt-2 text-lg font-semibold {% if agent_online %}text-emerald-700{% else %}text-red-700{% endif %}">{% if agent_online %}Connected{% else %}Offline{% endif %}</div> <div class="mt-2 text-lg font-semibold {% if agent_online %}text-emerald-700{% else %}text-red-700{% endif %}">{{ 'Connected' if agent_online else 'Offline' }}</div>
<div class="mt-1 text-xs text-slate-500">{{ storage_node.node_name if storage_node else 'No active branch agent' }}</div> <div class="mt-1 text-xs text-slate-500">{{ storage_node.node_name if storage_node else 'No active branch agent' }}</div>
</div> </div>
<div class="rounded-2xl bg-white p-5 shadow-soft"> <div class="rounded-2xl bg-white p-5 shadow-soft">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Tally Module</div> <div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Mapping Module</div>
{% set agent = live_result.agent if live_result else None %} <div class="mt-2 text-lg font-semibold {% if agent and agent.tally_mapping_capability %}text-emerald-700{% else %}text-slate-700{% endif %}">
<div class="mt-2 text-lg font-semibold {% if agent and agent.tally_capability %}text-emerald-700{% else %}text-slate-700{% endif %}">{{ 'Available' if agent and agent.tally_capability else ('Check connection' if agent_online else 'Unavailable') }}</div> {{ 'Available' if agent and agent.tally_mapping_capability else ('Update Agent' if agent_online else 'Unavailable') }}
</div>
<div class="mt-1 text-xs text-slate-500">Agent {{ agent.version if agent and agent.version else '-' }}</div> <div class="mt-1 text-xs text-slate-500">Agent {{ agent.version if agent and agent.version else '-' }}</div>
</div> </div>
<div class="rounded-2xl bg-white p-5 shadow-soft"> <div class="rounded-2xl bg-white p-5 shadow-soft">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">TallyPrime</div> <div class="text-xs font-semibold uppercase tracking-wide text-slate-500">TallyPrime</div>
{% set tally = live_result.tally if live_result else None %} <div class="mt-2 text-lg font-semibold {% if tally and tally.connected %}text-emerald-700{% elif tally %}text-red-700{% else %}text-slate-700{% endif %}">
<div class="mt-2 text-lg font-semibold {% if tally and tally.connected %}text-emerald-700{% elif tally %}text-red-700{% else %}text-slate-700{% endif %}">{{ 'Connected' if tally and tally.connected else ('Not connected' if tally else 'Not checked') }}</div> {{ 'Connected' if tally and tally.connected else ('Not connected' if tally else 'Not checked') }}
</div>
<div class="mt-1 text-xs text-slate-500">{{ tally.url if tally and tally.url else '127.0.0.1:9000' }}</div> <div class="mt-1 text-xs text-slate-500">{{ tally.url if tally and tally.url else '127.0.0.1:9000' }}</div>
</div> </div>
<div class="rounded-2xl bg-white p-5 shadow-soft"> <div class="rounded-2xl bg-white p-5 shadow-soft">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Loaded Companies</div> <div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Loaded Companies</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ tally.company_count if tally else '-' }}</div> <div class="mt-2 text-2xl font-semibold text-slate-900">{{ tally.company_count if tally else '-' }}</div>
<div class="mt-1 text-xs text-slate-500">Read-only discovery</div> <div class="mt-1 text-xs text-slate-500">GUID-based discovery</div>
</div> </div>
</section> </section>
<section class="rounded-2xl bg-white p-5 shadow-soft"> <section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="flex flex-wrap items-end gap-4"> <form method="get" action="/tools/tally" class="flex flex-wrap items-end gap-3">
<form method="get" action="/tools/tally" class="flex flex-1 flex-wrap items-end gap-3"> <label class="min-w-[280px] flex-1 text-sm font-medium text-slate-700">
<label class="min-w-[280px] flex-1 text-sm font-medium text-slate-700"> ERP Client
Client <select name="client_id" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100">
<select name="client_id" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100"> <option value="">Select client</option>
<option value="">Select client</option> {% for client in clients %}
{% for client in clients %}<option value="{{ client.id }}" {% if selected_client and selected_client.id == client.id %}selected{% endif %}>{{ client.client_name }}</option>{% endfor %} <option value="{{ client.id }}" {% if selected_client and selected_client.id == client.id %}selected{% endif %}>{{ client.client_name }}</option>
</select> {% endfor %}
</label> </select>
<button type="submit" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Open Client Storage</button> </label>
</form> <button type="submit" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Open Client</button>
{% if selected_client %} </form>
<form method="post" action="/tools/tally/initialize">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="client_id" value="{{ selected_client.id }}" />
<button type="submit" class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800">Create / Verify .act Storage</button>
</form>
{% endif %}
</div>
<p class="mt-3 text-xs text-slate-500">Only clients assigned to the logged-in Partner in the active branch are shown.</p> <p class="mt-3 text-xs text-slate-500">Only clients assigned to the logged-in Partner in the active branch are shown.</p>
</section> </section>
{% if selected_client %}
<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">Accounting Storage</h2>
<p class="mt-1 text-sm text-slate-500">{{ selected_client.client_name }}</p>
</div>
<span class="rounded-full px-3 py-1 text-xs font-semibold {% if accounting and accounting.exists %}bg-emerald-50 text-emerald-700{% else %}bg-amber-50 text-amber-700{% endif %}">
{{ 'Initialized' if accounting and accounting.exists else 'Not initialized' }}
</span>
</div>
{% if accounting and accounting.exists %}
<dl class="mt-4 grid gap-4 md:grid-cols-2">
<div><dt class="text-xs font-semibold uppercase tracking-wide text-slate-500">.act Database</dt><dd class="mt-1 break-all text-sm font-medium text-slate-900">{{ accounting.db_path }}</dd></div>
<div><dt class="text-xs font-semibold uppercase tracking-wide text-slate-500">Schema Version</dt><dd class="mt-1 text-sm font-medium text-slate-900">{{ accounting.metadata.schema_version or '2' }}</dd></div>
</dl>
{% else %}
<form method="post" action="/tools/tally/initialize" class="mt-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="client_id" value="{{ selected_client.id }}" />
<button type="submit" class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800">Create .act Accounting Storage</button>
</form>
{% endif %}
</section>
{% if accounting and accounting.exists and tally and tally.connected %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<div>
<h2 class="font-semibold text-slate-900">Map ERP Scope to Tally Company</h2>
<p class="mt-1 text-sm text-slate-500">The Tally GUID is stored as the permanent identifier. Company name and GSTIN are retained as descriptive snapshots.</p>
</div>
<form method="post" action="/tools/tally/map-company" class="mt-5 grid gap-4 lg:grid-cols-2">
<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">
ERP Scope
<select name="registration_id" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="">Client level · {{ selected_client.client_name }}</option>
{% for reg, reg_type in registrations %}
<option value="{{ reg.id }}">
{{ reg_type.name }} · {{ reg.registration_number }}
{% if reg.trade_name %} · {{ reg.trade_name }}{% elif reg.legal_name %} · {{ reg.legal_name }}{% endif %}
</option>
{% endfor %}
</select>
<span class="mt-1 block text-xs font-normal text-slate-500">Choose a GSTIN/other registration when the Tally company belongs to that registration; otherwise use client level.</span>
</label>
<label class="text-sm font-medium text-slate-700">
Loaded Tally Company
<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>
{% 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">Companies without a GUID are intentionally not offered for permanent mapping.</span>
</label>
<label class="flex items-start gap-2 lg:col-span-2">
<input type="checkbox" name="allow_gstin_mismatch" value="true" class="mt-1 rounded border-slate-300" />
<span class="text-sm text-slate-700">
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>
</label>
<div class="lg:col-span-2">
<button type="submit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save Tally Mapping</button>
</div>
</form>
</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">
<h2 class="font-semibold text-slate-900">Active Tally Mappings</h2>
<p class="text-sm text-slate-500">Mappings remain valid across Tally company renames because the GUID is the persistent key.</p>
</div>
{% if accounting.mappings %}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50">
<tr>
<th class="px-4 py-3 text-left font-semibold text-slate-600">ERP Scope</th>
<th class="px-4 py-3 text-left font-semibold text-slate-600">Tally Company</th>
<th class="px-4 py-3 text-left font-semibold text-slate-600">GUID</th>
<th class="px-4 py-3 text-left font-semibold text-slate-600">GSTIN</th>
<th class="px-4 py-3 text-left font-semibold text-slate-600">Loaded</th>
<th class="px-4 py-3 text-right font-semibold text-slate-600">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for mapping in accounting.mappings %}
<tr>
<td class="px-4 py-3">
{% if mapping.registration_id %}
<div class="font-medium text-slate-900">{{ mapping.registration_type_code or 'Registration' }} · {{ mapping.registration_number or mapping.registration_id }}</div>
<div class="text-xs text-slate-500">{{ mapping.registration_trade_name or mapping.registration_legal_name or selected_client.client_name }}</div>
{% else %}
<div class="font-medium text-slate-900">Client level</div>
<div class="text-xs text-slate-500">{{ selected_client.client_name }}</div>
{% endif %}
</td>
<td class="px-4 py-3 font-medium text-slate-900">{{ mapping.company_name }}</td>
<td class="px-4 py-3 font-mono text-xs text-slate-600">{{ mapping.tally_guid }}</td>
<td class="px-4 py-3 text-slate-600">{{ mapping.gstin or '-' }}</td>
<td class="px-4 py-3">
<span class="rounded-full px-2 py-1 text-xs font-semibold {% if mapping.currently_loaded %}bg-emerald-50 text-emerald-700{% else %}bg-slate-100 text-slate-600{% endif %}">
{{ 'Yes' if mapping.currently_loaded else 'No' }}
</span>
</td>
<td class="px-4 py-3 text-right">
<form method="post" action="/tools/tally/unmap-company" onsubmit="return confirm('Remove this Tally company mapping?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="client_id" value="{{ selected_client.id }}" />
<input type="hidden" name="mapping_id" value="{{ mapping.id }}" />
<button type="submit" class="rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-semibold text-red-700 hover:bg-red-50">Unmap</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-6 text-sm text-slate-500">No Tally company has been mapped for this client yet.</div>
{% endif %}
</section>
{% endif %}
{% if tally %} {% if tally %}
<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">
<h2 class="font-semibold text-slate-900">Loaded Tally Companies</h2> <h2 class="font-semibold text-slate-900">Loaded Tally Companies</h2>
<p class="text-sm text-slate-500">Company name, GUID and GSTIN returned directly by the local TallyPrime instance.</p> <p class="text-sm text-slate-500">Read-only company discovery from the local TallyPrime instance.</p>
</div> </div>
{% if tally.companies %} {% if tally.companies %}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 text-sm"> <table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left font-semibold text-slate-600">Company</th><th class="px-4 py-3 text-left font-semibold text-slate-600">GUID</th><th class="px-4 py-3 text-left font-semibold text-slate-600">GSTIN</th></tr></thead> <thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left font-semibold text-slate-600">Company</th><th class="px-4 py-3 text-left font-semibold text-slate-600">GUID</th><th class="px-4 py-3 text-left font-semibold text-slate-600">GSTIN</th></tr></thead>
<tbody class="divide-y divide-slate-100">{% for company in tally.companies %}<tr><td class="px-4 py-3 font-medium text-slate-900">{{ company.name }}</td><td class="px-4 py-3 text-slate-600">{{ company.guid or '-' }}</td><td class="px-4 py-3 text-slate-600">{{ company.gstin or '-' }}</td></tr>{% endfor %}</tbody> <tbody class="divide-y divide-slate-100">
{% for company in tally.companies %}
<tr>
<td class="px-4 py-3 font-medium text-slate-900">{{ company.name }}</td>
<td class="px-4 py-3 font-mono text-xs text-slate-600">{{ company.guid or '-' }}</td>
<td class="px-4 py-3 text-slate-600">{{ company.gstin or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table> </table>
</div> </div>
{% else %} {% else %}
@@ -83,23 +233,10 @@
{% endif %} {% endif %}
</section> </section>
{% endif %} {% endif %}
{% if selected_client %}
{% set accounting = live_result.accounting if live_result else None %}
<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">Accounting Storage</h2><p class="mt-1 text-sm text-slate-500">{{ selected_client.client_name }}</p></div>
<span class="rounded-full px-3 py-1 text-xs font-semibold {% if accounting and accounting.exists %}bg-emerald-50 text-emerald-700{% else %}bg-amber-50 text-amber-700{% endif %}">{{ 'Initialized' if accounting and accounting.exists else 'Not initialized / not checked' }}</span>
</div>
{% if accounting and accounting.exists %}
<dl class="mt-4 grid gap-4 md:grid-cols-2">
<div><dt class="text-xs font-semibold uppercase tracking-wide text-slate-500">.act Database</dt><dd class="mt-1 break-all text-sm font-medium text-slate-900">{{ accounting.db_path }}</dd></div>
<div><dt class="text-xs font-semibold uppercase tracking-wide text-slate-500">Schema Version</dt><dd class="mt-1 text-sm font-medium text-slate-900">{{ accounting.metadata.schema_version or '1' }}</dd></div>
</dl>
{% endif %}
</section>
{% endif %} {% endif %}
<div class="rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">Phase 1 is read-only. No ledger, voucher, inventory or write-back operation is performed.</div> <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.
</div>
</div> </div>
{% endblock %} {% endblock %}
+191 -5
View File
@@ -16,6 +16,7 @@ from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.core.rbac.permission_guard import require_permission from app.modules.core.rbac.permission_guard import require_permission
from app.modules.documents.services import build_document_scope, get_active_storage_node_for_branch from app.modules.documents.services import build_document_scope, get_active_storage_node_for_branch
from app.modules.accounting.agent_bridge import request_agent_command from app.modules.accounting.agent_bridge import request_agent_command
from app.modules.registrations.models import ClientRegistration, RegistrationType
router = APIRouter(prefix="/tools/tally", tags=["accounting-tally-ui"]) router = APIRouter(prefix="/tools/tally", tags=["accounting-tally-ui"])
@@ -54,6 +55,33 @@ def _find_visible_client(db, request: Request, user, client_id: int):
return client, clients, scope return client, clients, scope
def _client_registrations(db, client, tenant_id: int):
rows = db.execute(
select(ClientRegistration, RegistrationType)
.join(RegistrationType, RegistrationType.id == ClientRegistration.registration_type_id)
.where(
ClientRegistration.tenant_id == tenant_id,
ClientRegistration.client_id == client.id,
)
.order_by(RegistrationType.sort_order.asc(), ClientRegistration.id.asc())
).all()
return rows
def _registration_payload(registration, registration_type) -> dict:
return {
"id": int(registration.id),
"registration_type_id": int(registration.registration_type_id),
"registration_type_code": str(getattr(registration_type, "code", "") or "").strip(),
"registration_type_name": str(getattr(registration_type, "name", "") or "").strip(),
"registration_number": str(getattr(registration, "registration_number", "") or "").strip(),
"legal_name": str(getattr(registration, "legal_name", "") or "").strip(),
"trade_name": str(getattr(registration, "trade_name", "") or "").strip(),
"business_unit_id": getattr(registration, "business_unit_id", None),
"client_branch_id": getattr(registration, "client_branch_id", None),
}
def _node_online(node) -> bool: def _node_online(node) -> bool:
if not node or not node.last_seen_at_utc: if not node or not node.last_seen_at_utc:
return False return False
@@ -79,7 +107,15 @@ def _render(request: Request, db, user, **context):
@router.get("") @router.get("")
def tally_tool(request: Request, client_id: int | None = None, refresh: int = 0, initialized: int = 0, error: str = ""): def tally_tool(
request: Request,
client_id: int | None = None,
refresh: int = 0,
initialized: int = 0,
mapped: int = 0,
unmapped: int = 0,
error: str = "",
):
db = CommonSessionLocal() db = CommonSessionLocal()
try: try:
user, response = _require_partner(request, db, "accounting.tally.view") user, response = _require_partner(request, db, "accounting.tally.view")
@@ -88,17 +124,21 @@ def tally_tool(request: Request, client_id: int | None = None, refresh: int = 0,
clients, scope = _visible_clients(db, request, user) clients, scope = _visible_clients(db, request, user)
selected_client = next((row for row in clients if client_id and int(row.id) == int(client_id)), None) selected_client = next((row for row in clients if client_id and int(row.id) == int(client_id)), None)
registrations = _client_registrations(db, selected_client, scope.tenant_id) if selected_client else []
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
online = _node_online(node) online = _node_online(node)
live_result = None live_result = None
command_error = error or "" command_error = error or ""
should_query_agent = bool(refresh or selected_client)
if refresh: if refresh:
try: try:
require_permission(db, user, "accounting.tally.connect") require_permission(db, user, "accounting.tally.connect")
except Exception: except Exception:
return _denied() return _denied()
if refresh and node and online:
if should_query_agent and node and online:
payload = {} payload = {}
if selected_client: if selected_client:
payload = { payload = {
@@ -106,7 +146,12 @@ def tally_tool(request: Request, client_id: int | None = None, refresh: int = 0,
"client_name": selected_client.client_name, "client_name": selected_client.client_name,
} }
try: try:
response_data = request_agent_command(node.node_code, "phase1_status", payload, timeout_seconds=20) response_data = request_agent_command(
node.node_code,
"phase2_status",
payload,
timeout_seconds=20,
)
if response_data.get("ok"): if response_data.get("ok"):
live_result = response_data.get("result") or {} live_result = response_data.get("result") or {}
else: else:
@@ -118,13 +163,16 @@ def tally_tool(request: Request, client_id: int | None = None, refresh: int = 0,
request, request,
db, db,
user, user,
title="Tally Connection", title="Tally Company Mapping",
clients=clients, clients=clients,
selected_client=selected_client, selected_client=selected_client,
registrations=registrations,
storage_node=node, storage_node=node,
agent_online=online, agent_online=online,
live_result=live_result, live_result=live_result,
initialized=bool(initialized), initialized=bool(initialized),
mapped=bool(mapped),
unmapped=bool(unmapped),
command_error=command_error, command_error=command_error,
) )
finally: finally:
@@ -156,7 +204,12 @@ def initialize_accounting_storage(
result = request_agent_command( result = request_agent_command(
node.node_code, node.node_code,
"accounting_initialize", "accounting_initialize",
{"client_id": int(client.id), "client_name": client.client_name}, {
"client_id": int(client.id),
"client_name": client.client_name,
"tenant_id": int(scope.tenant_id),
"requested_by_user_id": int(user.id),
},
timeout_seconds=20, timeout_seconds=20,
) )
if not result.get("ok"): if not result.get("ok"):
@@ -172,3 +225,136 @@ def initialize_accounting_storage(
) )
finally: finally:
db.close() db.close()
@router.post("/map-company")
def map_tally_company(
request: Request,
client_id: int = Form(...),
tally_guid: str = Form(...),
registration_id: str = Form(""),
allow_gstin_mismatch: bool = Form(False),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.map_company")
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,
)
registration_payload = None
if str(registration_id or "").strip():
try:
rid = int(registration_id)
except Exception:
return RedirectResponse(
url=f"/tools/tally?client_id={client.id}&error={quote('Invalid registration selection.')}",
status_code=303,
)
row = db.execute(
select(ClientRegistration, RegistrationType)
.join(RegistrationType, RegistrationType.id == ClientRegistration.registration_type_id)
.where(
ClientRegistration.id == rid,
ClientRegistration.client_id == client.id,
ClientRegistration.tenant_id == scope.tenant_id,
)
).first()
if not row:
return _denied()
registration_payload = _registration_payload(row[0], row[1])
try:
result = request_agent_command(
node.node_code,
"accounting_map_company",
{
"client_id": int(client.id),
"client_name": client.client_name,
"tenant_id": int(scope.tenant_id),
"registration": registration_payload,
"tally_guid": str(tally_guid or "").strip(),
"allow_gstin_mismatch": bool(allow_gstin_mismatch),
"mapped_by_user_id": int(user.id),
},
timeout_seconds=20,
)
if not result.get("ok"):
raise RuntimeError(str(result.get("error") or "Tally company mapping 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&mapped=1",
status_code=303,
)
finally:
db.close()
@router.post("/unmap-company")
def unmap_tally_company(
request: Request,
client_id: int = Form(...),
mapping_id: int = Form(...),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.map_company")
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_unmap_company",
{
"client_id": int(client.id),
"mapping_id": int(mapping_id),
"unmapped_by_user_id": int(user.id),
},
timeout_seconds=20,
)
if not result.get("ok"):
raise RuntimeError(str(result.get("error") or "Tally company unmapping 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&unmapped=1",
status_code=303,
)
finally:
db.close()
@@ -1,4 +1,4 @@
from __future__ import annotations from __future__ import annotations
PERMISSIONS = { PERMISSIONS = {
"system.settings.view": "View System Settings", "system.settings.view": "View System Settings",
@@ -149,6 +149,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.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",
@@ -177,3 +178,4 @@ def expand_permission_codes(code: str) -> list[str]:
if alias not in codes: if alias not in codes:
codes.append(alias) codes.append(alias)
return codes return codes
+2 -2
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path from pathlib import Path
import zipfile import zipfile
ERP_LOCAL_AGENT_VERSION = "1.2.0" ERP_LOCAL_AGENT_VERSION = "1.3.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"
@@ -54,7 +54,7 @@ def _build_zip(*, env_text: str | None, include_env: bool, include_admin_readme:
dst.writestr( dst.writestr(
"README_ERP_LOCAL_AGENT.txt", "README_ERP_LOCAL_AGENT.txt",
f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n" f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n"
"Existing storage, WebSocket tunnel, Tally and accounting .act functionality are preserved.\n" "Existing storage, WebSocket tunnel, Tally, accounting .act and local dashboard functionality are preserved.\n"
"Local dashboard: http://127.0.0.1:8788\n" "Local dashboard: http://127.0.0.1:8788\n"
"The agent checks for updates automatically but installation is always initiated by the local user.\n" "The agent checks for updates automatically but installation is always initiated by the local user.\n"
"The existing .env, data, logs, .venv and client storage are preserved during updates.\n", "The existing .env, data, logs, .venv and client storage are preserved during updates.\n",
@@ -1,4 +1,4 @@
ERP Local Agent 1.2.0 ERP Local Agent 1.3.0
Existing storage, WebSocket tunnel, Tally and client .act functionality are preserved. Existing storage, WebSocket tunnel, Tally and client .act functionality are preserved.
@@ -16,3 +16,5 @@ Local operational database:
data\agent.db data\agent.db
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.
@@ -1,2 +1,2 @@
__version__ = "1.2.0" __version__ = "1.3.0"
AGENT_NAME = "ERP Local Agent" AGENT_NAME = "ERP Local Agent"
@@ -7,7 +7,7 @@ import sqlite3
from typing import Sequence from typing import Sequence
SCHEMA_VERSION = "1" SCHEMA_VERSION = "2"
def _utc_now_iso() -> str: def _utc_now_iso() -> str:
@@ -15,7 +15,12 @@ def _utc_now_iso() -> str:
class LocalAccountingStore: class LocalAccountingStore:
"""Client-scoped SQLite .act storage under the existing branch storage root.""" """Client-scoped SQLite .act storage under the existing branch storage root.
Phase 2 persists ERP client/registration -> Tally company mappings using the
Tally GUID as the durable identifier. Existing Phase 1 databases are upgraded
in place without deleting accounting history.
"""
def __init__(self, storage_root: Path): def __init__(self, storage_root: Path):
self.root = Path(storage_root).resolve() / "Accounting" self.root = Path(storage_root).resolve() / "Accounting"
@@ -38,17 +43,43 @@ 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) db = sqlite3.connect(path, timeout=30)
db.row_factory = sqlite3.Row db.row_factory = sqlite3.Row
db.execute("PRAGMA foreign_keys=ON")
db.execute("PRAGMA journal_mode=WAL")
db.execute("PRAGMA synchronous=NORMAL")
return db return db
def initialize(self, client_id: int, client_name: str = "") -> Path: @staticmethod
def _ensure_mapping_columns(db: sqlite3.Connection) -> None:
columns = {row["name"] for row in db.execute("PRAGMA table_info(tally_company_mapping)").fetchall()}
additions = {
"registration_type_code": "TEXT NOT NULL DEFAULT ''",
"registration_number": "TEXT NOT NULL DEFAULT ''",
"registration_legal_name": "TEXT NOT NULL DEFAULT ''",
"registration_trade_name": "TEXT NOT NULL DEFAULT ''",
"business_unit_id": "INTEGER",
"client_branch_id": "INTEGER",
"mapped_by_user_id": "INTEGER",
"updated_at_utc": "TEXT",
"unmapped_at_utc": "TEXT",
"unmapped_by_user_id": "INTEGER",
}
for name, ddl in additions.items():
if name not in columns:
db.execute(f"ALTER TABLE tally_company_mapping ADD COLUMN {name} {ddl}")
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(
""" """
PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
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, value TEXT NOT NULL,
@@ -65,17 +96,32 @@ class LocalAccountingStore:
); );
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_id INTEGER NULL,
registration_type_code TEXT NOT NULL DEFAULT '',
registration_number TEXT NOT NULL DEFAULT '',
registration_legal_name TEXT NOT NULL DEFAULT '',
registration_trade_name TEXT NOT NULL DEFAULT '',
business_unit_id INTEGER,
client_branch_id INTEGER,
tally_guid TEXT NOT NULL, tally_guid TEXT NOT NULL,
company_name TEXT NOT NULL, company_name TEXT NOT NULL,
gstin TEXT NOT NULL DEFAULT '', gstin TEXT NOT NULL DEFAULT '',
is_active INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1,
created_at_utc TEXT NOT NULL, created_at_utc TEXT NOT NULL,
updated_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
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 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,
@@ -97,11 +143,14 @@ class LocalAccountingStore:
); );
""" """
) )
self._ensure_mapping_columns(db)
now = _utc_now_iso() now = _utc_now_iso()
meta = { meta = {
"schema_version": SCHEMA_VERSION, "schema_version": SCHEMA_VERSION,
"client_id": str(int(client_id)), "client_id": str(int(client_id)),
"tenant_id": "" if tenant_id is None else str(int(tenant_id)),
"client_name": str(client_name or "").strip(), "client_name": str(client_name or "").strip(),
"created_by_user_id": "" if created_by_user_id is None else str(int(created_by_user_id)),
"storage_kind": "client_accounting_act", "storage_kind": "client_accounting_act",
} }
for key, value in meta.items(): for key, value in meta.items():
@@ -153,18 +202,159 @@ class LocalAccountingStore:
), ),
) )
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:
if not self.exists(client_id):
raise ValueError("Accounting storage is not initialized for this client.")
guid = str(tally_guid or "").strip()
name = str(company_name or "").strip()
if not guid:
raise ValueError("Tally company GUID is required for permanent mapping.")
if not name:
raise ValueError("Tally company name is required.")
registration = registration or {}
registration_id = registration.get("id")
registration_id = int(registration_id) if registration_id not in (None, "") else None
now = _utc_now_iso()
with self.connect(client_id) as db:
if registration_id is None:
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)),
)
else:
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),
)
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(
"""INSERT INTO tally_company_mapping(
client_id, registration_id, registration_type_code,
registration_number, registration_legal_name,
registration_trade_name, business_unit_id, client_branch_id,
tally_guid, company_name, gstin, is_active,
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)
return self.get_mapping(client_id, mapping_id)
def get_mapping(self, client_id: int, mapping_id: int) -> dict:
with self.connect(client_id) as db:
row = db.execute(
"SELECT * FROM tally_company_mapping WHERE id=? AND client_id=? LIMIT 1",
(int(mapping_id), int(client_id)),
).fetchone()
if not row:
raise ValueError("Tally company mapping was not found.")
return dict(row)
def unmap_company(self, client_id: int, mapping_id: int, unmapped_by_user_id: int | None = None) -> dict:
if not self.exists(client_id):
raise ValueError("Accounting storage is not initialized for this client.")
now = _utc_now_iso()
with self.connect(client_id) as db:
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()
if not row:
raise ValueError("Active Tally company mapping was not found.")
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)),
)
return {"mapping_id": int(mapping_id), "unmapped": True}
def snapshot(self, client_id: int) -> dict: def snapshot(self, client_id: int) -> dict:
path = self.db_path(client_id) path = self.db_path(client_id)
if not path.is_file(): if not path.is_file():
return {"exists": False, "db_path": str(path), "metadata": {}, "latest_connection": None} return {
"exists": False,
"db_path": str(path),
"metadata": {},
"latest_connection": None,
"companies": [],
"mappings": [],
}
# initialize() upgrades older Phase 1 schema in-place
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(
"""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(
"""SELECT id, client_id, registration_id, registration_type_code,
registration_number, registration_legal_name,
registration_trade_name, business_unit_id, client_branch_id,
tally_guid, company_name, gstin, is_active,
created_at_utc AS mapped_at_utc, updated_at_utc,
mapped_by_user_id, unmapped_at_utc, unmapped_by_user_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()
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" "SELECT checked_at_utc, connected, tally_url, company_count, error_message FROM tally_connection_history 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)}
mapped = []
for row in mappings:
item = dict(row)
item["currently_loaded"] = bool(item.get("tally_guid") and item["tally_guid"] in loaded_guids)
mapped.append(item)
return { return {
"exists": True, "exists": True,
"db_path": str(path), "db_path": str(path),
"metadata": {row["key"]: row["value"] for row in meta_rows}, "metadata": {row["key"]: row["value"] for row in meta_rows},
"latest_connection": dict(latest) if latest else None, "latest_connection": dict(latest) if latest else None,
"companies": [dict(row) for row in companies],
"mappings": mapped,
} }
@@ -23,12 +23,14 @@ class AgentCommandProcessor:
error: str | None = None error: str | None = None
ok = False ok = False
try: try:
if action == "tally_status": if action in {"tally_status", "phase1_status", "phase2_status"}:
result = self._status(payload)
elif action == "phase1_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)
elif action == "accounting_map_company":
result = self._map_company(payload)
elif action == "accounting_unmap_company":
result = self._unmap_company(payload)
else: else:
raise ValueError(f"Unsupported local-agent command: {action}") raise ValueError(f"Unsupported local-agent command: {action}")
ok = True ok = True
@@ -44,6 +46,15 @@ class AgentCommandProcessor:
"agent_time_utc": datetime.now(timezone.utc).isoformat(), "agent_time_utc": datetime.now(timezone.utc).isoformat(),
} }
def _agent_info(self) -> dict[str, Any]:
return {
"name": "ERP Local Agent",
"version": __version__,
"tally_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]:
tally_status = self.tally.status() tally_status = self.tally.status()
client_id = payload.get("client_id") client_id = payload.get("client_id")
@@ -54,12 +65,7 @@ class AgentCommandProcessor:
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": { "agent": self._agent_info(),
"name": "ERP Local Agent",
"version": __version__,
"tally_capability": True,
"accounting_act_capability": True,
},
"tally": tally_status, "tally": tally_status,
"accounting": accounting, "accounting": accounting,
} }
@@ -67,7 +73,14 @@ class AgentCommandProcessor:
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()
path = self.store.initialize(client_id, client_name) tenant_id = payload.get("tenant_id")
created_by_user_id = payload.get("requested_by_user_id")
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,
)
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 {
@@ -75,5 +88,85 @@ class AgentCommandProcessor:
"db_path": str(path), "db_path": str(path),
"accounting": self.store.snapshot(client_id), "accounting": self.store.snapshot(client_id),
"tally": tally_status, "tally": tally_status,
"agent": {"name": "ERP Local Agent", "version": __version__, "tally_capability": True, "accounting_act_capability": True}, "agent": self._agent_info(),
}
def _map_company(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id"))
client_name = str(payload.get("client_name") or "").strip()
tenant_id = payload.get("tenant_id")
mapped_by_user_id = payload.get("mapped_by_user_id")
requested_guid = str(payload.get("tally_guid") or "").strip()
registration = payload.get("registration") or None
allow_gstin_mismatch = bool(payload.get("allow_gstin_mismatch"))
if not requested_guid:
raise ValueError("Select a Tally company before mapping.")
if not self.store.exists(client_id):
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,
)
tally_status = self.tally.status()
if not tally_status.get("connected"):
raise ValueError(str(tally_status.get("error") or "TallyPrime is not connected."))
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:
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_gstin = str(company.get("gstin") or "").strip().upper()
if not 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:
reg_type = str(registration.get("registration_type_code") 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:
raise ValueError(
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)
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,
)
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]:
client_id = int(payload.get("client_id"))
mapping_id = int(payload.get("mapping_id"))
unmapped_by_user_id = payload.get("unmapped_by_user_id")
result = self.store.unmap_company(
client_id,
mapping_id,
int(unmapped_by_user_id) if unmapped_by_user_id not in (None, "") else None,
)
return {
**result,
"accounting": self.store.snapshot(client_id),
"agent": self._agent_info(),
} }