diff --git a/app/core/startup.py b/app/core/startup.py
index 36cc94c..f2ad1d2 100644
--- a/app/core/startup.py
+++ b/app/core/startup.py
@@ -1,4 +1,4 @@
-from __future__ import annotations
+from __future__ import annotations
from fastapi import FastAPI
from datetime import date, datetime, timezone
@@ -335,6 +335,7 @@ ROLE_PERMISSION_MAP = {
"documents.delete",
"accounting.tally.view",
"accounting.tally.connect",
+ "accounting.tally.map_company",
"accounting.act.initialize",
"clients.view.own_only",
"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.
start_notification_scheduler()
+
diff --git a/app/modules/accounting/act_store.py b/app/modules/accounting/act_store.py
index ed892df..09034d6 100644
--- a/app/modules/accounting/act_store.py
+++ b/app/modules/accounting/act_store.py
@@ -8,7 +8,7 @@ import os
import sqlite3
from typing import Iterator, Sequence
-ACT_SCHEMA_VERSION = 1
+ACT_SCHEMA_VERSION = 2
class AccountingActStoreError(RuntimeError):
@@ -29,10 +29,10 @@ def _safe_client_id(client_id: int) -> int:
class AccountingActStore:
"""Client-scoped SQLite storage for ERP accounting data.
- The `.act` extension is intentional; the underlying file format is SQLite.
- Phase 1 stores metadata, discovered Tally companies, connection history and
- sync-run control records only. Transaction/master tables arrive in later
- phases.
+ Phase 1 provides accounting storage and read-only Tally discovery.
+ Phase 2 adds durable client/registration -> Tally company mapping keyed by
+ Tally GUID. Accounting master/transaction sync and write-back remain out of
+ scope.
"""
def __init__(self, root: str | Path) -> None:
@@ -97,12 +97,21 @@ class AccountingActStore:
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL,
registration_id INTEGER,
- tally_guid TEXT NOT NULL DEFAULT '',
- company_name TEXT NOT NULL DEFAULT '',
+ 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,
+ company_name TEXT NOT NULL,
gstin TEXT NOT NULL DEFAULT '',
is_active INTEGER NOT NULL DEFAULT 1,
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 (
@@ -129,12 +138,17 @@ class AccountingActStore:
CREATE INDEX IF NOT EXISTS ix_tally_companies_last_seen
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
ON tally_connection_history(checked_at_utc);
CREATE INDEX IF NOT EXISTS ix_tally_sync_runs_started
ON tally_sync_runs(started_at_utc);
"""
)
+ self._ensure_mapping_columns(db)
now = _utc_now_iso()
metadata = {
"schema_version": str(ACT_SCHEMA_VERSION),
@@ -157,6 +171,24 @@ class AccountingActStore:
)
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:
self.initialize(client_id)
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:
self.initialize(client_id)
with self.connect(client_id) as db:
@@ -226,6 +375,20 @@ class AccountingActStore:
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,
+ 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(
"""
SELECT checked_at_utc, connected, tally_url, company_count, error_message
@@ -233,10 +396,17 @@ class AccountingActStore:
ORDER BY id DESC LIMIT 1
"""
).fetchone()
+ loaded_guids = {str(row["guid"] or "") for row in companies if int(row["is_currently_loaded"] or 0)}
+ 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 {
"db_path": str(self.db_path(client_id)),
"metadata": {row["key"]: row["value"] for row in meta_rows},
"companies": [dict(row) for row in companies],
+ "mappings": mapping_rows,
"latest_connection": dict(latest) if latest else None,
}
diff --git a/app/modules/accounting/templates/accounting/tally.html b/app/modules/accounting/templates/accounting/tally.html
index ee72ae2..51d58a0 100644
--- a/app/modules/accounting/templates/accounting/tally.html
+++ b/app/modules/accounting/templates/accounting/tally.html
@@ -4,78 +4,228 @@
Tools · Accounting
-
Tally Connection
-
Read-only Phase 1 connection through the existing ERP Local Agent. Tally port 9000 is never exposed to the internet.
+
Tally Company Mapping
+
Phase 2 permanently maps an ERP client or registration to a loaded TallyPrime company using the Tally GUID.
-
Test Tally Connection
+
Refresh Tally Companies
{% if initialized %}
Client accounting storage was initialized successfully.
{% endif %}
+ {% if mapped %}
+ Tally company mapping was saved successfully.
+ {% endif %}
+ {% if unmapped %}
+ Tally company mapping was removed.
+ {% endif %}
{% if command_error %}
{{ command_error }}
{% 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 %}
+
ERP Local Agent
-
{% if agent_online %}Connected{% else %}Offline{% endif %}
+
{{ 'Connected' if agent_online else 'Offline' }}
{{ storage_node.node_name if storage_node else 'No active branch agent' }}
-
Tally Module
- {% set agent = live_result.agent if live_result else None %}
-
{{ 'Available' if agent and agent.tally_capability else ('Check connection' if agent_online else 'Unavailable') }}
+
Mapping Module
+
+ {{ 'Available' if agent and agent.tally_mapping_capability else ('Update Agent' if agent_online else 'Unavailable') }}
+
Agent {{ agent.version if agent and agent.version else '-' }}
TallyPrime
- {% set tally = live_result.tally if live_result else None %}
-
{{ 'Connected' if tally and tally.connected else ('Not connected' if tally else 'Not checked') }}
+
+ {{ 'Connected' if tally and tally.connected else ('Not connected' if tally else 'Not checked') }}
+
{{ tally.url if tally and tally.url else '127.0.0.1:9000' }}
Loaded Companies
{{ tally.company_count if tally else '-' }}
-
Read-only discovery
+
GUID-based discovery
-
-
- {% if selected_client %}
-
- {% endif %}
-
+
Only clients assigned to the logged-in Partner in the active branch are shown.
+ {% if selected_client %}
+
+
+
+
Accounting Storage
+
{{ selected_client.client_name }}
+
+
+ {{ 'Initialized' if accounting and accounting.exists else 'Not initialized' }}
+
+
+
+ {% if accounting and accounting.exists %}
+
+ - .act Database
- {{ accounting.db_path }}
+ - Schema Version
- {{ accounting.metadata.schema_version or '2' }}
+
+ {% else %}
+
+ {% endif %}
+
+
+ {% if accounting and accounting.exists and tally and tally.connected %}
+
+
+
Map ERP Scope to Tally Company
+
The Tally GUID is stored as the permanent identifier. Company name and GSTIN are retained as descriptive snapshots.
+
+
+
+
+ {% endif %}
+
+ {% if accounting and accounting.exists %}
+
+
+
Active Tally Mappings
+
Mappings remain valid across Tally company renames because the GUID is the persistent key.
+
+ {% if accounting.mappings %}
+
+
+
+
+ | ERP Scope |
+ Tally Company |
+ GUID |
+ GSTIN |
+ Loaded |
+ Action |
+
+
+
+ {% for mapping in accounting.mappings %}
+
+ |
+ {% if mapping.registration_id %}
+ {{ mapping.registration_type_code or 'Registration' }} · {{ mapping.registration_number or mapping.registration_id }}
+ {{ mapping.registration_trade_name or mapping.registration_legal_name or selected_client.client_name }}
+ {% else %}
+ Client level
+ {{ selected_client.client_name }}
+ {% endif %}
+ |
+ {{ mapping.company_name }} |
+ {{ mapping.tally_guid }} |
+ {{ mapping.gstin or '-' }} |
+
+
+ {{ 'Yes' if mapping.currently_loaded else 'No' }}
+
+ |
+
+
+ |
+
+ {% endfor %}
+
+
+
+ {% else %}
+ No Tally company has been mapped for this client yet.
+ {% endif %}
+
+ {% endif %}
+
{% if tally %}
Loaded Tally Companies
-
Company name, GUID and GSTIN returned directly by the local TallyPrime instance.
+
Read-only company discovery from the local TallyPrime instance.
{% if tally.companies %}
| Company | GUID | GSTIN |
- {% for company in tally.companies %}| {{ company.name }} | {{ company.guid or '-' }} | {{ company.gstin or '-' }} |
{% endfor %}
+
+ {% for company in tally.companies %}
+
+ | {{ company.name }} |
+ {{ company.guid or '-' }} |
+ {{ company.gstin or '-' }} |
+
+ {% endfor %}
+
{% else %}
@@ -83,23 +233,10 @@
{% endif %}
{% endif %}
-
- {% if selected_client %}
- {% set accounting = live_result.accounting if live_result else None %}
-
-
-
Accounting Storage
{{ selected_client.client_name }}
-
{{ 'Initialized' if accounting and accounting.exists else 'Not initialized / not checked' }}
-
- {% if accounting and accounting.exists %}
-
- - .act Database
- {{ accounting.db_path }}
- - Schema Version
- {{ accounting.metadata.schema_version or '1' }}
-
- {% endif %}
-
{% endif %}
- Phase 1 is read-only. No ledger, voucher, inventory or write-back operation is performed.
+
+ Phase 2 only stores company mappings. Ledger masters, vouchers, inventory and Tally write-back remain disabled.
+
{% endblock %}
diff --git a/app/modules/accounting/ui.py b/app/modules/accounting/ui.py
index 80ef990..eb1ba69 100644
--- a/app/modules/accounting/ui.py
+++ b/app/modules/accounting/ui.py
@@ -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.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.registrations.models import ClientRegistration, RegistrationType
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
+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:
if not node or not node.last_seen_at_utc:
return False
@@ -79,7 +107,15 @@ def _render(request: Request, db, user, **context):
@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()
try:
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)
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)
online = _node_online(node)
live_result = None
command_error = error or ""
+ should_query_agent = bool(refresh or selected_client)
+
if refresh:
try:
require_permission(db, user, "accounting.tally.connect")
except Exception:
return _denied()
- if refresh and node and online:
+
+ if should_query_agent and node and online:
payload = {}
if selected_client:
payload = {
@@ -106,7 +146,12 @@ def tally_tool(request: Request, client_id: int | None = None, refresh: int = 0,
"client_name": selected_client.client_name,
}
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"):
live_result = response_data.get("result") or {}
else:
@@ -118,13 +163,16 @@ def tally_tool(request: Request, client_id: int | None = None, refresh: int = 0,
request,
db,
user,
- title="Tally Connection",
+ title="Tally Company Mapping",
clients=clients,
selected_client=selected_client,
+ registrations=registrations,
storage_node=node,
agent_online=online,
live_result=live_result,
initialized=bool(initialized),
+ mapped=bool(mapped),
+ unmapped=bool(unmapped),
command_error=command_error,
)
finally:
@@ -156,7 +204,12 @@ def initialize_accounting_storage(
result = request_agent_command(
node.node_code,
"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,
)
if not result.get("ok"):
@@ -172,3 +225,136 @@ def initialize_accounting_storage(
)
finally:
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()
diff --git a/app/modules/core/rbac/permissions_registry.py b/app/modules/core/rbac/permissions_registry.py
index b052473..5138df4 100644
--- a/app/modules/core/rbac/permissions_registry.py
+++ b/app/modules/core/rbac/permissions_registry.py
@@ -1,4 +1,4 @@
-from __future__ import annotations
+from __future__ import annotations
PERMISSIONS = {
"system.settings.view": "View System Settings",
@@ -149,6 +149,7 @@ PERMISSIONS = {
"accounting.tally.view": "View Tally Accounting Tool",
"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",
"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:
codes.append(alias)
return codes
+
diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py
index b45a8ee..2e520c4 100644
--- a/app/modules/documents/agent_package.py
+++ b/app/modules/documents/agent_package.py
@@ -4,7 +4,7 @@ import io
from pathlib import Path
import zipfile
-ERP_LOCAL_AGENT_VERSION = "1.2.0"
+ERP_LOCAL_AGENT_VERSION = "1.3.0"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
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(
"README_ERP_LOCAL_AGENT.txt",
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"
"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",
diff --git a/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt b/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt
index ef4f37a..7f9d5db 100644
--- a/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt
+++ b/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt
@@ -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.
@@ -16,3 +16,5 @@ Local operational database:
data\agent.db
Client accounting .act databases remain separate under the configured STORAGE_ROOT.
+
+Phase 2: client/registration to Tally company mapping is supported using Tally GUID.
diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py
index 90bfaca..893359a 100644
--- a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py
+++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py
@@ -1,2 +1,2 @@
-__version__ = "1.2.0"
+__version__ = "1.3.0"
AGENT_NAME = "ERP Local Agent"
diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py
index 0814a75..59e5226 100644
--- a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py
+++ b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py
@@ -7,7 +7,7 @@ import sqlite3
from typing import Sequence
-SCHEMA_VERSION = "1"
+SCHEMA_VERSION = "2"
def _utc_now_iso() -> str:
@@ -15,7 +15,12 @@ def _utc_now_iso() -> str:
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):
self.root = Path(storage_root).resolve() / "Accounting"
@@ -38,17 +43,43 @@ class LocalAccountingStore:
def connect(self, client_id: int):
path = self.db_path(client_id)
path.parent.mkdir(parents=True, exist_ok=True)
- db = sqlite3.connect(path)
+ db = sqlite3.connect(path, timeout=30)
db.row_factory = sqlite3.Row
+ db.execute("PRAGMA foreign_keys=ON")
+ db.execute("PRAGMA journal_mode=WAL")
+ db.execute("PRAGMA synchronous=NORMAL")
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)
with self.connect(client_id) as db:
db.executescript(
"""
- PRAGMA journal_mode=WAL;
- PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS act_meta (
key TEXT PRIMARY KEY,
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_name ON tally_companies(company_name);
+
CREATE TABLE IF NOT EXISTS tally_company_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT 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,
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
+ 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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
checked_at_utc TEXT NOT NULL,
@@ -97,11 +143,14 @@ class LocalAccountingStore:
);
"""
)
+ self._ensure_mapping_columns(db)
now = _utc_now_iso()
meta = {
"schema_version": SCHEMA_VERSION,
"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(),
+ "created_by_user_id": "" if created_by_user_id is None else str(int(created_by_user_id)),
"storage_kind": "client_accounting_act",
}
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:
path = self.db_path(client_id)
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:
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(
"SELECT checked_at_utc, connected, tally_url, company_count, error_message FROM tally_connection_history ORDER BY id DESC LIMIT 1"
).fetchone()
+
+ loaded_guids = {str(row["guid"] or "") for row in companies if int(row["is_currently_loaded"] or 0)}
+ mapped = []
+ for row in mappings:
+ item = dict(row)
+ item["currently_loaded"] = bool(item.get("tally_guid") and item["tally_guid"] in loaded_guids)
+ mapped.append(item)
return {
"exists": True,
"db_path": str(path),
"metadata": {row["key"]: row["value"] for row in meta_rows},
"latest_connection": dict(latest) if latest else None,
+ "companies": [dict(row) for row in companies],
+ "mappings": mapped,
}
diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py
index a1eb803..662fc55 100644
--- a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py
+++ b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py
@@ -23,12 +23,14 @@ class AgentCommandProcessor:
error: str | None = None
ok = False
try:
- if action == "tally_status":
- result = self._status(payload)
- elif action == "phase1_status":
+ if action in {"tally_status", "phase1_status", "phase2_status"}:
result = self._status(payload)
elif action == "accounting_initialize":
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:
raise ValueError(f"Unsupported local-agent command: {action}")
ok = True
@@ -44,6 +46,15 @@ class AgentCommandProcessor:
"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]:
tally_status = self.tally.status()
client_id = payload.get("client_id")
@@ -54,12 +65,7 @@ class AgentCommandProcessor:
self.store.record_tally_status(client_id, tally_status)
accounting = self.store.snapshot(client_id)
return {
- "agent": {
- "name": "ERP Local Agent",
- "version": __version__,
- "tally_capability": True,
- "accounting_act_capability": True,
- },
+ "agent": self._agent_info(),
"tally": tally_status,
"accounting": accounting,
}
@@ -67,7 +73,14 @@ class AgentCommandProcessor:
def _initialize(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id"))
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()
self.store.record_tally_status(client_id, tally_status)
return {
@@ -75,5 +88,85 @@ class AgentCommandProcessor:
"db_path": str(path),
"accounting": self.store.snapshot(client_id),
"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(),
}