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
+178 -8
View File
@@ -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,
}
@@ -4,78 +4,228 @@
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<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>
<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>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Tally Company Mapping</h1>
<p class="mt-1 text-sm text-slate-500">Phase 2 permanently maps an ERP client or registration to a loaded TallyPrime company using the Tally GUID.</p>
</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>
{% 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>
{% 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 %}
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">{{ command_error }}</div>
{% 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">
<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="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>
<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>
{% set agent = live_result.agent if live_result else None %}
<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>
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Mapping Module</div>
<div class="mt-2 text-lg font-semibold {% if agent and agent.tally_mapping_capability %}text-emerald-700{% else %}text-slate-700{% endif %}">
{{ '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>
<div class="rounded-2xl bg-white p-5 shadow-soft">
<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 %}">{{ 'Connected' if tally and tally.connected else ('Not connected' if tally else 'Not checked') }}</div>
<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>
<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 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="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>
</section>
<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-1 flex-wrap items-end gap-3">
<label class="min-w-[280px] flex-1 text-sm font-medium text-slate-700">
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">
<option value="">Select client</option>
{% for client in clients %}<option value="{{ client.id }}" {% if selected_client and selected_client.id == client.id %}selected{% endif %}>{{ client.client_name }}</option>{% endfor %}
</select>
</label>
<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>
</form>
{% if selected_client %}
<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>
<form method="get" action="/tools/tally" class="flex flex-wrap items-end gap-3">
<label class="min-w-[280px] flex-1 text-sm font-medium text-slate-700">
ERP 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">
<option value="">Select client</option>
{% for client in clients %}
<option value="{{ client.id }}" {% if selected_client and selected_client.id == client.id %}selected{% endif %}>{{ client.client_name }}</option>
{% endfor %}
</select>
</label>
<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>
</form>
<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>
{% 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 %}
<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">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>
{% if tally.companies %}
<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">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>
</div>
{% else %}
@@ -83,23 +233,10 @@
{% endif %}
</section>
{% 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 %}
<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>
{% 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.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()