Add Phase 2 Tally company mapping
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
ERP Local Agent 1.2.0
|
||||
ERP Local Agent 1.3.0
|
||||
|
||||
Existing storage, WebSocket tunnel, Tally and client .act functionality are preserved.
|
||||
|
||||
@@ -16,3 +16,5 @@ Local operational database:
|
||||
data\agent.db
|
||||
|
||||
Client accounting .act databases remain separate under the configured STORAGE_ROOT.
|
||||
|
||||
Phase 2: client/registration to Tally company mapping is supported using Tally GUID.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
__version__ = "1.2.0"
|
||||
__version__ = "1.3.0"
|
||||
AGENT_NAME = "ERP Local Agent"
|
||||
|
||||
@@ -7,7 +7,7 @@ import sqlite3
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
SCHEMA_VERSION = "1"
|
||||
SCHEMA_VERSION = "2"
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
@@ -15,7 +15,12 @@ def _utc_now_iso() -> str:
|
||||
|
||||
|
||||
class LocalAccountingStore:
|
||||
"""Client-scoped SQLite .act storage under the existing branch storage root."""
|
||||
"""Client-scoped SQLite .act storage under the existing branch storage root.
|
||||
|
||||
Phase 2 persists ERP client/registration -> Tally company mappings using the
|
||||
Tally GUID as the durable identifier. Existing Phase 1 databases are upgraded
|
||||
in place without deleting accounting history.
|
||||
"""
|
||||
|
||||
def __init__(self, storage_root: Path):
|
||||
self.root = Path(storage_root).resolve() / "Accounting"
|
||||
@@ -38,17 +43,43 @@ class LocalAccountingStore:
|
||||
def connect(self, client_id: int):
|
||||
path = self.db_path(client_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
db = sqlite3.connect(path)
|
||||
db = sqlite3.connect(path, timeout=30)
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("PRAGMA synchronous=NORMAL")
|
||||
return db
|
||||
|
||||
def initialize(self, client_id: int, client_name: str = "") -> Path:
|
||||
@staticmethod
|
||||
def _ensure_mapping_columns(db: sqlite3.Connection) -> None:
|
||||
columns = {row["name"] for row in db.execute("PRAGMA table_info(tally_company_mapping)").fetchall()}
|
||||
additions = {
|
||||
"registration_type_code": "TEXT NOT NULL DEFAULT ''",
|
||||
"registration_number": "TEXT NOT NULL DEFAULT ''",
|
||||
"registration_legal_name": "TEXT NOT NULL DEFAULT ''",
|
||||
"registration_trade_name": "TEXT NOT NULL DEFAULT ''",
|
||||
"business_unit_id": "INTEGER",
|
||||
"client_branch_id": "INTEGER",
|
||||
"mapped_by_user_id": "INTEGER",
|
||||
"updated_at_utc": "TEXT",
|
||||
"unmapped_at_utc": "TEXT",
|
||||
"unmapped_by_user_id": "INTEGER",
|
||||
}
|
||||
for name, ddl in additions.items():
|
||||
if name not in columns:
|
||||
db.execute(f"ALTER TABLE tally_company_mapping ADD COLUMN {name} {ddl}")
|
||||
|
||||
def initialize(
|
||||
self,
|
||||
client_id: int,
|
||||
client_name: str = "",
|
||||
tenant_id: int | None = None,
|
||||
created_by_user_id: int | None = None,
|
||||
) -> Path:
|
||||
path = self.db_path(client_id)
|
||||
with self.connect(client_id) as db:
|
||||
db.executescript(
|
||||
"""
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA foreign_keys=ON;
|
||||
CREATE TABLE IF NOT EXISTS act_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
@@ -65,17 +96,32 @@ class LocalAccountingStore:
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_tally_companies_guid ON tally_companies(tally_guid);
|
||||
CREATE INDEX IF NOT EXISTS ix_tally_companies_name ON tally_companies(company_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tally_company_mapping (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id INTEGER NOT NULL,
|
||||
registration_id INTEGER NULL,
|
||||
registration_type_code TEXT NOT NULL DEFAULT '',
|
||||
registration_number TEXT NOT NULL DEFAULT '',
|
||||
registration_legal_name TEXT NOT NULL DEFAULT '',
|
||||
registration_trade_name TEXT NOT NULL DEFAULT '',
|
||||
business_unit_id INTEGER,
|
||||
client_branch_id INTEGER,
|
||||
tally_guid TEXT NOT NULL,
|
||||
company_name TEXT NOT NULL,
|
||||
gstin TEXT NOT NULL DEFAULT '',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at_utc TEXT NOT NULL,
|
||||
updated_at_utc TEXT NOT NULL
|
||||
updated_at_utc TEXT NOT NULL,
|
||||
mapped_by_user_id INTEGER,
|
||||
unmapped_at_utc TEXT,
|
||||
unmapped_by_user_id INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_tally_company_mapping_client_active
|
||||
ON tally_company_mapping(client_id, is_active);
|
||||
CREATE INDEX IF NOT EXISTS ix_tally_company_mapping_guid
|
||||
ON tally_company_mapping(tally_guid, is_active);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tally_connection_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
checked_at_utc TEXT NOT NULL,
|
||||
@@ -97,11 +143,14 @@ class LocalAccountingStore:
|
||||
);
|
||||
"""
|
||||
)
|
||||
self._ensure_mapping_columns(db)
|
||||
now = _utc_now_iso()
|
||||
meta = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"client_id": str(int(client_id)),
|
||||
"tenant_id": "" if tenant_id is None else str(int(tenant_id)),
|
||||
"client_name": str(client_name or "").strip(),
|
||||
"created_by_user_id": "" if created_by_user_id is None else str(int(created_by_user_id)),
|
||||
"storage_kind": "client_accounting_act",
|
||||
}
|
||||
for key, value in meta.items():
|
||||
@@ -153,18 +202,159 @@ class LocalAccountingStore:
|
||||
),
|
||||
)
|
||||
|
||||
def map_company(
|
||||
self,
|
||||
client_id: int,
|
||||
*,
|
||||
tally_guid: str,
|
||||
company_name: str,
|
||||
gstin: str = "",
|
||||
registration: dict | None = None,
|
||||
mapped_by_user_id: int | None = None,
|
||||
) -> dict:
|
||||
if not self.exists(client_id):
|
||||
raise ValueError("Accounting storage is not initialized for this client.")
|
||||
guid = str(tally_guid or "").strip()
|
||||
name = str(company_name or "").strip()
|
||||
if not guid:
|
||||
raise ValueError("Tally company GUID is required for permanent mapping.")
|
||||
if not name:
|
||||
raise ValueError("Tally company name is required.")
|
||||
|
||||
registration = registration or {}
|
||||
registration_id = registration.get("id")
|
||||
registration_id = int(registration_id) if registration_id not in (None, "") else None
|
||||
now = _utc_now_iso()
|
||||
|
||||
with self.connect(client_id) as db:
|
||||
if registration_id is None:
|
||||
db.execute(
|
||||
"""UPDATE tally_company_mapping
|
||||
SET is_active=0, updated_at_utc=?, unmapped_at_utc=?
|
||||
WHERE client_id=? AND registration_id IS NULL AND is_active=1""",
|
||||
(now, now, int(client_id)),
|
||||
)
|
||||
else:
|
||||
db.execute(
|
||||
"""UPDATE tally_company_mapping
|
||||
SET is_active=0, updated_at_utc=?, unmapped_at_utc=?
|
||||
WHERE client_id=? AND registration_id=? AND is_active=1""",
|
||||
(now, now, int(client_id), registration_id),
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""UPDATE tally_company_mapping
|
||||
SET is_active=0, updated_at_utc=?, unmapped_at_utc=?
|
||||
WHERE client_id=? AND tally_guid=? AND is_active=1""",
|
||||
(now, now, int(client_id), guid),
|
||||
)
|
||||
|
||||
cur = db.execute(
|
||||
"""INSERT INTO tally_company_mapping(
|
||||
client_id, registration_id, registration_type_code,
|
||||
registration_number, registration_legal_name,
|
||||
registration_trade_name, business_unit_id, client_branch_id,
|
||||
tally_guid, company_name, gstin, is_active,
|
||||
created_at_utc, updated_at_utc, mapped_by_user_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)""",
|
||||
(
|
||||
int(client_id),
|
||||
registration_id,
|
||||
str(registration.get("registration_type_code") or ""),
|
||||
str(registration.get("registration_number") or ""),
|
||||
str(registration.get("legal_name") or ""),
|
||||
str(registration.get("trade_name") or ""),
|
||||
registration.get("business_unit_id"),
|
||||
registration.get("client_branch_id"),
|
||||
guid,
|
||||
name,
|
||||
str(gstin or "").strip().upper(),
|
||||
now,
|
||||
now,
|
||||
mapped_by_user_id,
|
||||
),
|
||||
)
|
||||
mapping_id = int(cur.lastrowid)
|
||||
return self.get_mapping(client_id, mapping_id)
|
||||
|
||||
def get_mapping(self, client_id: int, mapping_id: int) -> dict:
|
||||
with self.connect(client_id) as db:
|
||||
row = db.execute(
|
||||
"SELECT * FROM tally_company_mapping WHERE id=? AND client_id=? LIMIT 1",
|
||||
(int(mapping_id), int(client_id)),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("Tally company mapping was not found.")
|
||||
return dict(row)
|
||||
|
||||
def unmap_company(self, client_id: int, mapping_id: int, unmapped_by_user_id: int | None = None) -> dict:
|
||||
if not self.exists(client_id):
|
||||
raise ValueError("Accounting storage is not initialized for this client.")
|
||||
now = _utc_now_iso()
|
||||
with self.connect(client_id) as db:
|
||||
row = db.execute(
|
||||
"SELECT id FROM tally_company_mapping WHERE id=? AND client_id=? AND is_active=1",
|
||||
(int(mapping_id), int(client_id)),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("Active Tally company mapping was not found.")
|
||||
db.execute(
|
||||
"""UPDATE tally_company_mapping
|
||||
SET is_active=0, updated_at_utc=?, unmapped_at_utc=?, unmapped_by_user_id=?
|
||||
WHERE id=? AND client_id=?""",
|
||||
(now, now, unmapped_by_user_id, int(mapping_id), int(client_id)),
|
||||
)
|
||||
return {"mapping_id": int(mapping_id), "unmapped": True}
|
||||
|
||||
def snapshot(self, client_id: int) -> dict:
|
||||
path = self.db_path(client_id)
|
||||
if not path.is_file():
|
||||
return {"exists": False, "db_path": str(path), "metadata": {}, "latest_connection": None}
|
||||
return {
|
||||
"exists": False,
|
||||
"db_path": str(path),
|
||||
"metadata": {},
|
||||
"latest_connection": None,
|
||||
"companies": [],
|
||||
"mappings": [],
|
||||
}
|
||||
|
||||
# initialize() upgrades older Phase 1 schema in-place
|
||||
self.initialize(client_id)
|
||||
with self.connect(client_id) as db:
|
||||
meta_rows = db.execute("SELECT key, value FROM act_meta ORDER BY key").fetchall()
|
||||
companies = db.execute(
|
||||
"""SELECT tally_guid AS guid, company_name AS name, gstin,
|
||||
first_seen_at_utc, last_seen_at_utc, is_currently_loaded
|
||||
FROM tally_companies
|
||||
ORDER BY is_currently_loaded DESC, company_name COLLATE NOCASE"""
|
||||
).fetchall()
|
||||
mappings = db.execute(
|
||||
"""SELECT id, client_id, registration_id, registration_type_code,
|
||||
registration_number, registration_legal_name,
|
||||
registration_trade_name, business_unit_id, client_branch_id,
|
||||
tally_guid, company_name, gstin, is_active,
|
||||
created_at_utc AS mapped_at_utc, updated_at_utc,
|
||||
mapped_by_user_id, unmapped_at_utc, unmapped_by_user_id
|
||||
FROM tally_company_mapping
|
||||
WHERE is_active=1
|
||||
ORDER BY CASE WHEN registration_id IS NULL THEN 0 ELSE 1 END,
|
||||
registration_type_code, registration_number, id"""
|
||||
).fetchall()
|
||||
latest = db.execute(
|
||||
"SELECT checked_at_utc, connected, tally_url, company_count, error_message FROM tally_connection_history ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
|
||||
loaded_guids = {str(row["guid"] or "") for row in companies if int(row["is_currently_loaded"] or 0)}
|
||||
mapped = []
|
||||
for row in mappings:
|
||||
item = dict(row)
|
||||
item["currently_loaded"] = bool(item.get("tally_guid") and item["tally_guid"] in loaded_guids)
|
||||
mapped.append(item)
|
||||
return {
|
||||
"exists": True,
|
||||
"db_path": str(path),
|
||||
"metadata": {row["key"]: row["value"] for row in meta_rows},
|
||||
"latest_connection": dict(latest) if latest else None,
|
||||
"companies": [dict(row) for row in companies],
|
||||
"mappings": mapped,
|
||||
}
|
||||
|
||||
@@ -23,12 +23,14 @@ class AgentCommandProcessor:
|
||||
error: str | None = None
|
||||
ok = False
|
||||
try:
|
||||
if action == "tally_status":
|
||||
result = self._status(payload)
|
||||
elif action == "phase1_status":
|
||||
if action in {"tally_status", "phase1_status", "phase2_status"}:
|
||||
result = self._status(payload)
|
||||
elif action == "accounting_initialize":
|
||||
result = self._initialize(payload)
|
||||
elif action == "accounting_map_company":
|
||||
result = self._map_company(payload)
|
||||
elif action == "accounting_unmap_company":
|
||||
result = self._unmap_company(payload)
|
||||
else:
|
||||
raise ValueError(f"Unsupported local-agent command: {action}")
|
||||
ok = True
|
||||
@@ -44,6 +46,15 @@ class AgentCommandProcessor:
|
||||
"agent_time_utc": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
def _agent_info(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": "ERP Local Agent",
|
||||
"version": __version__,
|
||||
"tally_capability": True,
|
||||
"accounting_act_capability": True,
|
||||
"tally_mapping_capability": True,
|
||||
}
|
||||
|
||||
def _status(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
tally_status = self.tally.status()
|
||||
client_id = payload.get("client_id")
|
||||
@@ -54,12 +65,7 @@ class AgentCommandProcessor:
|
||||
self.store.record_tally_status(client_id, tally_status)
|
||||
accounting = self.store.snapshot(client_id)
|
||||
return {
|
||||
"agent": {
|
||||
"name": "ERP Local Agent",
|
||||
"version": __version__,
|
||||
"tally_capability": True,
|
||||
"accounting_act_capability": True,
|
||||
},
|
||||
"agent": self._agent_info(),
|
||||
"tally": tally_status,
|
||||
"accounting": accounting,
|
||||
}
|
||||
@@ -67,7 +73,14 @@ class AgentCommandProcessor:
|
||||
def _initialize(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
client_id = int(payload.get("client_id"))
|
||||
client_name = str(payload.get("client_name") or "").strip()
|
||||
path = self.store.initialize(client_id, client_name)
|
||||
tenant_id = payload.get("tenant_id")
|
||||
created_by_user_id = payload.get("requested_by_user_id")
|
||||
path = self.store.initialize(
|
||||
client_id,
|
||||
client_name,
|
||||
int(tenant_id) if tenant_id not in (None, "") else None,
|
||||
int(created_by_user_id) if created_by_user_id not in (None, "") else None,
|
||||
)
|
||||
tally_status = self.tally.status()
|
||||
self.store.record_tally_status(client_id, tally_status)
|
||||
return {
|
||||
@@ -75,5 +88,85 @@ class AgentCommandProcessor:
|
||||
"db_path": str(path),
|
||||
"accounting": self.store.snapshot(client_id),
|
||||
"tally": tally_status,
|
||||
"agent": {"name": "ERP Local Agent", "version": __version__, "tally_capability": True, "accounting_act_capability": True},
|
||||
"agent": self._agent_info(),
|
||||
}
|
||||
|
||||
def _map_company(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
client_id = int(payload.get("client_id"))
|
||||
client_name = str(payload.get("client_name") or "").strip()
|
||||
tenant_id = payload.get("tenant_id")
|
||||
mapped_by_user_id = payload.get("mapped_by_user_id")
|
||||
requested_guid = str(payload.get("tally_guid") or "").strip()
|
||||
registration = payload.get("registration") or None
|
||||
allow_gstin_mismatch = bool(payload.get("allow_gstin_mismatch"))
|
||||
|
||||
if not requested_guid:
|
||||
raise ValueError("Select a Tally company before mapping.")
|
||||
|
||||
if not self.store.exists(client_id):
|
||||
self.store.initialize(
|
||||
client_id,
|
||||
client_name,
|
||||
int(tenant_id) if tenant_id not in (None, "") else None,
|
||||
int(mapped_by_user_id) if mapped_by_user_id not in (None, "") else None,
|
||||
)
|
||||
|
||||
tally_status = self.tally.status()
|
||||
if not tally_status.get("connected"):
|
||||
raise ValueError(str(tally_status.get("error") or "TallyPrime is not connected."))
|
||||
|
||||
companies = tally_status.get("companies") or []
|
||||
company = next(
|
||||
(row for row in companies if str(row.get("guid") or "").strip() == requested_guid),
|
||||
None,
|
||||
)
|
||||
if not company:
|
||||
raise ValueError("The selected Tally company is no longer loaded. Refresh Tally companies and try again.")
|
||||
|
||||
company_name = str(company.get("name") or "").strip()
|
||||
company_gstin = str(company.get("gstin") or "").strip().upper()
|
||||
if not company_name:
|
||||
raise ValueError("Tally returned an invalid company name.")
|
||||
if not requested_guid:
|
||||
raise ValueError("Tally returned no GUID. Permanent mapping requires a Tally GUID.")
|
||||
|
||||
if registration:
|
||||
reg_type = str(registration.get("registration_type_code") or "").strip().upper()
|
||||
reg_number = str(registration.get("registration_number") or "").strip().upper()
|
||||
if reg_type == "GSTIN" and reg_number and company_gstin and reg_number != company_gstin and not allow_gstin_mismatch:
|
||||
raise ValueError(
|
||||
f"GSTIN mismatch: ERP registration is {reg_number}, but Tally company reports {company_gstin}. "
|
||||
"Verify the company or explicitly allow the mismatch."
|
||||
)
|
||||
|
||||
self.store.record_tally_status(client_id, tally_status)
|
||||
mapping = self.store.map_company(
|
||||
client_id,
|
||||
tally_guid=requested_guid,
|
||||
company_name=company_name,
|
||||
gstin=company_gstin,
|
||||
registration=registration,
|
||||
mapped_by_user_id=int(mapped_by_user_id) if mapped_by_user_id not in (None, "") else None,
|
||||
)
|
||||
return {
|
||||
"mapped": True,
|
||||
"mapping": mapping,
|
||||
"accounting": self.store.snapshot(client_id),
|
||||
"tally": tally_status,
|
||||
"agent": self._agent_info(),
|
||||
}
|
||||
|
||||
def _unmap_company(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
client_id = int(payload.get("client_id"))
|
||||
mapping_id = int(payload.get("mapping_id"))
|
||||
unmapped_by_user_id = payload.get("unmapped_by_user_id")
|
||||
result = self.store.unmap_company(
|
||||
client_id,
|
||||
mapping_id,
|
||||
int(unmapped_by_user_id) if unmapped_by_user_id not in (None, "") else None,
|
||||
)
|
||||
return {
|
||||
**result,
|
||||
"accounting": self.store.snapshot(client_id),
|
||||
"agent": self._agent_info(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user