Add Phase 2 Tally company mapping
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user