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 %} +
+ + + + + + + + + + + + + {% for mapping in accounting.mappings %} + + + + + + + + + {% endfor %} + +
ERP ScopeTally CompanyGUIDGSTINLoadedAction
+ {% 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' }} + + +
+ + + + +
+
+
+ {% 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 %}
- {% for company in tally.companies %}{% endfor %} + + {% for company in tally.companies %} + + + + + + {% endfor %} +
CompanyGUIDGSTIN
{{ company.name }}{{ company.guid or '-' }}{{ company.gstin or '-' }}
{{ company.name }}{{ company.guid or '-' }}{{ company.gstin or '-' }}
{% 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(), }