diff --git a/app/modules/documents/services.py b/app/modules/documents/services.py index 933023c..0f4cccc 100644 --- a/app/modules/documents/services.py +++ b/app/modules/documents/services.py @@ -435,6 +435,32 @@ def build_year_wise_relative_path(db: Session, engagement: ClientServiceSubscrip return Path(fy) / "Clients" / letter / client_folder / service_category / period / doc_type / stored_filename +def build_tally_data_relative_path( + db: Session, + engagement: ClientServiceSubscription, + document: EngagementDocument, + version_no: int, + original_filename: str, + financial_year: str, +) -> Path: + """Build the local-storage target for an uploaded Tally dataset. + + Tally data is engagement-level reference data, but it is deliberately stored + under the selected financial year so current-year and prior-year datasets can + coexist and be compared without changing the engagement/task workflow. + """ + client = getattr(engagement, "client", None) or db.get(Client, engagement.client_id) + fy = sanitize_segment(f"FY{financial_year}", "FY") + letter, client_folder = client_folder_parts(client, engagement.client_id) + stored_filename = build_versioned_filename( + original_filename, + document_id=document.id, + version_no=version_no, + engagement_code=sanitize_segment(f"ENG-{engagement.id}-TALLY", "ENGAGEMENT"), + ) + return Path(fy) / "Clients" / letter / client_folder / "Accounting" / "Tally Data" / stored_filename + + def build_permanent_relative_path(db: Session, client: Client, document: PermanentClientDocument, version_no: int, original_filename: str) -> Path: letter, client_folder = client_folder_parts(client, document.client_id) category = sanitize_segment(document.category, "Other Permanent Documents") @@ -1150,6 +1176,7 @@ def save_uploaded_revision( user, existing_document_id: int | None = None, udin_required: bool | str | None = None, + tally_financial_year: str | None = None, ) -> EngagementDocument: original_filename = Path(upload_file.filename or "document.bin").name document_type = document_type if document_type in DOCUMENT_TYPES else "GENERAL" @@ -1164,6 +1191,8 @@ def save_uploaded_revision( if description is not None: document.description = description.strip() or None document.document_type = document_type + if tally_financial_year: + document.financial_year = tally_financial_year document.udin_required = resolved_udin_required if resolved_udin_required and (document.udin_status or "not_required") == "not_required": document.udin_status = "pending" @@ -1175,7 +1204,7 @@ def save_uploaded_revision( branch_id=engagement.branch_id, client_id=engagement.client_id, engagement_id=engagement.id, - financial_year=engagement.financial_year, + financial_year=tally_financial_year or engagement.financial_year, assessment_year=engagement.assessment_year, document_code="PENDING", document_type=document_type, @@ -1192,7 +1221,12 @@ def save_uploaded_revision( document.document_code = create_document_code(db, engagement.tenant_id, engagement.id, document.id) next_version_no = int(document.current_version_no or 0) + 1 - rel_path = build_year_wise_relative_path(db, engagement, document, next_version_no, original_filename) + if tally_financial_year: + rel_path = build_tally_data_relative_path( + db, engagement, document, next_version_no, original_filename, tally_financial_year + ) + else: + rel_path = build_year_wise_relative_path(db, engagement, document, next_version_no, original_filename) abs_path = DEFAULT_STORAGE_ROOT / rel_path try: abs_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/app/modules/documents/ui.py b/app/modules/documents/ui.py index 9069693..b884f48 100644 --- a/app/modules/documents/ui.py +++ b/app/modules/documents/ui.py @@ -101,6 +101,20 @@ from app.modules.services.execution import recalculate_task_aqmm_status router = APIRouter(prefix="/documents", tags=["documents-ui"]) logger = logging.getLogger("audit_storage_agent.documents_ui") +_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)) + return f"{start - 1:04d}-{start % 100:02d}" + +def _allowed_tally_financial_years(engagement) -> set[str]: + current = str(getattr(engagement, "financial_year", "") or "").strip() + previous = _previous_financial_year(current) + return {year for year in (current, previous) if year} + def _normalise_document_text(value) -> str: return re.sub(r"[^a-z0-9]+", " ", str(value or "").lower()).strip() @@ -359,6 +373,8 @@ def upload_engagement_document( remarks: str | None = Form(None), existing_document_id: str | None = Form(None), udin_required: str | None = Form(None), + upload_target: str | None = Form(None), + tally_financial_year: str | None = Form(None), file: UploadFile = File(...), return_to: str = Form(""), csrf_token: str = Form(...), @@ -381,6 +397,24 @@ def upload_engagement_document( return RedirectResponse(url=return_target if return_to else f"/documents/engagements/{engagement.id}?year_locked=1", status_code=303) if not file or not file.filename: return RedirectResponse(url=return_target if return_to else f"/documents/engagements/{engagement_id}?error=missing_file", status_code=303) + + is_tally_upload = (upload_target or "").strip().lower() == "tally" + selected_tally_fy = (tally_financial_year or "").strip() if is_tally_upload else "" + if is_tally_upload: + if selected_tally_fy not in _allowed_tally_financial_years(engagement): + return RedirectResponse(url=return_target if return_to else f"/documents/engagements/{engagement_id}?error=invalid_tally_year", status_code=303) + title = f"Tally Data - FY {selected_tally_fy}" + description = f"{_TALLY_MARKER_PREFIX}{selected_tally_fy}" + document_type = "GENERAL" + if existing_document_id: + existing_doc = get_document(db, int(existing_document_id)) + existing_marker = str(getattr(existing_doc, "description", "") or "") if existing_doc else "" + if ( + not existing_doc + or int(getattr(existing_doc, "engagement_id", 0) or 0) != int(engagement.id) + or existing_marker.strip() != f"{_TALLY_MARKER_PREFIX}{selected_tally_fy}" + ): + return RedirectResponse(url=return_target if return_to else f"/documents/engagements/{engagement_id}?error=invalid_tally_document", status_code=303) try: doc = save_uploaded_revision( db, @@ -393,6 +427,7 @@ def upload_engagement_document( user=user, existing_document_id=int(existing_document_id) if existing_document_id else None, udin_required=_bool_from_form(udin_required), + tally_financial_year=selected_tally_fy or None, ) log_document_access(db, action="upload", result="success", user=user, request=request, document=doc) db.commit() diff --git a/app/modules/services/engagement_resources.py b/app/modules/services/engagement_resources.py index 092e620..e559f5a 100644 --- a/app/modules/services/engagement_resources.py +++ b/app/modules/services/engagement_resources.py @@ -10,6 +10,28 @@ from app.modules.documents.services import client_folder_parts, get_active_stora 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("\\/") if not root: @@ -148,6 +170,8 @@ def build_engagement_resource_context(db, engagement, tasks: Iterable[Any], docu 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 = "" @@ -174,19 +198,32 @@ def build_engagement_resource_context(db, engagement, tasks: Iterable[Any], docu 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 - document_options.append({ + latest_relative_path = str(getattr(latest, "local_relative_path", "") or "") if latest else "" + 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": str(getattr(latest, "local_relative_path", "") or "") if latest else "", + "latest_relative_path": latest_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, latest_relative_path) if latest_relative_path else "", + }) + + tally_data_files.sort(key=lambda row: row.get("financial_year", ""), reverse=True) return { "accounting_relative_dir": accounting_relative_dir, @@ -194,6 +231,8 @@ def build_engagement_resource_context(db, engagement, tasks: Iterable[Any], docu "accounting_local_path": accounting_local_path, "storage_root_path": storage_root_path, "storage_node_name": storage_node_name, + "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, diff --git a/app/modules/services/templates/services/engagements/_engagement_resources.html b/app/modules/services/templates/services/engagements/_engagement_resources.html index a731a5d..83406f5 100644 --- a/app/modules/services/templates/services/engagements/_engagement_resources.html +++ b/app/modules/services/templates/services/engagements/_engagement_resources.html @@ -15,7 +15,7 @@
This follows the existing branch Storage Node → FY → Client → Accounting convention used by the Local Agent.
+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.
@@ -59,11 +59,37 @@Copy the required year path for opening-balance comparison or local Tally work.
+Select Tally Data or a service task category. Category uploads stay linked to the existing task/evidence workflow.