diff --git a/app/modules/accounting/templates/accounting/depreciation_it.html b/app/modules/accounting/templates/accounting/depreciation_it.html index 94676e0..85f7a48 100644 --- a/app/modules/accounting/templates/accounting/depreciation_it.html +++ b/app/modules/accounting/templates/accounting/depreciation_it.html @@ -94,7 +94,7 @@
{% for row in depreciation_run.lines %}{% endfor %}
LedgerRateDepreciationClosing WDV
{{ row.ledger_name }}{{ '%.2f'|format(row.rate_percent) }}%{{ '%.2f'|format(row.depreciation_amount) }}{{ '%.2f'|format(row.closing_wdv) }}
{% if depreciation_run.status == 'draft' %}
- +
Partner Approval Required

Review the company, financial year, ledgers, rates and total before approving. Approval does not post anything to Tally.

@@ -103,7 +103,7 @@
{% elif depreciation_run.status == 'approved' %}
- +
Controlled Tally Write-back

This creates a real Journal voucher in the mapped company currently open in TallyPrime. The Local Agent re-checks the Tally GUID immediately before posting. Successful drafts cannot be posted twice.

diff --git a/app/modules/accounting/ui.py b/app/modules/accounting/ui.py index e405f08..6edd734 100644 --- a/app/modules/accounting/ui.py +++ b/app/modules/accounting/ui.py @@ -15,7 +15,7 @@ from app.core.templating import templates 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.permission_guard import require_permission -from app.modules.documents.services import build_document_scope, get_active_storage_node_for_branch, client_folder_parts +from app.modules.documents.services import build_document_scope, get_active_storage_node_for_branch, client_folder_parts, sanitize_segment from app.modules.accounting.agent_bridge import request_agent_command from app.modules.registrations.models import ClientRegistration, RegistrationType @@ -56,14 +56,23 @@ def _find_visible_client(db, request: Request, user, client_id: int): return client, clients, scope -def _accounting_storage_payload(client) -> dict: - """Use the exact client folder naming policy already used by Permanent/Engagement storage.""" +def _financial_year_for_date(value: date | None = None) -> str: + value = value or date.today() + start_year = value.year if value.month >= 4 else value.year - 1 + return f"{start_year}-{str(start_year + 1)[-2:]}" + + +def _accounting_storage_payload(client, financial_year: str | None = None) -> dict: + """Use the same FY + client-folder policy as existing Engagement storage.""" + fy_value = str(financial_year or _financial_year_for_date()).strip() + fy_folder = sanitize_segment(f"FY{fy_value}", "FY") letter, client_folder = client_folder_parts(client, int(client.id)) - relative_dir = Path("Accounting") / "Clients" / letter / client_folder + relative_dir = Path(fy_folder) / "Clients" / letter / client_folder / "Accounting" return { "client_id": int(client.id), "client_name": str(client.client_name or "").strip(), "client_code": str(getattr(client, "client_code", "") or "").strip(), + "financial_year": fy_value, "accounting_relative_dir": relative_dir.as_posix(), } @@ -462,7 +471,7 @@ def sync_tally_transactions( node.node_code, "accounting_sync_transactions", { - **_accounting_storage_payload(client), + **_accounting_storage_payload(client, _financial_year_for_date(start)), "tally_guid": str(tally_guid or "").strip(), "date_from": start.isoformat(), "date_to": end.isoformat(), @@ -504,16 +513,16 @@ def depreciation_it_tool( live_result = None; preview = None; depreciation_run = None; command_error = error or "" if selected_client and node and online: try: - status_response = request_agent_command(node.node_code,"phase6_status",_accounting_storage_payload(selected_client),timeout_seconds=20) + status_response = request_agent_command(node.node_code,"phase6_status",_accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))),timeout_seconds=20) 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.") chosen_guid=str(tally_guid or "").strip() if chosen_guid: - 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) + preview_response=request_agent_command(node.node_code,"accounting_depreciation_preview",{**_accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))),"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") else: command_error=str(preview_response.get("error") or "Depreciation preview failed.") if run_id: - 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) + run_response=request_agent_command(node.node_code,"accounting_get_it_depreciation_run",{**_accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))),"run_id":int(run_id)},timeout_seconds=30) if run_response.get("ok"): depreciation_run=(run_response.get("result") or {}).get("depreciation") 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)} @@ -545,7 +554,7 @@ async def calculate_it_depreciation(request: Request): 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) try: - 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) + result=request_agent_command(node.node_code,"accounting_calculate_it_depreciation",{**_accounting_storage_payload(client, _financial_year_for_date(start)),"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.")) dep=(result.get("result") or {}).get("depreciation") or {}; rid=int(dep.get("run_id")) except Exception as exc: @@ -565,8 +574,9 @@ async def approve_it_depreciation(request: Request): if response: return response try: client_id = int(form.get("client_id")); run_id = int(form.get("run_id")) + approval_fy_start = date.fromisoformat(str(form.get("fy_start") or "")) except Exception: - return RedirectResponse(url="/tools/tally/depreciation?error=" + quote("Invalid depreciation draft."), status_code=303) + return RedirectResponse(url="/tools/tally/depreciation?error=" + quote("Invalid depreciation draft or financial year."), status_code=303) client, _clients, scope = _find_visible_client(db, request, user, client_id) if not client: return _denied() if str(form.get("confirm_approval") or "") != "yes": @@ -577,7 +587,7 @@ async def approve_it_depreciation(request: Request): try: result = request_agent_command( node.node_code, "accounting_approve_it_depreciation", - {**_accounting_storage_payload(client), "run_id": run_id, "approved_by_user_id": int(user.id), "approval_note": str(form.get("approval_note") or "")}, + {**_accounting_storage_payload(client, _financial_year_for_date(approval_fy_start)), "run_id": run_id, "approved_by_user_id": int(user.id), "approval_note": str(form.get("approval_note") or "")}, timeout_seconds=30, ) if not result.get("ok"): raise RuntimeError(str(result.get("error") or "Approval failed.")) @@ -600,8 +610,9 @@ async def post_it_depreciation_to_tally(request: Request): if response: return response try: client_id = int(form.get("client_id")); run_id = int(form.get("run_id")) + posting_fy_start = date.fromisoformat(str(form.get("fy_start") or "")) except Exception: - return RedirectResponse(url="/tools/tally/depreciation?error=" + quote("Invalid depreciation draft."), status_code=303) + return RedirectResponse(url="/tools/tally/depreciation?error=" + quote("Invalid depreciation draft or financial year."), status_code=303) client, _clients, scope = _find_visible_client(db, request, user, client_id) if not client: return _denied() if str(form.get("post_confirmation") or "").strip().upper() != "POST": @@ -614,7 +625,7 @@ async def post_it_depreciation_to_tally(request: Request): try: result = request_agent_command( node.node_code, "accounting_post_it_depreciation", - {**_accounting_storage_payload(client), "run_id": run_id, "posted_by_user_id": int(user.id)}, + {**_accounting_storage_payload(client, _financial_year_for_date(posting_fy_start)), "run_id": run_id, "posted_by_user_id": int(user.id)}, timeout_seconds=120, ) if not result.get("ok"): raise RuntimeError(str(result.get("error") or "Tally write-back failed.")) diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 82b4ab8..46a1a38 100644 --- a/app/modules/documents/agent_package.py +++ b/app/modules/documents/agent_package.py @@ -4,7 +4,7 @@ import io from pathlib import Path import zipfile -ERP_LOCAL_AGENT_VERSION = "1.8.1" +ERP_LOCAL_AGENT_VERSION = "1.8.2" ERP_LOCAL_AGENT_NAME = "ERP Local Agent" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) diff --git a/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt b/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt index 1bd1981..aa57549 100644 --- a/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt +++ b/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt @@ -1,4 +1,4 @@ -ERP Local Agent 1.8.1 +ERP Local Agent 1.8.2 Existing storage, WebSocket tunnel, Tally and client .act functionality are preserved. @@ -42,3 +42,13 @@ Accounting storage policy 1.8.1: - Canonical layout: \Accounting\Clients\\\client_XXXXXXXX.act - Existing databases from \Accounting\client_XXXXXXXX or \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. + +Accounting engagement-folder policy 1.8.2: +- Final canonical Accounting DB path follows the existing Engagement hierarchy: + \FY2026-27\Clients\\\Accounting\client_XXXXXXXX.act +- ERP uses the same sanitize_segment("FY"+financial_year) and client_folder_parts() convention as Engagement storage. +- Current Tally page/mapping/master sync use the current financial year. +- Transaction sync uses the financial year containing date_from. +- Depreciation/approval/write-back use the selected depreciation financial year. +- Existing .act databases from original flat, v1.8.0 Local-Agent-data, and v1.8.1 Accounting\Clients locations are migrated safely by SQLite backup and retained as recovery copies. +- The Accounting folder is the stable base for later Reports, Exports, Workpapers and Backups; this release does not change report-generation behavior. diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index 3de3e81..2bf6808 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py @@ -1,2 +1,2 @@ -__version__ = "1.8.1" +__version__ = "1.8.2" AGENT_NAME = "ERP Local Agent" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py index 494eb93..02ae463 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py @@ -39,9 +39,9 @@ class LocalAccountingStore: """ def __init__(self, storage_root: Path): - # Accounting follows the same branch Storage Node root used by Permanent - # and Engagement storage. The ERP supplies the exact client-relative - # directory built with its shared client_folder_parts() policy. + # Accounting follows the same branch Storage Node and FY/client hierarchy used + # by Engagement storage. ERP supplies FY.../Clients///Accounting + # using the existing sanitize_segment() + client_folder_parts() policy. self.storage_root = Path(storage_root).resolve() self.storage_root.mkdir(parents=True, exist_ok=True) @@ -89,10 +89,20 @@ class LocalAccountingStore: 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.") + # Canonical Accounting path mirrors Engagement storage: + # FY2026-27/Clients/A/CLIENT_FOLDER/Accounting + parts = list(relative.parts) + parts_lower = [p.lower() for p in parts] + if ( + len(parts) != 5 + or not parts_lower[0].startswith("fy") + or parts_lower[1] != "clients" + or len(parts[2]) != 1 + or parts_lower[4] != "accounting" + ): + raise ValueError( + "Accounting storage path must follow FY/Clients///Accounting." + ) return relative def bind_client_path(self, client_id: int, accounting_relative_dir: str) -> Path: @@ -150,12 +160,17 @@ class LocalAccountingStore: # 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. + # v1.8.1 interim Accounting/Clients// location. accounting_clients = self.storage_root / "Accounting" / "Clients" if accounting_clients.exists(): for path in accounting_clients.glob(f"*/*/{filename}"): candidates.append(path) + # Earlier/future FY client Accounting locations (for client rename or FY + # resolver changes). The active target is excluded below. + for path in self.storage_root.glob(f"FY*/Clients/*/*/Accounting/{filename}"): + candidates.append(path) + unique = [] seen = set() for path in candidates: @@ -201,7 +216,7 @@ class LocalAccountingStore: # 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 / "_UNBOUND_ACCOUNTING" / self._client_key(client_id) return self.storage_root / relative def db_path(self, client_id: int) -> Path: