334 lines
15 KiB
Python
334 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any, Iterable
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.modules.documents.models import DocumentStorageJob
|
|
from app.modules.documents.services import client_folder_parts, get_active_storage_node_for_branch, sanitize_segment
|
|
from app.modules.accounting.accounting_mirror_service import get_current_accounting_mirror, update_accounting_mirror_paths
|
|
from app.modules.services.models import FirmServiceTaskTemplate, ServiceTaskCategory
|
|
|
|
|
|
|
|
|
|
_TALLY_MARKER_PREFIX = "ERP_TALLY_DATA_FY:"
|
|
|
|
def _previous_financial_year(financial_year: str) -> str:
|
|
match = re.match(r"^(\d{4})-(\d{2}|\d{4})$", (financial_year or "").strip())
|
|
if not match:
|
|
return ""
|
|
start = int(match.group(1))
|
|
prev_start = start - 1
|
|
return f"{prev_start:04d}-{start % 100:02d}"
|
|
|
|
def _tally_document_year(doc: Any) -> str:
|
|
description = str(getattr(doc, "description", "") or "").strip()
|
|
if description.startswith(_TALLY_MARKER_PREFIX):
|
|
return description[len(_TALLY_MARKER_PREFIX):].strip()
|
|
title = str(getattr(doc, "title", "") or "").strip()
|
|
match = re.match(r"^Tally Data\s*-\s*FY\s*(\d{4}-\d{2,4})$", title, re.IGNORECASE)
|
|
if match:
|
|
return match.group(1)
|
|
return ""
|
|
|
|
def _join_local_path(root: str, relative: str) -> str:
|
|
root = (root or "").strip().rstrip("\\/")
|
|
relative = (relative or "").strip()
|
|
if not relative:
|
|
return ""
|
|
# Local Agent may already return an absolute final path (especially for
|
|
# extracted Tally ZIP/TAR datasets). Never prefix the Storage Root twice.
|
|
if re.match(r"^[A-Za-z]:[\\/]", relative) or relative.startswith("/") or relative.startswith("\\\\"):
|
|
return relative
|
|
if not root:
|
|
return ""
|
|
if re.match(r"^[A-Za-z]:", root) or "\\" in root:
|
|
return root + "\\" + relative.replace("/", "\\")
|
|
return root + "/" + relative.lstrip("/")
|
|
|
|
|
|
def _latest_local_final_path(db, version: Any) -> str:
|
|
"""Return the branch-agent acknowledged final path for a document version.
|
|
|
|
For ordinary files this is the file path. For ZIP/TAR uploads handled by Local
|
|
Agent 1.22.1+ this is the extracted directory path, while the original archive
|
|
continues to remain the versioned source file for secure download/history.
|
|
"""
|
|
version_id = int(getattr(version, "id", 0) or 0)
|
|
if not version_id:
|
|
return ""
|
|
job = db.execute(
|
|
select(DocumentStorageJob).where(
|
|
DocumentStorageJob.version_id == version_id,
|
|
DocumentStorageJob.status == "completed",
|
|
).order_by(DocumentStorageJob.completed_at_utc.desc(), DocumentStorageJob.id.desc()).limit(1)
|
|
).scalar_one_or_none()
|
|
return str(getattr(job, "local_final_path", "") or "").strip() if job 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.
|
|
|
|
Older task instances may not contain the task_category snapshot even though their
|
|
FirmServiceTaskTemplate has since been linked to ServiceTaskCategory. Therefore
|
|
category resolution deliberately uses both the generated task snapshot and the
|
|
current template/category master, without changing any historical task rows.
|
|
"""
|
|
catalogue_id = int(getattr(engagement, "service_catalogue_id", 0) or 0)
|
|
tenant_id = int(getattr(engagement, "tenant_id", 0) or 0)
|
|
if not catalogue_id:
|
|
return []
|
|
|
|
template_ids = {
|
|
int(getattr(task, "firm_task_template_id", 0) or 0)
|
|
for task in tasks
|
|
if int(getattr(task, "firm_task_template_id", 0) or 0)
|
|
}
|
|
templates_by_id: dict[int, Any] = {}
|
|
if template_ids:
|
|
rows = db.execute(
|
|
select(FirmServiceTaskTemplate).where(FirmServiceTaskTemplate.id.in_(template_ids))
|
|
).scalars().all()
|
|
templates_by_id = {int(row.id): row for row in rows}
|
|
|
|
task_by_category_id: dict[int, int] = {}
|
|
task_by_category_name: dict[str, int] = {}
|
|
legacy_names: dict[str, tuple[str, int]] = {}
|
|
|
|
for task in sorted(tasks, key=lambda x: (int(getattr(x, "sequence_no", 0) or 0), int(getattr(x, "id", 0) or 0))):
|
|
task_id = int(getattr(task, "id", 0) or 0)
|
|
if not task_id:
|
|
continue
|
|
template_id = int(getattr(task, "firm_task_template_id", 0) or 0)
|
|
template = templates_by_id.get(template_id)
|
|
category_id = int(getattr(template, "task_category_id", 0) or 0) if template else 0
|
|
snapshot_name = str(getattr(task, "task_category", "") or "").strip()
|
|
template_name = str(getattr(template, "task_category", "") or "").strip() if template else ""
|
|
category_name = snapshot_name or template_name
|
|
|
|
if category_id and category_id not in task_by_category_id:
|
|
task_by_category_id[category_id] = task_id
|
|
if category_name:
|
|
key = category_name.casefold()
|
|
task_by_category_name.setdefault(key, task_id)
|
|
legacy_names.setdefault(key, (category_name, task_id))
|
|
|
|
# Firm categories are authoritative for a firm engagement. If none exist,
|
|
# retain support for the system/default category master.
|
|
firm_rows = db.execute(
|
|
select(ServiceTaskCategory).where(
|
|
ServiceTaskCategory.service_catalogue_id == catalogue_id,
|
|
ServiceTaskCategory.tenant_id == tenant_id,
|
|
ServiceTaskCategory.is_active.is_(True),
|
|
).order_by(ServiceTaskCategory.sort_order.asc(), ServiceTaskCategory.name.asc(), ServiceTaskCategory.id.asc())
|
|
).scalars().all()
|
|
master_rows = list(firm_rows)
|
|
if not master_rows:
|
|
master_rows = db.execute(
|
|
select(ServiceTaskCategory).where(
|
|
ServiceTaskCategory.service_catalogue_id == catalogue_id,
|
|
ServiceTaskCategory.tenant_id.is_(None),
|
|
ServiceTaskCategory.is_active.is_(True),
|
|
).order_by(ServiceTaskCategory.sort_order.asc(), ServiceTaskCategory.name.asc(), ServiceTaskCategory.id.asc())
|
|
).scalars().all()
|
|
|
|
options: list[dict[str, Any]] = []
|
|
seen_names: set[str] = set()
|
|
for category in master_rows:
|
|
name = str(getattr(category, "name", "") or "").strip()
|
|
if not name:
|
|
continue
|
|
key = name.casefold()
|
|
task_id = task_by_category_id.get(int(category.id)) or task_by_category_name.get(key)
|
|
options.append({
|
|
"id": int(category.id),
|
|
"code": str(getattr(category, "code", "") or ""),
|
|
"name": name,
|
|
"task_id": task_id,
|
|
"available": bool(task_id),
|
|
"source": "master",
|
|
})
|
|
seen_names.add(key)
|
|
|
|
# Do not hide historical categories if a legacy task still has one that is no
|
|
# longer present in the active master.
|
|
for key, (name, task_id) in legacy_names.items():
|
|
if key in seen_names:
|
|
continue
|
|
options.append({
|
|
"id": None,
|
|
"code": "",
|
|
"name": name,
|
|
"task_id": task_id,
|
|
"available": True,
|
|
"source": "legacy",
|
|
})
|
|
seen_names.add(key)
|
|
|
|
# Preserve the old behaviour only as a final fallback for genuinely
|
|
# uncategorised engagements.
|
|
if not options and tasks:
|
|
first_task_id = int(getattr(tasks[0], "id", 0) or 0)
|
|
if first_task_id:
|
|
options.append({
|
|
"id": None,
|
|
"code": "",
|
|
"name": "General Workflow",
|
|
"task_id": first_task_id,
|
|
"available": True,
|
|
"source": "fallback",
|
|
})
|
|
return options
|
|
|
|
|
|
def build_engagement_resource_context(db, engagement, tasks: Iterable[Any], documents: Iterable[Any]) -> dict[str, Any]:
|
|
"""Presentation-only engagement resource data using existing storage/task models.
|
|
|
|
No new persistence is introduced. Accounting paths continue to follow the same
|
|
FY/client convention used by the ERP Local Agent. Category uploads use existing
|
|
task instances so evidence/AQMM controls remain authoritative. Tally Data is an
|
|
engagement-level document destination and therefore is intentionally not forced
|
|
into an unrelated task category.
|
|
"""
|
|
tasks = list(tasks or [])
|
|
documents = list(documents or [])
|
|
client = getattr(engagement, "client", None)
|
|
client_id = int(getattr(engagement, "client_id", 0) or 0)
|
|
fy = str(getattr(engagement, "financial_year", "") or "").strip()
|
|
prior_fy = _previous_financial_year(fy)
|
|
tally_year_options = [year for year in (fy, prior_fy) if year]
|
|
|
|
accounting_relative_dir = ""
|
|
accounting_relative_path = ""
|
|
accounting_local_path = ""
|
|
storage_root_path = ""
|
|
storage_node_name = ""
|
|
registered_mirror = None
|
|
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)
|
|
fallback_dir = Path(fy_folder) / "Clients" / letter / client_folder / "Accounting"
|
|
node = get_active_storage_node_for_branch(
|
|
db,
|
|
tenant_id,
|
|
getattr(engagement, "branch_id", None),
|
|
)
|
|
if node:
|
|
storage_root_path = str(getattr(node, "storage_root_path", "") or "").strip()
|
|
storage_node_name = str(getattr(node, "node_name", "") or "").strip()
|
|
|
|
registered_mirror = get_current_accounting_mirror(db, tenant_id, client_id, fy)
|
|
if registered_mirror:
|
|
accounting_relative_dir = str(registered_mirror.accounting_relative_dir or fallback_dir.as_posix()).strip()
|
|
mirror_name = str(registered_mirror.mirror_file_name or f"client_{client_id:08d}.act").strip()
|
|
accounting_relative_path = (Path(accounting_relative_dir) / mirror_name).as_posix()
|
|
accounting_local_path = str(getattr(registered_mirror, "mirror_local_path", "") or "").strip()
|
|
if not accounting_local_path and storage_root_path:
|
|
accounting_local_path = _join_local_path(storage_root_path, accounting_relative_path)
|
|
else:
|
|
accounting_relative_dir = fallback_dir.as_posix()
|
|
accounting_relative_path = (fallback_dir / f"client_{client_id:08d}.act").as_posix()
|
|
if storage_root_path:
|
|
accounting_local_path = _join_local_path(storage_root_path, accounting_relative_path)
|
|
|
|
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)
|
|
|
|
document_options = []
|
|
tally_data_files: list[dict[str, Any]] = []
|
|
for doc in documents:
|
|
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 ""
|
|
displayed_relative_path = local_final_path or latest_relative_path
|
|
tally_year = _tally_document_year(doc)
|
|
row = {
|
|
"id": int(getattr(doc, "id", 0) or 0),
|
|
"title": str(getattr(doc, "title", "") or "Document"),
|
|
"tally_financial_year": tally_year,
|
|
"document_type": str(getattr(doc, "document_type", "GENERAL") or "GENERAL"),
|
|
"version_no": int(getattr(doc, "current_version_no", 0) or 0),
|
|
"task_instance_id": getattr(doc, "task_instance_id", None),
|
|
"latest_filename": str(getattr(latest, "original_filename", "") or "") if latest else "",
|
|
"latest_relative_path": latest_relative_path,
|
|
"local_final_path": local_final_path,
|
|
"displayed_relative_path": displayed_relative_path,
|
|
"storage_status": str(getattr(latest, "storage_status", "") or "") if latest else "",
|
|
}
|
|
document_options.append(row)
|
|
if tally_year:
|
|
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 "",
|
|
})
|
|
|
|
tally_data_files.sort(key=lambda row: row.get("financial_year", ""), reverse=True)
|
|
|
|
# Backfill Tally working paths into the existing Client/FY mirror metadata.
|
|
# This is idempotent and lets the Engagement page and all later Accounting
|
|
# tools obtain the same path without asking the user to select/upload it again.
|
|
metadata_changed = False
|
|
for tally in tally_data_files:
|
|
tally_fy = str(tally.get("financial_year") or "").strip()
|
|
if not tally_fy:
|
|
continue
|
|
local_path = str(tally.get("local_path") or "").strip()
|
|
relative_path = str(tally.get("displayed_relative_path") or tally.get("latest_relative_path") or "").strip()
|
|
row = get_current_accounting_mirror(db, tenant_id, client_id, tally_fy) if tenant_id and client_id else None
|
|
if not row:
|
|
continue
|
|
before = (
|
|
str(getattr(row, "tally_data_relative_path", "") or ""),
|
|
str(getattr(row, "tally_data_local_path", "") or ""),
|
|
)
|
|
update_accounting_mirror_paths(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
client_id=client_id,
|
|
financial_year=tally_fy,
|
|
tally_data_relative_path=relative_path,
|
|
tally_data_local_path=local_path,
|
|
)
|
|
after = (
|
|
str(getattr(row, "tally_data_relative_path", "") or ""),
|
|
str(getattr(row, "tally_data_local_path", "") or ""),
|
|
)
|
|
metadata_changed = metadata_changed or before != after
|
|
if metadata_changed:
|
|
db.commit()
|
|
registered_mirror = get_current_accounting_mirror(db, tenant_id, client_id, fy) if tenant_id and client_id and fy else registered_mirror
|
|
|
|
registered_mirror_info = None
|
|
if registered_mirror:
|
|
registered_mirror_info = {
|
|
"id": int(registered_mirror.id),
|
|
"financial_year": str(registered_mirror.financial_year or ""),
|
|
"version_no": int(registered_mirror.version_no or 1),
|
|
"status": str(registered_mirror.status or "active"),
|
|
"company_name": str(registered_mirror.company_name or ""),
|
|
"mirror_local_path": str(getattr(registered_mirror, "mirror_local_path", "") or accounting_local_path or ""),
|
|
"tally_data_relative_path": str(getattr(registered_mirror, "tally_data_relative_path", "") or ""),
|
|
"tally_data_local_path": str(getattr(registered_mirror, "tally_data_local_path", "") or ""),
|
|
"last_synced_at_utc": getattr(registered_mirror, "last_synced_at_utc", None),
|
|
}
|
|
|
|
return {
|
|
"accounting_relative_dir": accounting_relative_dir,
|
|
"accounting_relative_path": accounting_relative_path,
|
|
"accounting_local_path": accounting_local_path,
|
|
"storage_root_path": storage_root_path,
|
|
"storage_node_name": storage_node_name,
|
|
"registered_mirror": registered_mirror_info,
|
|
"tally_year_options": tally_year_options,
|
|
"tally_data_files": tally_data_files,
|
|
"task_categories": categories,
|
|
"default_upload_task_id": default_upload_task_id,
|
|
"document_options": document_options,
|
|
}
|