Add client financial year Accounting Mirror registry

This commit is contained in:
A R R R Associates
2026-09-18 20:58:41 +05:30
parent 93f2e02c35
commit 8a0d5b212d
11 changed files with 630 additions and 71 deletions
+1
View File
@@ -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
+59 -1
View File
@@ -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>
+142 -3
View File
@@ -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()
+1 -1
View File
@@ -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)),