diff --git a/app/modules/services/engagement_resources.py b/app/modules/services/engagement_resources.py index 4f3a762..001fd21 100644 --- a/app/modules/services/engagement_resources.py +++ b/app/modules/services/engagement_resources.py @@ -6,9 +6,10 @@ from typing import Any, Iterable from sqlalchemy import select -from app.modules.documents.models import DocumentStorageJob +from app.modules.documents.models import BranchStorageNode, DocumentStorageJob, EngagementDocument from app.modules.documents.services import client_folder_parts, get_active_storage_node_for_branch, sanitize_segment from app.modules.services.models import FirmServiceTaskTemplate, ServiceTaskCategory +from app.modules.accounting.accounting_mirror_models import AccountingMirrorRegistry @@ -61,6 +62,81 @@ def _latest_local_final_path(db, version: Any) -> str: return str(getattr(job, "local_final_path", "") or "").strip() if job else "" +def _current_mirror_for_year(db, *, tenant_id: int, client_id: int, financial_year: str): + if not tenant_id or not client_id or not financial_year: + return None + return db.execute( + select(AccountingMirrorRegistry).where( + AccountingMirrorRegistry.tenant_id == int(tenant_id), + AccountingMirrorRegistry.client_id == int(client_id), + AccountingMirrorRegistry.financial_year == str(financial_year).strip(), + AccountingMirrorRegistry.is_current.is_(True), + AccountingMirrorRegistry.is_active.is_(True), + AccountingMirrorRegistry.status == "active", + ).order_by(AccountingMirrorRegistry.version_no.desc(), AccountingMirrorRegistry.id.desc()).limit(1) + ).scalar_one_or_none() + + +def _mirror_row(db, *, tenant_id: int, client_id: int, financial_year: str) -> dict[str, Any]: + mirror = _current_mirror_for_year( + db, tenant_id=tenant_id, client_id=client_id, financial_year=financial_year + ) + if not mirror: + return { + "available": False, + "financial_year": financial_year, + "local_path": "", + "relative_path": "", + "version_no": 0, + "company_name": "", + "last_synced_at_utc": None, + } + + relative_path = (Path(str(mirror.accounting_relative_dir or "")) / str(mirror.mirror_file_name or "")).as_posix() + local_path = "" + node = db.get(BranchStorageNode, int(mirror.storage_node_id)) if getattr(mirror, "storage_node_id", None) else None + if node: + root = str(getattr(node, "storage_root_path", "") or "").strip() + local_path = _join_local_path(root, relative_path) if root else "" + + return { + "available": True, + "financial_year": financial_year, + "local_path": local_path, + "relative_path": relative_path, + "version_no": int(getattr(mirror, "version_no", 0) or 0), + "company_name": str(getattr(mirror, "company_name", "") or ""), + "last_synced_at_utc": getattr(mirror, "last_synced_at_utc", None), + "storage_node_name": str(getattr(node, "node_name", "") or "") if node else "", + } + + +def _tally_row_from_document(db, doc: Any, storage_root_path: str) -> dict[str, Any] | None: + tally_year = _tally_document_year(doc) + if not tally_year: + return None + versions = list(getattr(doc, "versions", None) or []) + latest = versions[0] if versions else None + latest_relative_path = str(getattr(latest, "local_relative_path", "") or "") if latest else "" + local_final_path = _latest_local_final_path(db, latest) if latest else "" + # local_final_path is already the workstation/storage-node acknowledged final + # destination. For extracted ZIP/TAR Tally uploads it is the directory that + # can be pasted into TallyPrime, so never prepend storage_root_path to it. + local_path = local_final_path or (_join_local_path(storage_root_path, latest_relative_path) if latest_relative_path else "") + return { + "id": int(getattr(doc, "id", 0) or 0), + "engagement_id": int(getattr(doc, "engagement_id", 0) or 0), + "title": str(getattr(doc, "title", "") or "Tally Data"), + "financial_year": tally_year, + "version_no": int(getattr(doc, "current_version_no", 0) or 0), + "latest_filename": str(getattr(latest, "original_filename", "") or "") if latest else "", + "latest_relative_path": latest_relative_path, + "local_final_path": local_final_path, + "local_path": local_path, + "storage_status": str(getattr(latest, "storage_status", "") or "") if latest else "", + } + + def _task_category_options(db, engagement, tasks: list[Any]) -> list[dict[str, Any]]: """Return the service's real task-category master mapped to this engagement's tasks. @@ -198,6 +274,7 @@ def build_engagement_resource_context(db, engagement, tasks: Iterable[Any], docu accounting_local_path = "" storage_root_path = "" storage_node_name = "" + tenant_id = int(getattr(engagement, "tenant_id", 0) or 0) if client and client_id and fy: fy_folder = sanitize_segment(f"FY{fy}", "FY") letter, client_folder = client_folder_parts(client, client_id) @@ -206,7 +283,7 @@ def build_engagement_resource_context(db, engagement, tasks: Iterable[Any], docu accounting_relative_path = (relative_dir / f"client_{client_id:08d}.act").as_posix() node = get_active_storage_node_for_branch( db, - int(getattr(engagement, "tenant_id", 0) or 0), + tenant_id, getattr(engagement, "branch_id", None), ) if node: @@ -214,6 +291,36 @@ def build_engagement_resource_context(db, engagement, tasks: Iterable[Any], docu storage_node_name = str(getattr(node, "node_name", "") or "").strip() accounting_local_path = _join_local_path(storage_root_path, accounting_relative_path) + # Client/FY accounting resources are shared across engagements for the same + # client. This lets Tax Audit, Statutory Audit, ITR, etc. reuse the same Tally + # dataset and registered mirror instead of requiring duplicate uploads. + client_tally_documents: list[Any] = [] + if tenant_id and client_id and tally_year_options: + client_tally_documents = db.execute( + select(EngagementDocument).where( + EngagementDocument.tenant_id == tenant_id, + EngagementDocument.client_id == client_id, + EngagementDocument.is_deleted.is_(False), + EngagementDocument.description.like(f"{_TALLY_MARKER_PREFIX}%"), + EngagementDocument.financial_year.in_(tally_year_options), + ).order_by(EngagementDocument.updated_at_utc.desc(), EngagementDocument.id.desc()) + ).scalars().unique().all() + + client_tally_by_fy: dict[str, dict[str, Any]] = {} + for tally_doc in client_tally_documents: + row = _tally_row_from_document(db, tally_doc, storage_root_path) + if row and row["financial_year"] not in client_tally_by_fy: + client_tally_by_fy[row["financial_year"]] = row + + accounting_years: list[dict[str, Any]] = [] + for year in tally_year_options: + accounting_years.append({ + "financial_year": year, + "is_engagement_year": year == fy, + "tally": client_tally_by_fy.get(year), + "mirror": _mirror_row(db, tenant_id=tenant_id, client_id=client_id, financial_year=year), + }) + categories = _task_category_options(db, engagement, tasks) default_upload_task_id = next((row["task_id"] for row in categories if row.get("task_id")), None) @@ -241,10 +348,11 @@ def build_engagement_resource_context(db, engagement, tasks: Iterable[Any], docu } document_options.append(row) if tally_year: + tally_local_path = local_final_path or (_join_local_path(storage_root_path, latest_relative_path) if latest_relative_path else "") tally_data_files.append({ **row, "financial_year": tally_year, - "local_path": _join_local_path(storage_root_path, displayed_relative_path) if displayed_relative_path else "", + "local_path": tally_local_path, }) tally_data_files.sort(key=lambda row: row.get("financial_year", ""), reverse=True) @@ -256,6 +364,7 @@ def build_engagement_resource_context(db, engagement, tasks: Iterable[Any], docu "storage_root_path": storage_root_path, "storage_node_name": storage_node_name, "tally_year_options": tally_year_options, + "accounting_years": accounting_years, "tally_data_files": tally_data_files, "task_categories": categories, "default_upload_task_id": default_upload_task_id, diff --git a/app/modules/services/templates/services/engagements/_engagement_resources.html b/app/modules/services/templates/services/engagements/_engagement_resources.html index aaceec9..07b159c 100644 --- a/app/modules/services/templates/services/engagements/_engagement_resources.html +++ b/app/modules/services/templates/services/engagements/_engagement_resources.html @@ -15,23 +15,61 @@
These resources are linked by client and financial year, so the same data can be reused across this client's engagements.
Paste this storage/extracted-data path into TallyPrime when working with this FY.
+ {% if year_row.tally.latest_filename %}No usable Tally storage path is registered for this client/FY yet.
+ {% endif %} +No active Accounting Mirror is linked to this client/FY.
+ {% endif %} +This is the existing .act accounting/intelligence database path used by the Local Agent. Uploaded Tally data paths are shown separately on the right.
- {% elif resources.accounting_relative_path %} -The relative accounting path is known, but this branch does not currently expose a configured local storage root.
- {% else %} -Accounting path is unavailable for this engagement.
- {% endif %}