Align Tally accounting storage with client storage policy

This commit is contained in:
A R R R Associates
2026-08-20 14:25:57 +05:30
parent f23da90dec
commit 3912d2fd16
6 changed files with 204 additions and 47 deletions
+26 -18
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
from fastapi import APIRouter, Form, Request from fastapi import APIRouter, Form, Request
@@ -14,7 +15,7 @@ from app.core.templating import templates
from app.modules.clients.models import Client from app.modules.clients.models import Client
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles 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, client_folder_parts
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 from app.modules.registrations.models import ClientRegistration, RegistrationType
@@ -55,6 +56,18 @@ def _find_visible_client(db, request: Request, user, client_id: int):
return client, clients, scope return client, clients, scope
def _accounting_storage_payload(client) -> dict:
"""Use the exact client folder naming policy already used by Permanent/Engagement storage."""
letter, client_folder = client_folder_parts(client, int(client.id))
relative_dir = Path("Accounting") / "Clients" / letter / client_folder
return {
"client_id": int(client.id),
"client_name": str(client.client_name or "").strip(),
"client_code": str(getattr(client, "client_code", "") or "").strip(),
"accounting_relative_dir": relative_dir.as_posix(),
}
def _client_registrations(db, client, tenant_id: int): def _client_registrations(db, client, tenant_id: int):
rows = db.execute( rows = db.execute(
select(ClientRegistration, RegistrationType) select(ClientRegistration, RegistrationType)
@@ -143,10 +156,7 @@ def tally_tool(
if should_query_agent and node and online: if should_query_agent and node and online:
payload = {} payload = {}
if selected_client: if selected_client:
payload = { payload = _accounting_storage_payload(selected_client)
"client_id": int(selected_client.id),
"client_name": selected_client.client_name,
}
try: try:
response_data = request_agent_command( response_data = request_agent_command(
node.node_code, node.node_code,
@@ -216,8 +226,7 @@ def initialize_accounting_storage(
node.node_code, node.node_code,
"accounting_initialize", "accounting_initialize",
{ {
"client_id": int(client.id), **_accounting_storage_payload(client),
"client_name": client.client_name,
"tenant_id": int(scope.tenant_id), "tenant_id": int(scope.tenant_id),
"requested_by_user_id": int(user.id), "requested_by_user_id": int(user.id),
}, },
@@ -293,8 +302,7 @@ def map_tally_company(
node.node_code, node.node_code,
"accounting_map_company", "accounting_map_company",
{ {
"client_id": int(client.id), **_accounting_storage_payload(client),
"client_name": client.client_name,
"tenant_id": int(scope.tenant_id), "tenant_id": int(scope.tenant_id),
"registration": registration_payload, "registration": registration_payload,
"tally_guid": str(tally_guid or "").strip(), "tally_guid": str(tally_guid or "").strip(),
@@ -349,7 +357,7 @@ def unmap_tally_company(
node.node_code, node.node_code,
"accounting_unmap_company", "accounting_unmap_company",
{ {
"client_id": int(client.id), **_accounting_storage_payload(client),
"mapping_id": int(mapping_id), "mapping_id": int(mapping_id),
"unmapped_by_user_id": int(user.id), "unmapped_by_user_id": int(user.id),
}, },
@@ -398,7 +406,7 @@ def sync_tally_masters(
node.node_code, node.node_code,
"accounting_sync_masters", "accounting_sync_masters",
{ {
"client_id": int(client.id), **_accounting_storage_payload(client),
"tally_guid": str(tally_guid or "").strip(), "tally_guid": str(tally_guid or "").strip(),
"requested_by_user_id": int(user.id), "requested_by_user_id": int(user.id),
}, },
@@ -454,7 +462,7 @@ def sync_tally_transactions(
node.node_code, node.node_code,
"accounting_sync_transactions", "accounting_sync_transactions",
{ {
"client_id": int(client.id), **_accounting_storage_payload(client),
"tally_guid": str(tally_guid or "").strip(), "tally_guid": str(tally_guid or "").strip(),
"date_from": start.isoformat(), "date_from": start.isoformat(),
"date_to": end.isoformat(), "date_to": end.isoformat(),
@@ -496,16 +504,16 @@ def depreciation_it_tool(
live_result = None; preview = None; depreciation_run = None; command_error = error or "" live_result = None; preview = None; depreciation_run = None; command_error = error or ""
if selected_client and node and online: if selected_client and node and online:
try: try:
status_response = request_agent_command(node.node_code,"phase6_status",{"client_id":int(selected_client.id)},timeout_seconds=20) status_response = request_agent_command(node.node_code,"phase6_status",_accounting_storage_payload(selected_client),timeout_seconds=20)
if status_response.get("ok"): live_result=status_response.get("result") or {} if status_response.get("ok"): live_result=status_response.get("result") or {}
else: command_error=str(status_response.get("error") or "Local Agent status failed.") else: command_error=str(status_response.get("error") or "Local Agent status failed.")
chosen_guid=str(tally_guid or "").strip() chosen_guid=str(tally_guid or "").strip()
if chosen_guid: if chosen_guid:
preview_response=request_agent_command(node.node_code,"accounting_depreciation_preview",{"client_id":int(selected_client.id),"tally_guid":chosen_guid,"fy_start":start_text,"fy_end":end_text},timeout_seconds=60) preview_response=request_agent_command(node.node_code,"accounting_depreciation_preview",{**_accounting_storage_payload(selected_client),"tally_guid":chosen_guid,"fy_start":start_text,"fy_end":end_text},timeout_seconds=60)
if preview_response.get("ok"): preview=(preview_response.get("result") or {}).get("preview") if preview_response.get("ok"): preview=(preview_response.get("result") or {}).get("preview")
else: command_error=str(preview_response.get("error") or "Depreciation preview failed.") else: command_error=str(preview_response.get("error") or "Depreciation preview failed.")
if run_id: if run_id:
run_response=request_agent_command(node.node_code,"accounting_get_it_depreciation_run",{"client_id":int(selected_client.id),"run_id":int(run_id)},timeout_seconds=30) run_response=request_agent_command(node.node_code,"accounting_get_it_depreciation_run",{**_accounting_storage_payload(selected_client),"run_id":int(run_id)},timeout_seconds=30)
if run_response.get("ok"): depreciation_run=(run_response.get("result") or {}).get("depreciation") if run_response.get("ok"): depreciation_run=(run_response.get("result") or {}).get("depreciation")
except Exception as exc: command_error=str(exc) except Exception as exc: command_error=str(exc)
base={"request":request,"current_user":user,"current_user_roles":get_user_roles(db,user.id),"current_user_permissions":get_user_permissions(db,user.id),"csrf_token":get_or_create_csrf_token(request)} base={"request":request,"current_user":user,"current_user_roles":get_user_roles(db,user.id),"current_user_permissions":get_user_permissions(db,user.id),"csrf_token":get_or_create_csrf_token(request)}
@@ -537,7 +545,7 @@ async def calculate_it_depreciation(request: Request):
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)
if not node or not _node_online(node): return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client.id}&tally_guid={quote(tally_guid)}&fy_start={start.isoformat()}&fy_end={end.isoformat()}&error={quote('ERP Local Agent is offline for the active branch.')}",status_code=303) if not node or not _node_online(node): return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client.id}&tally_guid={quote(tally_guid)}&fy_start={start.isoformat()}&fy_end={end.isoformat()}&error={quote('ERP Local Agent is offline for the active branch.')}",status_code=303)
try: try:
result=request_agent_command(node.node_code,"accounting_calculate_it_depreciation",{"client_id":int(client.id),"tally_guid":tally_guid,"fy_start":start.isoformat(),"fy_end":end.isoformat(),"assignments":assignments,"depreciation_expense_ledger":str(form.get("depreciation_expense_ledger") or ""),"depreciation_reserve_ledger":str(form.get("depreciation_reserve_ledger") or ""),"requested_by_user_id":int(user.id)},timeout_seconds=120) result=request_agent_command(node.node_code,"accounting_calculate_it_depreciation",{**_accounting_storage_payload(client),"tally_guid":tally_guid,"fy_start":start.isoformat(),"fy_end":end.isoformat(),"assignments":assignments,"depreciation_expense_ledger":str(form.get("depreciation_expense_ledger") or ""),"depreciation_reserve_ledger":str(form.get("depreciation_reserve_ledger") or ""),"requested_by_user_id":int(user.id)},timeout_seconds=120)
if not result.get("ok"): raise RuntimeError(str(result.get("error") or "Income-tax depreciation calculation failed.")) if not result.get("ok"): raise RuntimeError(str(result.get("error") or "Income-tax depreciation calculation failed."))
dep=(result.get("result") or {}).get("depreciation") or {}; rid=int(dep.get("run_id")) dep=(result.get("result") or {}).get("depreciation") or {}; rid=int(dep.get("run_id"))
except Exception as exc: except Exception as exc:
@@ -569,7 +577,7 @@ async def approve_it_depreciation(request: Request):
try: try:
result = request_agent_command( result = request_agent_command(
node.node_code, "accounting_approve_it_depreciation", node.node_code, "accounting_approve_it_depreciation",
{"client_id": client_id, "run_id": run_id, "approved_by_user_id": int(user.id), "approval_note": str(form.get("approval_note") or "")}, {**_accounting_storage_payload(client), "run_id": run_id, "approved_by_user_id": int(user.id), "approval_note": str(form.get("approval_note") or "")},
timeout_seconds=30, timeout_seconds=30,
) )
if not result.get("ok"): raise RuntimeError(str(result.get("error") or "Approval failed.")) if not result.get("ok"): raise RuntimeError(str(result.get("error") or "Approval failed."))
@@ -606,7 +614,7 @@ async def post_it_depreciation_to_tally(request: Request):
try: try:
result = request_agent_command( result = request_agent_command(
node.node_code, "accounting_post_it_depreciation", node.node_code, "accounting_post_it_depreciation",
{"client_id": client_id, "run_id": run_id, "posted_by_user_id": int(user.id)}, {**_accounting_storage_payload(client), "run_id": run_id, "posted_by_user_id": int(user.id)},
timeout_seconds=120, timeout_seconds=120,
) )
if not result.get("ok"): raise RuntimeError(str(result.get("error") or "Tally write-back failed.")) if not result.get("ok"): raise RuntimeError(str(result.get("error") or "Tally write-back failed."))
+1 -1
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path from pathlib import Path
import zipfile import zipfile
ERP_LOCAL_AGENT_VERSION = "1.8.0" ERP_LOCAL_AGENT_VERSION = "1.8.1"
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"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
@@ -1,4 +1,4 @@
ERP Local Agent 1.8.0 ERP Local Agent 1.8.1
Existing storage, WebSocket tunnel, Tally and client .act functionality are preserved. Existing storage, WebSocket tunnel, Tally and client .act functionality are preserved.
@@ -35,3 +35,10 @@ Depreciation refinement 1.8.0:
- Empty Fixed Asset ledgers are omitted unless they have current-period additions. - Empty Fixed Asset ledgers are omitted unless they have current-period additions.
- Bulk checkbox + rate assignment is available. - Bulk checkbox + rate assignment is available.
- Nested Tally accounting allocations and case-insensitive ledger matching improve fixed-asset additions detection. - Nested Tally accounting allocations and case-insensitive ledger matching improve fixed-asset additions detection.
Accounting storage policy 1.8.1:
- .act databases now use the same configured branch Storage Node root and client-folder naming policy as Permanent/Engagement storage.
- ERP builds the client folder through the existing client_folder_parts() helper and sends only the safe relative Accounting/Clients/... directory.
- Canonical layout: <STORAGE_ROOT>\Accounting\Clients\<Letter>\<ExistingClientFolder>\client_XXXXXXXX.act
- Existing databases from <STORAGE_ROOT>\Accounting\client_XXXXXXXX or <ERP_Local_Agent>\data\accounting\client_XXXXXXXX are migrated by SQLite backup on first use.
- Migration sources are retained as recovery copies; no existing .act file is deleted.
@@ -1,2 +1,2 @@
__version__ = "1.8.0" __version__ = "1.8.1"
AGENT_NAME = "ERP Local Agent" AGENT_NAME = "ERP Local Agent"
@@ -39,50 +39,184 @@ class LocalAccountingStore:
""" """
def __init__(self, storage_root: Path): def __init__(self, storage_root: Path):
# Accounting databases are application data, not document-storage payloads. # Accounting follows the same branch Storage Node root used by Permanent
# Keep them under the Local Agent data directory by default. An explicit # and Engagement storage. The ERP supplies the exact client-relative
# ACCOUNTING_ROOT may override this without changing document STORAGE_ROOT. # directory built with its shared client_folder_parts() policy.
configured = str(os.getenv("ACCOUNTING_ROOT", "") or "").strip() self.storage_root = Path(storage_root).resolve()
install_root = Path(__file__).resolve().parents[1] self.storage_root.mkdir(parents=True, exist_ok=True)
self.root = Path(configured).expanduser().resolve() if configured else (install_root / "data" / "accounting").resolve()
self.legacy_root = Path(storage_root).resolve() / "Accounting" install_root = Path(__file__).resolve().parents[1]
self.root.mkdir(parents=True, exist_ok=True) self.agent_data_root = (install_root / "data").resolve()
self.agent_data_root.mkdir(parents=True, exist_ok=True)
self.path_index_file = self.agent_data_root / "accounting_path_index.json"
# Historical locations retained only as migration sources.
self.legacy_flat_root = self.storage_root / "Accounting"
self.legacy_agent_root = self.agent_data_root / "accounting"
self._client_paths: dict[str, str] = self._load_path_index()
def _load_path_index(self) -> dict[str, str]:
if not self.path_index_file.exists():
return {}
try:
raw = json.loads(self.path_index_file.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
return {}
return {str(k): str(v) for k, v in raw.items() if str(v).strip()}
except Exception:
return {}
def _save_path_index(self) -> None:
tmp = self.path_index_file.with_suffix(".json.tmp")
tmp.write_text(json.dumps(self._client_paths, indent=2, sort_keys=True), encoding="utf-8")
tmp.replace(self.path_index_file)
@staticmethod
def _safe_relative_dir(raw: str) -> Path:
text = str(raw or "").strip().replace("\\", "/")
if not text:
raise ValueError("Accounting storage path was not supplied by ERP.")
path = Path(text)
if path.is_absolute():
raise ValueError("Accounting storage path must be relative to the configured Storage Node root.")
safe_parts = []
for part in path.parts:
value = str(part or "").strip()
if value in {"", ".", ".."}:
raise ValueError("Unsafe Accounting storage path received from ERP.")
if "/" in value or "\\" in value:
raise ValueError("Unsafe Accounting storage segment received from ERP.")
safe_parts.append(value)
relative = Path(*safe_parts)
# Accounting paths are intentionally constrained to Accounting/Clients/...
parts_lower = [p.lower() for p in relative.parts]
if len(parts_lower) < 4 or parts_lower[0] != "accounting" or parts_lower[1] != "clients":
raise ValueError("Accounting storage path does not follow the ERP Storage Node client policy.")
return relative
def bind_client_path(self, client_id: int, accounting_relative_dir: str) -> Path:
relative = self._safe_relative_dir(accounting_relative_dir)
target_dir = (self.storage_root / relative).resolve()
if os.path.commonpath([str(self.storage_root), str(target_dir)]) != str(self.storage_root):
raise ValueError("Accounting storage path escapes the configured Storage Node root.")
def _migrate_legacy_client_if_needed(self, client_id: int) -> None:
key = self._client_key(client_id) key = self._client_key(client_id)
new_dir = self.root / key target_dir.mkdir(parents=True, exist_ok=True)
new_db = new_dir / f"{key}.act" target_db = target_dir / f"{key}.act"
old_dir = self.legacy_root / key
old_db = old_dir / f"{key}.act" self._client_paths[str(int(client_id))] = relative.as_posix()
if new_db.exists() or not old_db.exists(): self._save_path_index()
self._migrate_existing_database(client_id, target_db)
return target_db
@staticmethod
def _database_activity_mtime(path: Path) -> float:
stamps = []
for candidate in (path, Path(str(path) + "-wal"), Path(str(path) + "-shm")):
try:
if candidate.exists():
stamps.append(candidate.stat().st_mtime)
except OSError:
pass
return max(stamps) if stamps else 0.0
@staticmethod
def _sqlite_backup(source: Path, destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
tmp = destination.with_suffix(destination.suffix + ".migrating")
if tmp.exists():
tmp.unlink()
src = sqlite3.connect(source, timeout=60)
dst = sqlite3.connect(tmp, timeout=60)
try:
src.execute("PRAGMA busy_timeout=60000")
dst.execute("PRAGMA busy_timeout=60000")
src.backup(dst)
dst.commit()
finally:
dst.close()
src.close()
tmp.replace(destination)
def _migration_candidates(self, client_id: int, target_db: Path) -> list[Path]:
key = self._client_key(client_id)
filename = f"{key}.act"
candidates: list[Path] = []
# Original Phase 1-7 flat Storage Root location.
candidates.append(self.legacy_flat_root / key / filename)
# Temporary v1.8.0 Local-Agent data location.
candidates.append(self.legacy_agent_root / key / filename)
# If a client was renamed, an earlier canonical client-folder path may exist.
accounting_clients = self.storage_root / "Accounting" / "Clients"
if accounting_clients.exists():
for path in accounting_clients.glob(f"*/*/{filename}"):
candidates.append(path)
unique = []
seen = set()
for path in candidates:
try:
resolved = path.resolve()
except Exception:
resolved = path
if resolved == target_db.resolve():
continue
marker = str(resolved).lower()
if marker in seen or not path.exists():
continue
seen.add(marker)
unique.append(path)
return unique
def _migrate_existing_database(self, client_id: int, target_db: Path) -> None:
if target_db.exists():
return return
new_dir.mkdir(parents=True, exist_ok=True) candidates = self._migration_candidates(client_id, target_db)
# Copy, do not delete, the legacy database. The old copy remains a recovery if not candidates:
# fallback while all new reads/writes switch to Local Agent data/accounting. return
shutil.copy2(old_db, new_db) source = max(candidates, key=self._database_activity_mtime)
for suffix in ("-wal", "-shm"): self._sqlite_backup(source, target_db)
src = Path(str(old_db) + suffix) # Source is deliberately left untouched as a recovery copy.
if src.exists():
shutil.copy2(src, Path(str(new_db) + suffix)) def _bound_relative_dir(self, client_id: int) -> Path | None:
raw = self._client_paths.get(str(int(client_id)))
if not raw:
return None
try:
return self._safe_relative_dir(raw)
except Exception:
return None
@staticmethod @staticmethod
def _client_key(client_id: int) -> str: def _client_key(client_id: int) -> str:
return f"client_{int(client_id):08d}" return f"client_{int(client_id):08d}"
def client_dir(self, client_id: int) -> Path: def client_dir(self, client_id: int) -> Path:
return self.root / self._client_key(client_id) relative = self._bound_relative_dir(client_id)
if relative is None:
# Do not silently create a new Accounting DB in a non-policy location.
# Existing legacy DBs remain discoverable only after ERP supplies its
# canonical client path on the next status/initialize command.
return self.storage_root / "Accounting" / "Clients" / "_UNBOUND" / self._client_key(client_id)
return self.storage_root / relative
def db_path(self, client_id: int) -> Path: def db_path(self, client_id: int) -> Path:
self._migrate_legacy_client_if_needed(client_id)
key = self._client_key(client_id) key = self._client_key(client_id)
return self.client_dir(client_id) / f"{key}.act" return self.client_dir(client_id) / f"{key}.act"
def exists(self, client_id: int) -> bool: def exists(self, client_id: int) -> bool:
self._migrate_legacy_client_if_needed(client_id)
return self.db_path(client_id).is_file() return self.db_path(client_id).is_file()
def connect(self, client_id: int): def connect(self, client_id: int):
self._migrate_legacy_client_if_needed(client_id) if self._bound_relative_dir(client_id) is None:
raise RuntimeError(
"Accounting storage path is not bound to the ERP client folder policy. "
"Refresh Tools → Tally so ERP can supply the canonical Storage Node path."
)
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, timeout=60) db = sqlite3.connect(path, timeout=60)
@@ -19,6 +19,7 @@ class AgentCommandProcessor:
command_id = str(command.get("command_id") or "").strip() command_id = str(command.get("command_id") or "").strip()
action = str(command.get("action") or "").strip() action = str(command.get("action") or "").strip()
payload = command.get("payload") or {} payload = command.get("payload") or {}
self._bind_accounting_storage(payload)
result: dict[str, Any] | None = None result: dict[str, Any] | None = None
error: str | None = None error: str | None = None
ok = False ok = False
@@ -53,6 +54,13 @@ class AgentCommandProcessor:
self.logger.exception("Agent command failed action=%s command_id=%s: %s", action, command_id, exc) self.logger.exception("Agent command failed action=%s command_id=%s: %s", action, command_id, exc)
return {"type": "command_result", "command_id": command_id, "ok": ok, "result": result, "error": error, "agent_time_utc": datetime.now(timezone.utc).isoformat()} return {"type": "command_result", "command_id": command_id, "ok": ok, "result": result, "error": error, "agent_time_utc": datetime.now(timezone.utc).isoformat()}
def _bind_accounting_storage(self, payload: dict[str, Any]) -> None:
client_id = payload.get("client_id")
relative_dir = str(payload.get("accounting_relative_dir") or "").strip()
if client_id in (None, "") or not relative_dir:
return
self.store.bind_client_path(int(client_id), relative_dir)
def _agent_info(self) -> dict[str, Any]: def _agent_info(self) -> dict[str, Any]:
return { return {
"name": "ERP Local Agent", "version": __version__, "name": "ERP Local Agent", "version": __version__,