diff --git a/alembic/env.py b/alembic/env.py index dbaa994..33b893e 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -49,6 +49,7 @@ from app.modules.notice_cases import models as notice_cases_models # noqa: F401 from app.modules.registrations import models as registration_models # noqa: F401 from app.modules.credential_vault import models as credential_vault_models # noqa: F401 from app.modules.client_identity import models as client_identity_models # noqa: F401 +from app.modules.accounting import accounting_mirror_models as accounting_mirror_models # noqa: F401 config = context.config diff --git a/alembic/versions/20260918_accounting_mirror_registry.py b/alembic/versions/20260918_accounting_mirror_registry.py new file mode 100644 index 0000000..554014f --- /dev/null +++ b/alembic/versions/20260918_accounting_mirror_registry.py @@ -0,0 +1,52 @@ +"""Accounting Mirror client/FY registry. + +Revision ID: 20260918_accounting_mirror_reg +Revises: 20260903_tds_withholding_tools +""" +from alembic import op +import sqlalchemy as sa + +revision = "20260918_accounting_mirror_reg" +down_revision = "20260903_tds_withholding_tools" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "accounting_mirror_registry", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False), + sa.Column("storage_node_id", sa.Integer(), sa.ForeignKey("branch_storage_nodes.id", ondelete="SET NULL"), nullable=True), + sa.Column("financial_year", sa.String(length=7), nullable=False), + sa.Column("accounting_relative_dir", sa.String(length=1000), nullable=False), + sa.Column("mirror_file_name", sa.String(length=255), nullable=False), + sa.Column("company_name", sa.String(length=255), nullable=False, server_default=""), + sa.Column("company_guid", sa.String(length=160), nullable=False, server_default=""), + sa.Column("voucher_from_date", sa.Date(), nullable=True), + sa.Column("voucher_to_date", sa.Date(), nullable=True), + sa.Column("file_size_bytes", sa.BigInteger(), nullable=False, server_default="0"), + sa.Column("status", sa.String(length=30), nullable=False, server_default="active"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("replacement_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("last_synced_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_synced_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("tenant_id", "client_id", "financial_year", name="uq_accounting_mirror_registry_client_fy"), + ) + for name, col in ( + ("ix_accounting_mirror_registry_tenant_id", "tenant_id"), + ("ix_accounting_mirror_registry_client_id", "client_id"), + ("ix_accounting_mirror_registry_storage_node_id", "storage_node_id"), + ("ix_accounting_mirror_registry_financial_year", "financial_year"), + ("ix_accounting_mirror_registry_status", "status"), + ("ix_accounting_mirror_registry_is_active", "is_active"), + ): + op.create_index(name, "accounting_mirror_registry", [col], unique=False) + + +def downgrade() -> None: + op.drop_table("accounting_mirror_registry") diff --git a/app/modules/accounting/accounting_mirror_models.py b/app/modules/accounting/accounting_mirror_models.py new file mode 100644 index 0000000..14cd01c --- /dev/null +++ b/app/modules/accounting/accounting_mirror_models.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from datetime import date, datetime, timezone + +from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Integer, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db.common import CommonBase + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class AccountingMirrorRegistry(CommonBase): + """Authoritative link between one ERP client/FY and its active local .act mirror.""" + + __tablename__ = "accounting_mirror_registry" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "client_id", + "financial_year", + name="uq_accounting_mirror_registry_client_fy", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + storage_node_id: Mapped[int | None] = mapped_column(ForeignKey("branch_storage_nodes.id", ondelete="SET NULL"), nullable=True, index=True) + + financial_year: Mapped[str] = mapped_column(String(7), nullable=False, index=True) + accounting_relative_dir: Mapped[str] = mapped_column(String(1000), nullable=False) + mirror_file_name: Mapped[str] = mapped_column(String(255), nullable=False) + + company_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + company_guid: Mapped[str] = mapped_column(String(160), nullable=False, default="") + voucher_from_date: Mapped[date | None] = mapped_column(Date, nullable=True) + voucher_to_date: Mapped[date | None] = mapped_column(Date, nullable=True) + file_size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + + status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) + replacement_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + last_synced_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow) + last_synced_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow, onupdate=_utcnow) diff --git a/app/modules/accounting/accounting_mirror_registry.py b/app/modules/accounting/accounting_mirror_registry.py new file mode 100644 index 0000000..b389a3b --- /dev/null +++ b/app/modules/accounting/accounting_mirror_registry.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from datetime import date, datetime, timezone +from pathlib import Path +import re +from typing import Any + +from sqlalchemy import select + +from app.modules.accounting.accounting_mirror_models import AccountingMirrorRegistry + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _fy_key(value: str) -> int: + match = re.fullmatch(r"(\d{4})-(\d{2})", str(value or "").strip()) + return int(match.group(1)) if match else -1 + + +def _get_any_mirror(db, tenant_id: int, client_id: int, financial_year: str) -> AccountingMirrorRegistry | 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 or "").strip(), + ) + ).scalar_one_or_none() + + +def get_registered_mirror(db, tenant_id: int, client_id: int, financial_year: str) -> AccountingMirrorRegistry | None: + row = _get_any_mirror(db, tenant_id, client_id, financial_year) + return row if row and row.is_active and row.status == "active" else None + + +def list_registered_mirrors(db, tenant_id: int, client_id: int) -> list[AccountingMirrorRegistry]: + rows = db.execute( + select(AccountingMirrorRegistry).where( + AccountingMirrorRegistry.tenant_id == int(tenant_id), + AccountingMirrorRegistry.client_id == int(client_id), + AccountingMirrorRegistry.is_active.is_(True), + ) + ).scalars().all() + return sorted(rows, key=lambda row: _fy_key(row.financial_year), reverse=True) + + +def _parse_iso_date(value: Any) -> date | None: + text = str(value or "").strip() + if not text: + return None + try: + return date.fromisoformat(text[:10]) + except Exception: + return None + + +def _parse_iso_datetime(value: Any) -> datetime | None: + text = str(value or "").strip() + if not text: + return None + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + except Exception: + return None + + +def upsert_registered_mirror( + db, + *, + tenant_id: int, + client_id: int, + financial_year: str, + accounting_relative_dir: str, + storage_node_id: int | None, + mirror: dict[str, Any] | None = None, + job: dict[str, Any] | None = None, + requested_by_user_id: int | None = None, + replacement: bool = False, +) -> AccountingMirrorRegistry: + mirror = dict(mirror or {}) + job = dict(job or {}) + company = dict(mirror.get("company") or {}) + voucher_period = dict(mirror.get("voucher_period") or {}) + + row = _get_any_mirror(db, tenant_id, client_id, financial_year) + created = row is None + if row is None: + row = AccountingMirrorRegistry( + tenant_id=int(tenant_id), + client_id=int(client_id), + financial_year=str(financial_year).strip(), + accounting_relative_dir=str(accounting_relative_dir or "").strip(), + mirror_file_name=f"client_{int(client_id):08d}.act", + created_by_user_id=requested_by_user_id, + ) + db.add(row) + + row.storage_node_id = int(storage_node_id) if storage_node_id else None + row.accounting_relative_dir = str(accounting_relative_dir or row.accounting_relative_dir or "").strip() + row.mirror_file_name = Path(str(job.get("accounting_db_path") or row.mirror_file_name or f"client_{int(client_id):08d}.act")).name + row.company_name = str( + job.get("company_name") + or company.get("company_name") + or row.company_name + or "" + ).strip() + row.company_guid = str( + job.get("tally_guid") + or company.get("company_guid") + or row.company_guid + or "" + ).strip() + row.voucher_from_date = _parse_iso_date(voucher_period.get("from_date")) or row.voucher_from_date + row.voucher_to_date = _parse_iso_date(voucher_period.get("to_date")) or row.voucher_to_date + try: + row.file_size_bytes = int(mirror.get("size_bytes") or row.file_size_bytes or 0) + except Exception: + pass + row.status = "active" + row.is_active = True + row.last_synced_by_user_id = requested_by_user_id + row.last_synced_at_utc = ( + _parse_iso_datetime(job.get("finished_at_utc")) + or _parse_iso_datetime(job.get("updated_at_utc")) + or _utcnow() + ) + if replacement and not created: + row.replacement_count = int(row.replacement_count or 0) + 1 + row.updated_at_utc = _utcnow() + db.flush() + return row + + +def sync_discovered_mirrors( + db, + *, + tenant_id: int, + client_id: int, + storage_node_id: int | None, + discovered: list[dict[str, Any]], + requested_by_user_id: int | None = None, +) -> list[AccountingMirrorRegistry]: + output: list[AccountingMirrorRegistry] = [] + discovered_fys: set[str] = set() + for item in discovered or []: + fy = str(item.get("financial_year") or "").strip() + relative_dir = str(item.get("accounting_relative_dir") or "").strip() + mirror = dict(item.get("mirror") or {}) + if not re.fullmatch(r"\d{4}-\d{2}", fy) or not relative_dir or not mirror.get("ready"): + continue + discovered_fys.add(fy) + row = upsert_registered_mirror( + db, + tenant_id=tenant_id, + client_id=client_id, + financial_year=fy, + accounting_relative_dir=relative_dir, + storage_node_id=storage_node_id, + mirror=mirror, + job=item, + requested_by_user_id=requested_by_user_id, + replacement=False, + ) + output.append(row) + + existing = db.execute( + select(AccountingMirrorRegistry).where( + AccountingMirrorRegistry.tenant_id == int(tenant_id), + AccountingMirrorRegistry.client_id == int(client_id), + AccountingMirrorRegistry.is_active.is_(True), + ) + ).scalars().all() + for row in existing: + if row.financial_year not in discovered_fys: + row.status = "missing" + row.is_active = False + row.updated_at_utc = _utcnow() + db.commit() + return output diff --git a/app/modules/accounting/creditors_aging_ui.py b/app/modules/accounting/creditors_aging_ui.py index b1f6866..115d60d 100644 --- a/app/modules/accounting/creditors_aging_ui.py +++ b/app/modules/accounting/creditors_aging_ui.py @@ -13,6 +13,12 @@ from app.core.db.common import CommonSessionLocal from app.core.security.csrf import get_or_create_csrf_token from app.core.templating import templates from app.modules.accounting.accounting_mirror_service import AccountingMirrorError, sundry_creditors_aging +from app.modules.accounting.accounting_mirror_registry import ( + get_registered_mirror, + list_registered_mirrors, + sync_discovered_mirrors, +) +from app.modules.accounting.agent_bridge import request_agent_command from app.modules.accounting.ui import ( _accounting_storage_payload, _active_financial_year, @@ -43,6 +49,42 @@ def _resolve_fy(value: str, fallback: str) -> str: return fallback +def _sync_client_mirror_registry(db, node, scope, user, client) -> None: + if not node or not _node_online(node) or not client: + return + try: + response = request_agent_command( + node.node_code, + "accounting_mirror_list", + {"client_id": int(client.id)}, + timeout_seconds=20, + ) + if response.get("ok"): + sync_discovered_mirrors( + db, + tenant_id=scope.tenant_id, + client_id=int(client.id), + storage_node_id=int(node.id), + discovered=(response.get("result") or {}).get("mirrors") or [], + requested_by_user_id=int(user.id), + ) + except Exception: + pass + + +def _registered_follow_up_fys(db, tenant_id: int, client_id: int, selected_fy: str) -> list[str]: + base_start, _ = _financial_year_bounds(selected_fy) + values = [] + for row in list_registered_mirrors(db, tenant_id, client_id): + try: + start, _ = _financial_year_bounds(row.financial_year) + except Exception: + continue + if start > base_start: + values.append(row.financial_year) + return sorted(set(values), key=lambda fy: _financial_year_bounds(fy)[0]) + + def _load_analysis(request: Request, db, user, client_id: int, financial_year: str, follow_up_fy: str): client, clients, scope = _find_visible_client(db, request, user, client_id) if not client: @@ -51,6 +93,11 @@ def _load_analysis(request: Request, db, user, client_id: int, financial_year: s if not node or not _node_online(node): return None, clients, scope, "ERP Local Agent is offline for the active branch." + _sync_client_mirror_registry(db, node, scope, user, client) + base_registration = get_registered_mirror(db, scope.tenant_id, int(client.id), financial_year) + if not base_registration: + return None, clients, scope, f"No registered Accounting Mirror is available for FY {financial_year}. Create the mirror from Tally Accounting first." + fy_start, fy_end = _financial_year_bounds(financial_year) follow_up_start = "" follow_up_end = "" @@ -59,6 +106,9 @@ def _load_analysis(request: Request, db, user, client_id: int, financial_year: s follow_start, follow_end = _financial_year_bounds(follow_up_fy) if follow_start <= fy_end: return None, clients, scope, "Follow-up financial year must be after the analysis financial year." + follow_up_registration = get_registered_mirror(db, scope.tenant_id, int(client.id), follow_up_fy) + if not follow_up_registration: + return None, clients, scope, f"No registered Accounting Mirror is available for follow-up FY {follow_up_fy}." follow_up_start = follow_start.isoformat() follow_up_end = follow_end.isoformat() follow_up_payload = _accounting_storage_payload(client, follow_up_fy) @@ -103,6 +153,13 @@ def creditors_aging( selected_fy = fallback_fy selected_follow_up = str(follow_up_fy or "").strip() selected_client = next((c for c in clients if client_id and int(c.id) == int(client_id)), None) + registered_fys: list[str] = [] + follow_up_options: list[str] = [] + if selected_client: + node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) + _sync_client_mirror_registry(db, node, scope, user, selected_client) + registered_fys = [row.financial_year for row in list_registered_mirrors(db, scope.tenant_id, int(selected_client.id))] + follow_up_options = _registered_follow_up_fys(db, scope.tenant_id, int(selected_client.id), selected_fy) result = None error = "" if analyze and selected_client: @@ -119,7 +176,8 @@ def creditors_aging( "selected_client": selected_client, "selected_fy": selected_fy, "follow_up_fy": selected_follow_up, - "fy_options": _fy_options(selected_fy), + "fy_options": registered_fys or _fy_options(selected_fy), + "follow_up_options": follow_up_options, "analysis": result, "error": error, } diff --git a/app/modules/accounting/templates/accounting/sundry_creditors_aging.html b/app/modules/accounting/templates/accounting/sundry_creditors_aging.html index 82549a9..aee9a77 100644 --- a/app/modules/accounting/templates/accounting/sundry_creditors_aging.html +++ b/app/modules/accounting/templates/accounting/sundry_creditors_aging.html @@ -21,14 +21,14 @@
@@ -141,69 +141,30 @@ {% endif %} + + diff --git a/app/modules/accounting/ui.py b/app/modules/accounting/ui.py index fce016b..7087abd 100644 --- a/app/modules/accounting/ui.py +++ b/app/modules/accounting/ui.py @@ -22,6 +22,12 @@ 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, sanitize_segment from app.modules.accounting.agent_bridge import request_agent_command +from app.modules.accounting.accounting_mirror_registry import ( + get_registered_mirror, + list_registered_mirrors, + sync_discovered_mirrors, + upsert_registered_mirror, +) from app.modules.registrations.models import ClientRegistration, RegistrationType @@ -247,6 +253,30 @@ def tally_tool( node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) online = _node_online(node) + # Synchronise the cloud-side Client + FY registry from mirrors already + # present on the branch Storage Node. This is read-only and also backfills + # mirrors created before the registry feature was introduced. + if selected_client and node and online: + try: + discovered_response = request_agent_command( + node.node_code, + "accounting_mirror_list", + {"client_id": int(selected_client.id)}, + timeout_seconds=20, + ) + if discovered_response.get("ok"): + sync_discovered_mirrors( + db, + tenant_id=scope.tenant_id, + client_id=int(selected_client.id), + storage_node_id=int(node.id), + discovered=(discovered_response.get("result") or {}).get("mirrors") or [], + requested_by_user_id=int(user.id), + ) + except Exception: + # Mirror discovery must never block the existing Tally page. + pass + live_result = None analysis_history = [] command_error = error or "" @@ -304,6 +334,15 @@ def tally_tool( else selected_fy_end.isoformat() ) + registered_mirrors = ( + list_registered_mirrors(db, scope.tenant_id, int(selected_client.id)) + if selected_client else [] + ) + selected_mirror_registration = ( + get_registered_mirror(db, scope.tenant_id, int(selected_client.id), selected_financial_year) + if selected_client else None + ) + return _render( request, db, @@ -325,6 +364,8 @@ def tally_tool( selected_financial_year=selected_financial_year, workspace_financial_year=workspace_financial_year, mirror_fy_options=mirror_fy_options, + registered_mirrors=registered_mirrors, + selected_mirror_registration=selected_mirror_registration, command_error=command_error, analysis_history=analysis_history, ) @@ -764,6 +805,55 @@ def sync_tally_transactions( finally: db.close() + + +@router.get("/mirror-registration/status") +def mirror_registration_status(request: Request, client_id: int, financial_year: str): + db = CommonSessionLocal() + try: + user, response = _require_partner(request, db, "accounting.tally.view") + if response: + return JSONResponse({"ok": False, "error": "Access denied."}, status_code=403) + client, _clients, scope = _find_visible_client(db, request, user, client_id) + if not client: + return JSONResponse({"ok": False, "error": "Client is not available."}, status_code=404) + try: + _financial_year_bounds(financial_year) + except Exception: + return JSONResponse({"ok": False, "error": "Invalid financial year."}, status_code=400) + + row = get_registered_mirror(db, scope.tenant_id, int(client.id), financial_year) + node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) + file_exists = False + mirror = {} + if node and _node_online(node): + try: + storage_payload = _accounting_storage_payload(client, financial_year) + status = request_agent_command(node.node_code, "accounting_mirror_status", storage_payload, timeout_seconds=20) + if status.get("ok"): + mirror = (status.get("result") or {}).get("mirror") or {} + file_exists = bool(mirror.get("ready")) + if file_exists: + row = upsert_registered_mirror( + db, tenant_id=scope.tenant_id, client_id=int(client.id), financial_year=financial_year, + accounting_relative_dir=storage_payload["accounting_relative_dir"], storage_node_id=int(node.id), + mirror=mirror, requested_by_user_id=int(user.id), replacement=False, + ) + db.commit() + except Exception: + pass + exists = bool(row or file_exists) + return JSONResponse({ + "ok": True, + "exists": exists, + "financial_year": financial_year, + "client_name": client.client_name, + "company_name": (row.company_name if row else str((mirror.get("company") or {}).get("company_name") or "")), + "last_synced_at": (row.last_synced_at_utc.isoformat() if row and row.last_synced_at_utc else ""), + }) + finally: + db.close() + @router.post("/full-export") def start_full_accounting_export( request: Request, @@ -772,6 +862,7 @@ def start_full_accounting_export( date_from: str = Form(...), date_to: str = Form(...), financial_year: str = Form(""), + replace_existing: str = Form(""), csrf_token: str = Form(...), ): validate_csrf(request, csrf_token) @@ -800,13 +891,45 @@ def start_full_accounting_export( 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?client_id={client.id}&error={quote('ERP Local Agent is offline for the active branch.')}", status_code=303) + + storage_payload = _accounting_storage_payload(client, target_fy) + existing_registration = get_registered_mirror(db, scope.tenant_id, int(client.id), target_fy) + existing_file = False + try: + current_status = request_agent_command( + node.node_code, "accounting_mirror_status", storage_payload, timeout_seconds=20 + ) + current_mirror = ((current_status.get("result") or {}).get("mirror") or {}) if current_status.get("ok") else {} + existing_file = bool(current_mirror.get("ready")) + if existing_file: + upsert_registered_mirror( + db, tenant_id=scope.tenant_id, client_id=int(client.id), financial_year=target_fy, + accounting_relative_dir=storage_payload["accounting_relative_dir"], storage_node_id=int(node.id), + mirror=current_mirror, requested_by_user_id=int(user.id), replacement=False, + ) + db.commit() + except Exception: + existing_file = bool(existing_registration) + + replace_confirmed = str(replace_existing or "").strip().lower() in {"1", "true", "yes", "on"} + if (existing_registration or existing_file) and not replace_confirmed: + message = ( + f"An Accounting Mirror already exists for {client.client_name} - FY {target_fy}. " + "Use Replace Existing Mirror to refresh it; the current mirror will remain active if the new export fails." + ) + return RedirectResponse( + url=f"/tools/tally?client_id={client.id}&financial_year={quote(target_fy)}&error={quote(message)}", + status_code=303, + ) + result = request_agent_command( node.node_code, "accounting_full_export_start", { - **_accounting_storage_payload(client, target_fy), + **storage_payload, "tally_guid": str(tally_guid or "").strip(), "date_from": start.isoformat(), "date_to": end.isoformat(), "requested_by_user_id": int(user.id), "timeout_seconds": 1800, + "replacement_requested": bool(existing_registration or existing_file), }, timeout_seconds=20, ) if not result.get("ok"): @@ -819,7 +942,7 @@ def start_full_accounting_export( @router.get("/full-export/status") -def full_accounting_export_status(request: Request, client_id: int): +def full_accounting_export_status(request: Request, client_id: int, financial_year: str = ""): db = CommonSessionLocal() try: user, response = _require_partner(request, db, "accounting.tally.view") @@ -837,7 +960,23 @@ def full_accounting_export_status(request: Request, client_id: int): ) if not result.get("ok"): return JSONResponse({"ok": False, "error": str(result.get("error") or "Progress lookup failed.")}, status_code=500) - return JSONResponse({"ok": True, **(result.get("result") or {})}) + result_payload = result.get("result") or {} + job = result_payload.get("job") or {} + if str(job.get("status") or "").lower() == "completed": + fy = str(job.get("financial_year") or financial_year or "").strip() + if re.fullmatch(r"\d{4}-\d{2}", fy): + storage_payload = _accounting_storage_payload(client, fy) + try: + upsert_registered_mirror( + db, tenant_id=scope.tenant_id, client_id=int(client.id), financial_year=fy, + accounting_relative_dir=storage_payload["accounting_relative_dir"], storage_node_id=int(node.id), + mirror=job.get("mirror") or {}, job=job, requested_by_user_id=int(user.id), + replacement=bool(job.get("replacement_requested")), + ) + db.commit() + except Exception: + db.rollback() + return JSONResponse({"ok": True, **result_payload}) finally: db.close() diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 6a7b6de..45c3de3 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.26.16" +ERP_LOCAL_AGENT_VERSION = "1.26.17" 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/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index 644ec6e..3a4b3fc 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.26.16" +__version__ = "1.26.17" AGENT_NAME = "ERP Local Agent" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py index 73ba1d3..d5033f2 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py @@ -179,6 +179,8 @@ class AgentCommandProcessor: result = self._post_it_depreciation(payload) elif action == "accounting_mirror_status": result = self._accounting_mirror_status(payload) + elif action == "accounting_mirror_list": + result = self._accounting_mirror_list(payload) elif action == "accounting_mirror_sync": result = self._accounting_mirror_sync(payload) elif action == "accounting_full_export_start": @@ -4192,6 +4194,65 @@ class AgentCommandProcessor: "financial_year_to": fy_to.isoformat(), } + def _accounting_mirror_list(self, payload: dict[str, Any]) -> dict[str, Any]: + """Discover already-created FY mirrors for one client without relying on Tally names. + + This scans only the canonical Storage Node hierarchy and returns file metadata + needed by the ERP mirror registry. No mirror is modified. + """ + import sqlite3 as _sqlite3 + + client_id = int(payload.get("client_id") or 0) + if client_id <= 0: + raise ValueError("client_id is required.") + filename = f"client_{client_id:08d}.act" + items: list[dict[str, Any]] = [] + pattern = f"FY*/Clients/*/*/Accounting/{filename}" + for path in sorted(self.store.storage_root.glob(pattern)): + if not path.is_file(): + continue + try: + relative = path.parent.relative_to(self.store.storage_root).as_posix() + except Exception: + continue + first = relative.split("/", 1)[0] + fy = first[2:] if first.upper().startswith("FY") else "" + if not re.fullmatch(r"\d{4}-\d{2}", fy): + continue + company: dict[str, Any] = {} + voucher_period = {"from_date": "", "to_date": ""} + ready = False + try: + db = _sqlite3.connect(path, timeout=15) + db.row_factory = _sqlite3.Row + try: + row = db.execute("SELECT * FROM company_master ORDER BY synced_at DESC LIMIT 1").fetchone() + company = dict(row) if row else {} + period = db.execute("SELECT MIN(voucher_date) AS from_date, MAX(voucher_date) AS to_date FROM voucher").fetchone() + if period: + voucher_period = dict(period) + ready = bool(company) + finally: + db.close() + except Exception: + ready = False + items.append({ + "client_id": client_id, + "financial_year": fy, + "accounting_relative_dir": relative, + "accounting_db_path": str(path), + "company_name": str(company.get("company_name") or "").strip(), + "tally_guid": str(company.get("company_guid") or "").strip(), + "mirror": { + "ready": ready, + "path": str(path), + "size_bytes": path.stat().st_size, + "company": company, + "voucher_period": voucher_period, + }, + }) + return {"mirrors": items, "count": len(items), "agent": self._agent_info()} + def _accounting_mirror_status(self, payload: dict[str, Any]) -> dict[str, Any]: client_id = int(payload.get("client_id") or 0) if client_id <= 0: @@ -4248,6 +4309,10 @@ class AgentCommandProcessor: "job_id": job_id, "status": "queued", "percent": 1, "stage": "Queued", "message": "Full Accounting Export queued on the Local Agent.", "client_id": client_id, "company_name": company_name, "tally_guid": tally_guid, + "financial_year": str(payload.get("financial_year") or "").strip(), + "accounting_relative_dir": str(payload.get("accounting_relative_dir") or "").strip(), + "requested_by_user_id": requested_by, + "replacement_requested": bool(payload.get("replacement_requested")), "date_from": date_from, "date_to": date_to, "rows": {}, "started_at_utc": now, "updated_at_utc": now, "finished_at_utc": "", "error": "", "mirror_db_path": "", "accounting_db_path": str(self.store.db_path(client_id)),