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