Add client financial year Accounting Mirror registry
This commit is contained in:
@@ -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.registrations import models as registration_models # noqa: F401
|
||||||
from app.modules.credential_vault import models as credential_vault_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.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
|
config = context.config
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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.security.csrf import get_or_create_csrf_token
|
||||||
from app.core.templating import templates
|
from app.core.templating import templates
|
||||||
from app.modules.accounting.accounting_mirror_service import AccountingMirrorError, sundry_creditors_aging
|
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 (
|
from app.modules.accounting.ui import (
|
||||||
_accounting_storage_payload,
|
_accounting_storage_payload,
|
||||||
_active_financial_year,
|
_active_financial_year,
|
||||||
@@ -43,6 +49,42 @@ def _resolve_fy(value: str, fallback: str) -> str:
|
|||||||
return fallback
|
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):
|
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)
|
client, clients, scope = _find_visible_client(db, request, user, client_id)
|
||||||
if not client:
|
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):
|
if not node or not _node_online(node):
|
||||||
return None, clients, scope, "ERP Local Agent is offline for the active branch."
|
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)
|
fy_start, fy_end = _financial_year_bounds(financial_year)
|
||||||
follow_up_start = ""
|
follow_up_start = ""
|
||||||
follow_up_end = ""
|
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)
|
follow_start, follow_end = _financial_year_bounds(follow_up_fy)
|
||||||
if follow_start <= fy_end:
|
if follow_start <= fy_end:
|
||||||
return None, clients, scope, "Follow-up financial year must be after the analysis financial year."
|
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_start = follow_start.isoformat()
|
||||||
follow_up_end = follow_end.isoformat()
|
follow_up_end = follow_end.isoformat()
|
||||||
follow_up_payload = _accounting_storage_payload(client, follow_up_fy)
|
follow_up_payload = _accounting_storage_payload(client, follow_up_fy)
|
||||||
@@ -103,6 +153,13 @@ def creditors_aging(
|
|||||||
selected_fy = fallback_fy
|
selected_fy = fallback_fy
|
||||||
selected_follow_up = str(follow_up_fy or "").strip()
|
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)
|
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
|
result = None
|
||||||
error = ""
|
error = ""
|
||||||
if analyze and selected_client:
|
if analyze and selected_client:
|
||||||
@@ -119,7 +176,8 @@ def creditors_aging(
|
|||||||
"selected_client": selected_client,
|
"selected_client": selected_client,
|
||||||
"selected_fy": selected_fy,
|
"selected_fy": selected_fy,
|
||||||
"follow_up_fy": selected_follow_up,
|
"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,
|
"analysis": result,
|
||||||
"error": error,
|
"error": error,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,14 +21,14 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="space-y-1 text-sm"><span class="font-semibold text-slate-700">Analysis FY</span>
|
<label class="space-y-1 text-sm"><span class="font-semibold text-slate-700">Analysis FY</span>
|
||||||
<select name="financial_year" data-creditor-analysis-fy class="w-full rounded-xl border border-slate-300 px-3 py-2">
|
<select id="creditorAnalysisFY" name="financial_year" class="w-full rounded-xl border border-slate-300 px-3 py-2">
|
||||||
{% for fy in fy_options %}<option value="{{ fy }}" {% if fy == selected_fy %}selected{% endif %}>{{ fy }}</option>{% endfor %}
|
{% for fy in fy_options %}<option value="{{ fy }}" {% if fy == selected_fy %}selected{% endif %}>{{ fy }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="space-y-1 text-sm"><span class="font-semibold text-slate-700">Follow-up FY (optional)</span>
|
<label class="space-y-1 text-sm"><span class="font-semibold text-slate-700">Follow-up FY (optional)</span>
|
||||||
<select name="follow_up_fy" data-creditor-follow-up-fy class="w-full rounded-xl border border-slate-300 px-3 py-2">
|
<select id="creditorFollowUpFY" name="follow_up_fy" class="w-full rounded-xl border border-slate-300 px-3 py-2">
|
||||||
<option value="">Do not trace later payments</option>
|
<option value="">Do not trace later payments</option>
|
||||||
{% for fy in fy_options %}{% if fy > selected_fy %}<option value="{{ fy }}" {% if fy == follow_up_fy %}selected{% endif %}>{{ fy }}</option>{% endif %}{% endfor %}
|
{% for fy in follow_up_options %}<option value="{{ fy }}" {% if fy == follow_up_fy %}selected{% endif %}>{{ fy }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div class="flex items-end"><button class="w-full rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Analyse Creditors</button></div>
|
<div class="flex items-end"><button class="w-full rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Analyse Creditors</button></div>
|
||||||
@@ -142,68 +142,29 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function(){
|
||||||
// Keep Follow-up FY synchronized with the Analysis FY on the client side.
|
const analysis=document.getElementById('creditorAnalysisFY');
|
||||||
// Previously the server rendered follow-up options only for the FY that was
|
const follow=document.getElementById('creditorFollowUpFY');
|
||||||
// active when the page loaded. If the user changed Analysis FY in the form
|
const registered={{ (fy_options or [])|tojson }};
|
||||||
// (for example 2026-27 -> 2021-22), the Follow-up FY control still showed
|
if(!analysis||!follow)return;
|
||||||
// 2027-28 until the page was submitted. Rebuild it immediately from the FY
|
function startYear(fy){const m=/^(\d{4})-\d{2}$/.exec(fy||'');return m?parseInt(m[1],10):-1;}
|
||||||
// values already supplied by the server, without changing any accounting or
|
function rebuild(){
|
||||||
// FIFO calculation logic.
|
const chosen=follow.value;
|
||||||
var analysisFySelect = document.querySelector('[data-creditor-analysis-fy]');
|
const base=startYear(analysis.value);
|
||||||
var followUpFySelect = document.querySelector('[data-creditor-follow-up-fy]');
|
follow.innerHTML='';
|
||||||
|
const empty=document.createElement('option'); empty.value=''; empty.textContent='Do not trace later payments'; follow.appendChild(empty);
|
||||||
function fyStartYear(value) {
|
registered.slice().sort((a,b)=>startYear(a)-startYear(b)).forEach(function(fy){
|
||||||
var match = String(value || '').match(/^(\d{4})-\d{2}$/);
|
if(startYear(fy)<=base)return;
|
||||||
return match ? parseInt(match[1], 10) : null;
|
const opt=document.createElement('option'); opt.value=fy; opt.textContent=fy; if(fy===chosen)opt.selected=true; follow.appendChild(opt);
|
||||||
}
|
|
||||||
|
|
||||||
if (analysisFySelect && followUpFySelect) {
|
|
||||||
var fyUniverse = Array.from(analysisFySelect.options)
|
|
||||||
.map(function (option) { return String(option.value || '').trim(); })
|
|
||||||
.filter(function (value) { return fyStartYear(value) !== null; });
|
|
||||||
|
|
||||||
function rebuildFollowUpYears() {
|
|
||||||
var selectedFy = String(analysisFySelect.value || '').trim();
|
|
||||||
var selectedStart = fyStartYear(selectedFy);
|
|
||||||
var previousFollowUp = String(followUpFySelect.value || '').trim();
|
|
||||||
|
|
||||||
while (followUpFySelect.options.length) {
|
|
||||||
followUpFySelect.remove(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
var emptyOption = document.createElement('option');
|
|
||||||
emptyOption.value = '';
|
|
||||||
emptyOption.textContent = 'Do not trace later payments';
|
|
||||||
followUpFySelect.appendChild(emptyOption);
|
|
||||||
|
|
||||||
if (selectedStart === null) return;
|
|
||||||
|
|
||||||
var laterYears = fyUniverse
|
|
||||||
.filter(function (fy) {
|
|
||||||
var start = fyStartYear(fy);
|
|
||||||
return start !== null && start > selectedStart;
|
|
||||||
})
|
|
||||||
.sort(function (a, b) { return fyStartYear(a) - fyStartYear(b); });
|
|
||||||
|
|
||||||
laterYears.forEach(function (fy) {
|
|
||||||
var option = document.createElement('option');
|
|
||||||
option.value = fy;
|
|
||||||
option.textContent = fy;
|
|
||||||
followUpFySelect.appendChild(option);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (laterYears.indexOf(previousFollowUp) !== -1) {
|
|
||||||
followUpFySelect.value = previousFollowUp;
|
|
||||||
} else {
|
|
||||||
followUpFySelect.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
analysisFySelect.addEventListener('change', rebuildFollowUpYears);
|
|
||||||
rebuildFollowUpYears();
|
|
||||||
}
|
}
|
||||||
|
analysis.addEventListener('change',rebuild);
|
||||||
|
rebuild();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
function applyBucket(detailRow, bucket) {
|
function applyBucket(detailRow, bucket) {
|
||||||
if (!detailRow) return;
|
if (!detailRow) return;
|
||||||
detailRow.querySelectorAll('[data-creditor-lot]').forEach(function (lot) {
|
detailRow.querySelectorAll('[data-creditor-lot]').forEach(function (lot) {
|
||||||
|
|||||||
@@ -290,9 +290,17 @@
|
|||||||
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">Read-only export</span>
|
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">Read-only export</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900"><strong>Before export:</strong> open the mapped company in TallyPrime and set Tally's active period (Alt+F2) to cover the full period you want exported. The ODBC mirror reads Tally's active period; the ERP does not change Tally's period automatically.</div>
|
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900"><strong>Before export:</strong> open the mapped company in TallyPrime and set Tally's active period (Alt+F2) to cover the full period you want exported. The ODBC mirror reads Tally's active period; the ERP does not change Tally's period automatically.</div>
|
||||||
|
<div id="mirrorRegistryStatus" class="mt-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
|
||||||
|
{% if selected_mirror_registration %}
|
||||||
|
<strong>Mirror registered:</strong> FY {{ selected_mirror_registration.financial_year }}{% if selected_mirror_registration.company_name %} · {{ selected_mirror_registration.company_name }}{% endif %}{% if selected_mirror_registration.last_synced_at_utc %} · last synced {{ selected_mirror_registration.last_synced_at_utc.strftime('%d-%m-%Y %H:%M') }}{% endif %}. Creating this FY again will ask for replacement confirmation.
|
||||||
|
{% else %}
|
||||||
|
<strong>No registered mirror for FY {{ selected_financial_year }}.</strong> Creating the mirror will register it to this client and financial year for Accounting/Audit tools.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<form method="post" action="/tools/tally/full-export" class="mt-5 grid gap-4 lg:grid-cols-5 lg:items-end" id="mirrorExportForm">
|
<form method="post" action="/tools/tally/full-export" class="mt-5 grid gap-4 lg:grid-cols-5 lg:items-end" id="mirrorExportForm">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||||
<input type="hidden" name="client_id" value="{{ selected_client.id }}" />
|
<input type="hidden" name="client_id" value="{{ selected_client.id }}" />
|
||||||
|
<input type="hidden" name="replace_existing" id="mirrorReplaceExisting" value="" />
|
||||||
<label class="text-sm font-medium text-slate-700">Target Financial Year
|
<label class="text-sm font-medium text-slate-700">Target Financial Year
|
||||||
<select id="mirrorFinancialYear" name="financial_year" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
|
<select id="mirrorFinancialYear" name="financial_year" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
|
||||||
{% for fy in mirror_fy_options %}
|
{% for fy in mirror_fy_options %}
|
||||||
@@ -553,7 +561,7 @@
|
|||||||
function setText(id,v){const e=document.getElementById(id);if(e)e.textContent=String(v??'');}
|
function setText(id,v){const e=document.getElementById(id);if(e)e.textContent=String(v??'');}
|
||||||
async function pollFullExport(){
|
async function pollFullExport(){
|
||||||
try{
|
try{
|
||||||
const r=await fetch('/tools/tally/full-export/status?client_id='+encodeURIComponent(clientId),{cache:'no-store'});
|
const fyEl=document.getElementById('mirrorFinancialYear'); const fyValue=fyEl?fyEl.value:''; const r=await fetch('/tools/tally/full-export/status?client_id='+encodeURIComponent(clientId)+'&financial_year='+encodeURIComponent(fyValue),{cache:'no-store'});
|
||||||
const d=await r.json(); if(!r.ok||!d.ok||!d.job)return;
|
const d=await r.json(); if(!r.ok||!d.ok||!d.job)return;
|
||||||
const j=d.job||{}, p=Math.max(0,Math.min(100,Number(j.percent)||0)); box.classList.remove('hidden');
|
const j=d.job||{}, p=Math.max(0,Math.min(100,Number(j.percent)||0)); box.classList.remove('hidden');
|
||||||
setText('fullExportStage',j.stage||j.status||'Working…'); setText('fullExportMessage',j.message||''); setText('fullExportPct',Math.round(p)+'%');
|
setText('fullExportStage',j.stage||j.status||'Working…'); setText('fullExportMessage',j.message||''); setText('fullExportPct',Math.round(p)+'%');
|
||||||
@@ -609,6 +617,49 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
applyFY(fy.value);
|
applyFY(fy.value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mirrorForm=document.getElementById("mirrorExportForm");
|
||||||
|
const replaceField=document.getElementById("mirrorReplaceExisting");
|
||||||
|
const registryStatus=document.getElementById("mirrorRegistryStatus");
|
||||||
|
let confirmingMirror=false;
|
||||||
|
async function refreshMirrorRegistrationStatus(){
|
||||||
|
if(!fy||!clientId)return;
|
||||||
|
try{
|
||||||
|
const r=await fetch('/tools/tally/mirror-registration/status?client_id='+encodeURIComponent(clientId)+'&financial_year='+encodeURIComponent(fy.value),{cache:'no-store'});
|
||||||
|
const d=await r.json();
|
||||||
|
if(!r.ok||!d.ok)return;
|
||||||
|
if(registryStatus){
|
||||||
|
registryStatus.innerHTML=d.exists
|
||||||
|
? '<strong>Mirror registered:</strong> FY '+fy.value+(d.company_name?' · '+d.company_name:'')+(d.last_synced_at?' · last synced '+new Date(d.last_synced_at).toLocaleString():'')+'. Creating this FY again will ask for replacement confirmation.'
|
||||||
|
: '<strong>No registered mirror for FY '+fy.value+'.</strong> Creating the mirror will register it to this client and financial year for Accounting/Audit tools.';
|
||||||
|
}
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
if(fy)fy.addEventListener("change",refreshMirrorRegistrationStatus);
|
||||||
|
refreshMirrorRegistrationStatus();
|
||||||
|
|
||||||
|
if(mirrorForm){
|
||||||
|
mirrorForm.addEventListener("submit",async function(event){
|
||||||
|
if(confirmingMirror || (replaceField&&replaceField.value==='1'))return;
|
||||||
|
event.preventDefault();
|
||||||
|
try{
|
||||||
|
const r=await fetch('/tools/tally/mirror-registration/status?client_id='+encodeURIComponent(clientId)+'&financial_year='+encodeURIComponent(fy.value),{cache:'no-store'});
|
||||||
|
const d=await r.json();
|
||||||
|
if(!r.ok||!d.ok){ mirrorForm.submit(); return; }
|
||||||
|
if(d.exists){
|
||||||
|
const label=(d.client_name||'this client')+' - FY '+fy.value;
|
||||||
|
const ok=window.confirm('An Accounting Mirror already exists for '+label+'.\n\nReplace the existing mirror?\n\nThe current mirror will remain active if the new export fails.');
|
||||||
|
if(!ok)return;
|
||||||
|
if(replaceField)replaceField.value='1';
|
||||||
|
}
|
||||||
|
confirmingMirror=true;
|
||||||
|
mirrorForm.submit();
|
||||||
|
}catch(e){
|
||||||
|
confirmingMirror=true;
|
||||||
|
mirrorForm.submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -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.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.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.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
|
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)
|
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
|
||||||
online = _node_online(node)
|
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
|
live_result = None
|
||||||
analysis_history = []
|
analysis_history = []
|
||||||
command_error = error or ""
|
command_error = error or ""
|
||||||
@@ -304,6 +334,15 @@ def tally_tool(
|
|||||||
else selected_fy_end.isoformat()
|
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(
|
return _render(
|
||||||
request,
|
request,
|
||||||
db,
|
db,
|
||||||
@@ -325,6 +364,8 @@ def tally_tool(
|
|||||||
selected_financial_year=selected_financial_year,
|
selected_financial_year=selected_financial_year,
|
||||||
workspace_financial_year=workspace_financial_year,
|
workspace_financial_year=workspace_financial_year,
|
||||||
mirror_fy_options=mirror_fy_options,
|
mirror_fy_options=mirror_fy_options,
|
||||||
|
registered_mirrors=registered_mirrors,
|
||||||
|
selected_mirror_registration=selected_mirror_registration,
|
||||||
command_error=command_error,
|
command_error=command_error,
|
||||||
analysis_history=analysis_history,
|
analysis_history=analysis_history,
|
||||||
)
|
)
|
||||||
@@ -764,6 +805,55 @@ def sync_tally_transactions(
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
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")
|
@router.post("/full-export")
|
||||||
def start_full_accounting_export(
|
def start_full_accounting_export(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -772,6 +862,7 @@ def start_full_accounting_export(
|
|||||||
date_from: str = Form(...),
|
date_from: str = Form(...),
|
||||||
date_to: str = Form(...),
|
date_to: str = Form(...),
|
||||||
financial_year: str = Form(""),
|
financial_year: str = Form(""),
|
||||||
|
replace_existing: str = Form(""),
|
||||||
csrf_token: str = Form(...),
|
csrf_token: str = Form(...),
|
||||||
):
|
):
|
||||||
validate_csrf(request, csrf_token)
|
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)
|
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
|
||||||
if not node or not _node_online(node):
|
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)
|
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(
|
result = request_agent_command(
|
||||||
node.node_code, "accounting_full_export_start",
|
node.node_code, "accounting_full_export_start",
|
||||||
{
|
{
|
||||||
**_accounting_storage_payload(client, target_fy),
|
**storage_payload,
|
||||||
"tally_guid": str(tally_guid or "").strip(),
|
"tally_guid": str(tally_guid or "").strip(),
|
||||||
"date_from": start.isoformat(), "date_to": end.isoformat(),
|
"date_from": start.isoformat(), "date_to": end.isoformat(),
|
||||||
"requested_by_user_id": int(user.id), "timeout_seconds": 1800,
|
"requested_by_user_id": int(user.id), "timeout_seconds": 1800,
|
||||||
|
"replacement_requested": bool(existing_registration or existing_file),
|
||||||
}, timeout_seconds=20,
|
}, timeout_seconds=20,
|
||||||
)
|
)
|
||||||
if not result.get("ok"):
|
if not result.get("ok"):
|
||||||
@@ -819,7 +942,7 @@ def start_full_accounting_export(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/full-export/status")
|
@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()
|
db = CommonSessionLocal()
|
||||||
try:
|
try:
|
||||||
user, response = _require_partner(request, db, "accounting.tally.view")
|
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"):
|
if not result.get("ok"):
|
||||||
return JSONResponse({"ok": False, "error": str(result.get("error") or "Progress lookup failed.")}, status_code=500)
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import io
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
ERP_LOCAL_AGENT_VERSION = "1.26.16"
|
ERP_LOCAL_AGENT_VERSION = "1.26.17"
|
||||||
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
||||||
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
||||||
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
__version__ = "1.26.16"
|
__version__ = "1.26.17"
|
||||||
AGENT_NAME = "ERP Local Agent"
|
AGENT_NAME = "ERP Local Agent"
|
||||||
|
|||||||
@@ -179,6 +179,8 @@ class AgentCommandProcessor:
|
|||||||
result = self._post_it_depreciation(payload)
|
result = self._post_it_depreciation(payload)
|
||||||
elif action == "accounting_mirror_status":
|
elif action == "accounting_mirror_status":
|
||||||
result = self._accounting_mirror_status(payload)
|
result = self._accounting_mirror_status(payload)
|
||||||
|
elif action == "accounting_mirror_list":
|
||||||
|
result = self._accounting_mirror_list(payload)
|
||||||
elif action == "accounting_mirror_sync":
|
elif action == "accounting_mirror_sync":
|
||||||
result = self._accounting_mirror_sync(payload)
|
result = self._accounting_mirror_sync(payload)
|
||||||
elif action == "accounting_full_export_start":
|
elif action == "accounting_full_export_start":
|
||||||
@@ -4192,6 +4194,65 @@ class AgentCommandProcessor:
|
|||||||
"financial_year_to": fy_to.isoformat(),
|
"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]:
|
def _accounting_mirror_status(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
client_id = int(payload.get("client_id") or 0)
|
client_id = int(payload.get("client_id") or 0)
|
||||||
if client_id <= 0:
|
if client_id <= 0:
|
||||||
@@ -4248,6 +4309,10 @@ class AgentCommandProcessor:
|
|||||||
"job_id": job_id, "status": "queued", "percent": 1,
|
"job_id": job_id, "status": "queued", "percent": 1,
|
||||||
"stage": "Queued", "message": "Full Accounting Export queued on the Local Agent.",
|
"stage": "Queued", "message": "Full Accounting Export queued on the Local Agent.",
|
||||||
"client_id": client_id, "company_name": company_name, "tally_guid": tally_guid,
|
"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": {},
|
"date_from": date_from, "date_to": date_to, "rows": {},
|
||||||
"started_at_utc": now, "updated_at_utc": now, "finished_at_utc": "",
|
"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)),
|
"error": "", "mirror_db_path": "", "accounting_db_path": str(self.store.db_path(client_id)),
|
||||||
|
|||||||
Reference in New Issue
Block a user