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,
}