Simplify Client FY Tally mirror workflow

This commit is contained in:
A R R R Associates
2026-09-20 22:52:18 +05:30
parent 1462a823ee
commit 84220554c4
3 changed files with 357 additions and 212 deletions
@@ -127,214 +127,67 @@
</section>
{% if selected_client %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<section class="rounded-2xl bg-white p-5 shadow-soft" id="client-fy-tally-mirror">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="font-semibold text-slate-900">Accounting Storage</h2>
<h2 class="font-semibold text-slate-900">Client Financial-Year Tally Mirror</h2>
<p class="mt-1 text-sm text-slate-500">{{ selected_client.client_name }}</p>
<p class="mt-1 text-xs text-slate-500">Select the financial year and the Tally data. ERP will validate the client identity, create the FY storage automatically, create/refresh the mirror, and update the Client/FY Mirror Registry.</p>
</div>
<span class="rounded-full px-3 py-1 text-xs font-semibold {% if accounting and accounting.exists %}bg-emerald-50 text-emerald-700{% else %}bg-amber-50 text-amber-700{% endif %}">
{{ 'Initialized' if accounting and accounting.exists else 'Not initialized' }}
</span>
</div>
{% if accounting and accounting.exists %}
<dl class="mt-4 grid gap-4 md:grid-cols-2">
<div><dt class="text-xs font-semibold uppercase tracking-wide text-slate-500">.act Database</dt><dd class="mt-1 break-all text-sm font-medium text-slate-900">{{ accounting.db_path }}</dd></div>
<div><dt class="text-xs font-semibold uppercase tracking-wide text-slate-500">Schema Version</dt><dd class="mt-1 text-sm font-medium text-slate-900">{{ accounting.metadata.schema_version or '2' }}</dd></div>
</dl>
{% else %}
<form method="post" action="/tools/tally/initialize" class="mt-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="client_id" value="{{ selected_client.id }}" />
<button type="submit" class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800">Create .act Accounting Storage</button>
</form>
{% endif %}
</section>
{% if accounting and accounting.exists and tally and tally.connected %}
{% set active_mappings = (accounting.mappings or []) | selectattr('is_active') | list %}
{% if active_mappings %}
{% set current_mapping = active_mappings[0] %}
{% if selected_client and analysis_history %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="flex items-center justify-between gap-3"><div><h2 class="font-semibold text-slate-900">Recent Accounting Analysis</h2><p class="mt-1 text-sm text-slate-500">Stored in the client local SQLite accounting database.</p></div></div>
<div class="mt-3 overflow-x-auto"><table class="min-w-full text-sm"><thead><tr class="text-left text-xs uppercase text-slate-500"><th class="p-2">Run</th><th class="p-2">Analysis</th><th class="p-2">FY / Period</th><th class="p-2">Completed</th><th class="p-2"></th></tr></thead><tbody>{% for row in analysis_history %}<tr class="border-t"><td class="p-2">#{{ row.id }}</td><td class="p-2 font-medium">{{ row.analysis_type|replace('_',' ')|title }}</td><td class="p-2">{{ row.financial_year }} · {{ row.period_from }} → {{ row.period_to }}</td><td class="p-2">{{ row.completed_at_utc }}</td><td class="p-2 text-right"><a class="font-semibold text-brand-700" href="/tools/tally/analysis/{{ row.id }}/download?client_id={{ selected_client.id }}&financial_year={{ row.financial_year }}&format=xlsx">Excel</a> · <a class="font-semibold text-brand-700" href="/tools/tally/analysis/{{ row.id }}/download?client_id={{ selected_client.id }}&financial_year={{ row.financial_year }}&format=pdf">PDF</a></td></tr>{% endfor %}</tbody></table></div>
</section>
{% endif %}
<details class="rounded-2xl border border-slate-200 bg-white shadow-soft">
<summary class="cursor-pointer list-none px-5 py-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="font-semibold text-slate-900">Tally Company Linked</h2>
<p class="mt-1 text-sm text-slate-500">
{{ selected_client.client_name }} → {{ current_mapping.company_name }}
{% if current_mapping.gstin %} · {{ current_mapping.gstin }}{% endif %}
</p>
</div>
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">Mapped by GUID</span>
</div>
<p class="mt-2 text-xs text-slate-500">Mapping is a one-time setup. Open this panel only when you need to change the linked Tally company.</p>
</summary>
<div class="border-t border-slate-100 p-5">
<div>
<h3 class="font-semibold text-slate-900">Change ERP Scope to Tally Company Mapping</h3>
<p class="mt-1 text-sm text-slate-500">The Tally GUID remains the permanent identifier. Company name and GSTIN are descriptive snapshots.</p>
</div>
<form method="post" action="/tools/tally/map-company" class="mt-5 grid gap-4 lg:grid-cols-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="client_id" value="{{ selected_client.id }}" />
<label class="text-sm font-medium text-slate-700">
ERP Scope
<select name="registration_id" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="">Client level · {{ selected_client.client_name }}</option>
{% for reg, reg_type in registrations %}
<option value="{{ reg.id }}">
{{ reg_type.name }} · {{ reg.registration_number }}
{% if reg.trade_name %} · {{ reg.trade_name }}{% elif reg.legal_name %} · {{ reg.legal_name }}{% endif %}
</option>
{% endfor %}
</select>
<span class="mt-1 block text-xs font-normal text-slate-500">Choose a GSTIN/other registration when the Tally company belongs to that registration; otherwise use client level.</span>
</label>
<label class="text-sm font-medium text-slate-700">
Currently Open in TallyPrime
<select name="tally_guid" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="">Select Tally company</option>
{% for company in tally.companies %}
{% if company.guid %}
<option value="{{ company.guid }}">{{ company.name }}{% if company.gstin %} · {{ company.gstin }}{% endif %}</option>
{% endif %}
{% endfor %}
</select>
<span class="mt-1 block text-xs font-normal text-slate-500">Companies without a GUID are intentionally not offered for permanent mapping.</span>
</label>
<label class="flex items-start gap-2 lg:col-span-2">
<input type="checkbox" name="allow_gstin_mismatch" value="true" class="mt-1 rounded border-slate-300" />
<span class="text-sm text-slate-700">
Allow GSTIN mismatch after manual verification.
<span class="block text-xs text-slate-500">For a GSTIN registration, Phase 3 blocks a mapping when both ERP and Tally contain different GSTINs unless this override is explicitly selected.</span>
</span>
</label>
<div class="lg:col-span-2">
<button type="submit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save Tally Mapping</button>
</div>
</form>
</div>
</details>
{% else %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<div>
<h2 class="font-semibold text-slate-900">Link Tally Company</h2>
<p class="mt-1 text-sm text-slate-500">One-time setup. The Tally GUID is stored as the permanent identifier; company name and GSTIN are descriptive snapshots.</p>
</div>
<form method="post" action="/tools/tally/map-company" class="mt-5 grid gap-4 lg:grid-cols-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="client_id" value="{{ selected_client.id }}" />
<label class="text-sm font-medium text-slate-700">
ERP Scope
<select name="registration_id" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="">Client level · {{ selected_client.client_name }}</option>
{% for reg, reg_type in registrations %}
<option value="{{ reg.id }}">
{{ reg_type.name }} · {{ reg.registration_number }}
{% if reg.trade_name %} · {{ reg.trade_name }}{% elif reg.legal_name %} · {{ reg.legal_name }}{% endif %}
</option>
{% endfor %}
</select>
<span class="mt-1 block text-xs font-normal text-slate-500">Choose a registration only when the Tally company belongs specifically to that registration; otherwise use client level.</span>
</label>
<label class="text-sm font-medium text-slate-700">
Currently Open in TallyPrime
<select name="tally_guid" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="">Select Tally company</option>
{% for company in tally.companies %}
{% if company.guid %}
<option value="{{ company.guid }}">{{ company.name }}{% if company.gstin %} · {{ company.gstin }}{% endif %}</option>
{% endif %}
{% endfor %}
</select>
</label>
<label class="flex items-start gap-2 lg:col-span-2">
<input type="checkbox" name="allow_gstin_mismatch" value="true" class="mt-1 rounded border-slate-300" />
<span class="text-sm text-slate-700">
Allow GSTIN mismatch after manual verification.
<span class="block text-xs text-slate-500">Use only when the ERP registration and the selected Tally company have intentionally different GSTIN snapshots.</span>
</span>
</label>
<div class="lg:col-span-2">
<button type="submit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save Tally Mapping</button>
</div>
</form>
</section>
{% endif %}
{% endif %}
{% if accounting and accounting.exists %}
<section class="rounded-2xl bg-white p-5 shadow-soft" id="full-accounting-export">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="font-semibold text-slate-900">Mirror Tally to SQLite</h2>
<p class="mt-1 text-sm text-slate-500">Create or refresh the client Accounting Mirror from Tally using the high-speed read-only ODBC path. After this finishes, ERP analysis tools read the local SQLite .act data instead of repeatedly extracting from Tally.</p>
</div>
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">Read-only export</span>
</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 available:</strong> FY {{ selected_mirror_registration.financial_year }} · Version {{ selected_mirror_registration.version_no }}{% 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 creates a new version and keeps the earlier version.
<div class="mt-3 grid gap-2 lg:grid-cols-2">
{% if selected_mirror_registration.mirror_local_path %}
<div class="rounded-lg border border-slate-200 bg-white p-2.5"><div class="text-[11px] font-semibold uppercase text-slate-500">Registered Mirror Path</div><div class="mt-1 break-all font-mono text-[11px] text-slate-700">{{ selected_mirror_registration.mirror_local_path }}</div></div>
{% endif %}
{% if selected_mirror_registration.tally_data_local_path %}
<div class="rounded-lg border border-slate-200 bg-white p-2.5"><div class="text-[11px] font-semibold uppercase text-slate-500">Tally Working Data Path</div><div class="mt-1 break-all font-mono text-[11px] text-slate-700">{{ selected_mirror_registration.tally_data_local_path }}</div></div>
{% endif %}
</div>
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">FY {{ selected_financial_year }} · Mirror Ready</span>
{% 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. Existing canonical mirrors discovered by the Local Agent are adopted automatically; you do not need to recreate them.
<span class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">FY {{ selected_financial_year }} · Not Mirrored</span>
{% 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">
{% if not agent_online %}
<div class="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">ERP Local Agent is offline for this branch. Start the Local Agent before creating or refreshing the mirror.</div>
{% elif not tally or not tally.connected %}
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">TallyPrime is not connected. Open TallyPrime with the required company data and click <strong>Refresh Tally Companies</strong>.</div>
{% else %}
<form method="post" action="/tools/tally/full-export" class="mt-5 grid gap-4 lg:grid-cols-[1fr_2fr_auto] lg:items-end" id="mirrorExportForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<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
<input type="hidden" name="allow_identity_mismatch" id="mirrorAllowIdentityMismatch" value="" />
<input id="mirrorDateFrom" type="hidden" name="date_from" value="{{ transaction_date_from }}" />
<input id="mirrorDateTo" type="hidden" name="date_to" value="{{ transaction_date_to }}" />
<label class="text-sm font-medium text-slate-700">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">
{% for fy in mirror_fy_options %}
<option value="{{ fy }}" {% if fy == selected_financial_year %}selected{% endif %}>FY {{ fy }}</option>
{% endfor %}
</select>
<span class="mt-1 block text-xs text-slate-500">Defaults to the ERP workspace FY (FY {{ workspace_financial_year }}). You can still choose another FY to create or refresh its separate mirror.</span>
<span class="mt-1 block text-xs font-normal text-slate-500">Defaults to the ERP workspace FY {{ workspace_financial_year }}.</span>
</label>
<label class="text-sm font-medium text-slate-700">Mapped Tally Company
<select name="tally_guid" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="">Select mapped company</option>
{% for mapping in accounting.mappings or [] %}
{% if mapping.tally_guid and mapping.is_active %}
<option value="{{ mapping.tally_guid }}">{{ mapping.company_name }}{% if mapping.gstin %} · {{ mapping.gstin }}{% endif %}</option>
<label class="text-sm font-medium text-slate-700">Tally Data
<select id="mirrorTallyGuid" name="tally_guid" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="">Select Tally company / data</option>
{% for company in tally.companies or [] %}
{% if company.guid %}
<option value="{{ company.guid }}">{{ company.name }}{% if company.gstin %} · GSTIN {{ company.gstin }}{% endif %}</option>
{% endif %}
{% endfor %}
</select>
<span class="mt-1 block text-xs font-normal text-slate-500">Live list from TallyPrime. No separate initialization or mapping is required.</span>
</label>
<label class="text-sm font-medium text-slate-700">From Date<input id="mirrorDateFrom" type="date" name="date_from" required value="{{ transaction_date_from }}" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm" /></label>
<label class="text-sm font-medium text-slate-700">To Date<input id="mirrorDateTo" type="date" name="date_to" required value="{{ transaction_date_to }}" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm" /></label>
<button id="fullExportButton" type="submit" class="rounded-xl bg-emerald-700 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-800">{% if selected_mirror_registration %}Create New Version{% else %}Mirror Tally to SQLite{% endif %}</button>
<button id="fullExportButton" type="submit" class="rounded-xl bg-emerald-700 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-800">{% if selected_mirror_registration %}Refresh Mirror{% else %}Create Mirror{% endif %}</button>
</form>
<div class="mt-3 flex flex-wrap items-center gap-3 text-sm">
<button type="button" id="mirrorPreviousFY" class="rounded-lg border border-slate-300 bg-white px-3 py-1.5 font-semibold text-slate-700">Use Previous FY</button>
<span class="text-slate-500">Select an FY here to create or refresh that year's separate local mirror in the client's FY storage hierarchy.</span>
<div id="mirrorIdentityStatus" class="mt-3 hidden rounded-xl border px-4 py-3 text-sm"></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>Registered mirror:</strong> FY {{ selected_mirror_registration.financial_year }} · Version {{ selected_mirror_registration.version_no }}{% 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 %}.
{% if selected_mirror_registration.mirror_local_path %}<div class="mt-2 break-all font-mono text-[11px] text-slate-600">{{ selected_mirror_registration.mirror_local_path }}</div>{% endif %}
{% else %}
<strong>No registered mirror for FY {{ selected_financial_year }}.</strong> The mirror will be created under the normal Client/FY directory rules and registered automatically.
{% endif %}
</div>
<div id="fullExportProgress" class="mt-5 hidden rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="flex items-center justify-between gap-3"><div><div id="fullExportStage" class="font-semibold text-slate-900">Waiting…</div><div id="fullExportMessage" class="mt-1 text-xs text-slate-500"></div></div><div id="fullExportPct" class="text-lg font-bold text-slate-900">0%</div></div>
<div class="mt-3 h-3 overflow-hidden rounded-full bg-slate-200"><div id="fullExportBar" class="h-full bg-emerald-600 transition-all duration-300" style="width:0%"></div></div>
@@ -342,10 +195,17 @@
<div id="fullExportMasterDiagnostics" class="mt-3 hidden rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs text-slate-600"></div>
<div id="fullExportPaths" class="mt-3 break-all text-xs text-slate-500"></div><div id="fullExportError" class="mt-3 hidden rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700"></div>
</div>
{% endif %}
</section>
{% if selected_client and analysis_history %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="flex items-center justify-between gap-3"><div><h2 class="font-semibold text-slate-900">Recent Accounting Analysis</h2><p class="mt-1 text-sm text-slate-500">Stored against the registered Client/FY Accounting Mirror.</p></div></div>
<div class="mt-3 overflow-x-auto"><table class="min-w-full text-sm"><thead><tr class="text-left text-xs uppercase text-slate-500"><th class="p-2">Run</th><th class="p-2">Analysis</th><th class="p-2">FY / Period</th><th class="p-2">Completed</th><th class="p-2"></th></tr></thead><tbody>{% for row in analysis_history %}<tr class="border-t"><td class="p-2">#{{ row.id }}</td><td class="p-2 font-medium">{{ row.analysis_type|replace('_',' ')|title }}</td><td class="p-2">{{ row.financial_year }} · {{ row.period_from }} → {{ row.period_to }}</td><td class="p-2">{{ row.completed_at_utc }}</td><td class="p-2 text-right"><a class="font-semibold text-brand-700" href="/tools/tally/analysis/{{ row.id }}/download?client_id={{ selected_client.id }}&financial_year={{ row.financial_year }}&format=xlsx">Excel</a> · <a class="font-semibold text-brand-700" href="/tools/tally/analysis/{{ row.id }}/download?client_id={{ selected_client.id }}&financial_year={{ row.financial_year }}&format=pdf">PDF</a></td></tr>{% endfor %}</tbody></table></div>
</section>
{% endif %}
<details class="rounded-2xl border border-slate-200 bg-white shadow-soft">
<details class="rounded-2xl border border-slate-200 bg-white shadow-soft">
<summary class="cursor-pointer list-none px-5 py-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
@@ -356,6 +216,39 @@
</div>
</summary>
<div class="space-y-6 border-t border-slate-100 p-5">
<details class="rounded-xl border border-slate-200 bg-slate-50">
<summary class="cursor-pointer px-4 py-3 text-sm font-semibold text-slate-700">Legacy Storage / Mapping Setup</summary>
<div class="space-y-4 border-t border-slate-200 p-4">
<p class="text-xs text-slate-500">Retained for compatibility and troubleshooting. Normal mirroring above initializes storage and maintains the internal GUID mapping automatically.</p>
{% if not accounting or not accounting.exists %}
<form method="post" action="/tools/tally/initialize">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="client_id" value="{{ selected_client.id }}" />
<button type="submit" class="rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-white">Initialize Legacy .act Storage</button>
</form>
{% endif %}
{% if tally and tally.connected %}
<form method="post" action="/tools/tally/map-company" class="grid gap-3 lg:grid-cols-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="client_id" value="{{ selected_client.id }}" />
<label class="text-xs font-medium text-slate-600">ERP Scope
<select name="registration_id" class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="">Client level · {{ selected_client.client_name }}</option>
{% for reg, reg_type in registrations %}<option value="{{ reg.id }}">{{ reg_type.name }} · {{ reg.registration_number }}{% if reg.trade_name %} · {{ reg.trade_name }}{% elif reg.legal_name %} · {{ reg.legal_name }}{% endif %}</option>{% endfor %}
</select>
</label>
<label class="text-xs font-medium text-slate-600">Tally Company
<select name="tally_guid" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="">Select Tally company</option>
{% for company in tally.companies or [] %}{% if company.guid %}<option value="{{ company.guid }}">{{ company.name }}{% if company.gstin %} · {{ company.gstin }}{% endif %}</option>{% endif %}{% endfor %}
</select>
</label>
<label class="flex items-start gap-2 lg:col-span-2"><input type="checkbox" name="allow_gstin_mismatch" value="true" class="mt-1 rounded border-slate-300" /><span class="text-xs text-slate-600">Allow GSTIN mismatch after manual verification.</span></label>
<div class="lg:col-span-2"><button type="submit" class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700">Save Legacy Mapping</button></div>
</form>
{% endif %}
</div>
</details>
{% if accounting and accounting.exists and tally and tally.connected %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="flex flex-wrap items-start justify-between gap-4">
@@ -556,7 +449,7 @@
{% endif %}
<div class="rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">
Normal workflow: map the company once, mirror Tally to SQLite, then run analysis from the client .act database. Approved write-back tools remain controlled separately.
Normal workflow: select Client → Financial Year → Tally Data. ERP validates identity, creates/refreshes the Client/FY mirror, and updates the Mirror Registry automatically. Approved write-back tools remain controlled separately.
</div>
</div>
@@ -607,10 +500,19 @@
<script>
document.addEventListener("DOMContentLoaded", function () {
const clientId = {{ selected_client.id if selected_client else 'null' }};
const fy=document.getElementById("mirrorFinancialYear");
const tallyGuid=document.getElementById("mirrorTallyGuid");
const from=document.getElementById("mirrorDateFrom");
const to=document.getElementById("mirrorDateTo");
const previous=document.getElementById("mirrorPreviousFY");
const mirrorForm=document.getElementById("mirrorExportForm");
const replaceField=document.getElementById("mirrorReplaceExisting");
const mismatchField=document.getElementById("mirrorAllowIdentityMismatch");
const registryStatus=document.getElementById("mirrorRegistryStatus");
const identityStatus=document.getElementById("mirrorIdentityStatus");
const exportButton=document.getElementById("fullExportButton");
let confirmingMirror=false;
function applyFY(value){
const m=/^(\d{4})-(\d{2})$/.exec(value||"");
if(!m||!from||!to)return;
@@ -618,18 +520,7 @@ document.addEventListener("DOMContentLoaded", function () {
from.value=String(y).padStart(4,"0")+"-04-01";
to.value=String(y+1).padStart(4,"0")+"-03-31";
}
if(fy)fy.addEventListener("change",function(){applyFY(fy.value);});
if(previous&&fy)previous.addEventListener("click",function(){
if(fy.selectedIndex+1<fy.options.length){
fy.selectedIndex+=1;
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{
@@ -638,35 +529,70 @@ document.addEventListener("DOMContentLoaded", function () {
if(!r.ok||!d.ok)return;
if(registryStatus){
registryStatus.innerHTML=d.exists
? '<strong>Mirror available:</strong> FY '+fy.value+' · Version '+(d.version_no||1)+(d.company_name?' · '+d.company_name:'')+(d.last_synced_at?' · last synced '+new Date(d.last_synced_at).toLocaleString():'')+'. Creating it again will create a new version and retain the current version.'
: '<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.';
const exportButton=document.getElementById('fullExportButton');
if(exportButton) exportButton.textContent=d.exists?'Create New Version':'Mirror Tally to SQLite';
? '<strong>Registered mirror:</strong> FY '+fy.value+' · Version '+(d.version_no||1)+(d.company_name?' · '+d.company_name:'')+(d.last_synced_at?' · last synced '+new Date(d.last_synced_at).toLocaleString():'')+'.'
: '<strong>No registered mirror for FY '+fy.value+'.</strong> The mirror will be created under the Client/FY directory rules and registered automatically.';
}
if(exportButton) exportButton.textContent=d.exists?'Refresh Mirror':'Create Mirror';
}catch(e){}
}
if(fy)fy.addEventListener("change",refreshMirrorRegistrationStatus);
async function runIdentityPreflight(){
if(!fy||!tallyGuid||!tallyGuid.value||!clientId)return null;
const r=await fetch('/tools/tally/mirror-preflight?client_id='+encodeURIComponent(clientId)+'&financial_year='+encodeURIComponent(fy.value)+'&tally_guid='+encodeURIComponent(tallyGuid.value),{cache:'no-store'});
const d=await r.json();
if(!r.ok||!d.ok) throw new Error(d.error||'Unable to verify the selected Tally company.');
if(identityStatus){
const i=d.identity||{};
const erpG=(i.erp_gstins||[]).join(', ')||'Not available';
const tallyG=(i.tally_gstins||[]).join(', ')||'Not available';
identityStatus.classList.remove('hidden','border-amber-200','bg-amber-50','text-amber-900','border-emerald-200','bg-emerald-50','text-emerald-800');
if(d.requires_confirmation){
identityStatus.classList.add('border-amber-200','bg-amber-50','text-amber-900');
identityStatus.innerHTML='<strong>Verification required:</strong> '+(i.reason||'Client/Tally identity differs')+' · ERP GSTIN: '+erpG+' · Tally GSTIN: '+tallyG;
}else{
identityStatus.classList.add('border-emerald-200','bg-emerald-50','text-emerald-800');
identityStatus.innerHTML='<strong>Verified:</strong> '+(i.reason||'Client identity matched')+(tallyG!=='Not available'?' · GSTIN '+tallyG:'');
}
}
return d;
}
if(fy)fy.addEventListener("change",function(){
applyFY(fy.value);
if(replaceField)replaceField.value='';
if(mismatchField)mismatchField.value='';
refreshMirrorRegistrationStatus();
if(tallyGuid&&tallyGuid.value)runIdentityPreflight().catch(()=>{});
});
if(tallyGuid)tallyGuid.addEventListener("change",function(){
if(mismatchField)mismatchField.value='';
runIdentityPreflight().catch(()=>{});
});
refreshMirrorRegistrationStatus();
if(mirrorForm){
mirrorForm.addEventListener("submit",async function(event){
if(confirmingMirror || (replaceField&&replaceField.value==='1'))return;
if(confirmingMirror)return;
event.preventDefault();
try{
const preflight=await runIdentityPreflight();
if(!preflight)return;
if(preflight.requires_confirmation && !(mismatchField&&mismatchField.value==='1')){
const ok=window.confirm((preflight.warning||'The selected Tally company does not match the ERP client.')+'\n\nContinue and create/refresh the mirror?');
if(!ok)return;
if(mismatchField)mismatchField.value='1';
}
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\nCreate a new mirror version?\n\nThe current version will be preserved and remains active if the new export fails.');
if(r.ok&&d.ok&&d.exists){
const ok=window.confirm('A registered mirror already exists for '+(d.client_name||'this client')+' - FY '+fy.value+'.\n\nRefresh it as a new version? The current version will remain available if the refresh fails.');
if(!ok)return;
if(replaceField)replaceField.value='1';
}
confirmingMirror=true;
mirrorForm.submit();
}catch(e){
confirmingMirror=true;
mirrorForm.submit();
window.alert(e.message||'Unable to verify the selected Tally company.');
}
});
}
+219 -1
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import date, datetime, timezone
from io import BytesIO
from difflib import SequenceMatcher
import json
import textwrap
from pathlib import Path
@@ -254,6 +255,108 @@ def _normalise_gstin(value: str) -> str:
return re.sub(r"\s+", "", str(value or "").strip().upper())
def _normalise_company_name(value: str) -> str:
text = str(value or "").strip().upper()
replacements = {
"PRIVATE LIMITED": "PVT LTD",
"PVT. LTD.": "PVT LTD",
"PVT. LTD": "PVT LTD",
"LIMITED": "LTD",
}
for source, target in replacements.items():
text = text.replace(source, target)
text = re.sub(r"\b(AUDIT|AUDITED|BOOKS|TALLY)\b", " ", text)
return re.sub(r"[^A-Z0-9]+", "", text)
def _tally_company_identity_check(client, registrations, company: dict | None) -> dict:
company = company or {}
tally_name = str(company.get("name") or "").strip()
tally_gstins: list[str] = []
for value in list(company.get("gstins") or []) + [company.get("gstin")]:
gstin = _normalise_gstin(value)
if gstin and gstin not in tally_gstins:
tally_gstins.append(gstin)
erp_gstins = _erp_client_gstins(registrations)
erp_names = [str(getattr(client, "client_name", "") or "").strip()]
for registration, registration_type in registrations or []:
if str(getattr(registration_type, "code", "") or "").strip().upper() != "GSTIN":
continue
for value in (getattr(registration, "legal_name", ""), getattr(registration, "trade_name", "")):
name = str(value or "").strip()
if name and name not in erp_names:
erp_names.append(name)
tally_normal = _normalise_company_name(tally_name)
name_scores = []
for name in erp_names:
normal = _normalise_company_name(name)
if not normal or not tally_normal:
continue
score = 1.0 if normal == tally_normal else SequenceMatcher(None, normal, tally_normal).ratio()
name_scores.append((score, name))
best_score, best_name = max(name_scores, default=(0.0, erp_names[0] if erp_names else ""))
name_match = bool(best_score >= 0.82)
gst_match = bool(erp_gstins and tally_gstins and set(erp_gstins).intersection(tally_gstins))
gst_mismatch = bool(erp_gstins and tally_gstins and not gst_match)
if gst_match:
requires_confirmation = False
reason = "GSTIN matched"
elif gst_mismatch:
requires_confirmation = True
reason = "GSTIN differs"
elif name_match:
requires_confirmation = False
reason = "Company name matched"
else:
requires_confirmation = True
reason = "Company identity could not be matched automatically"
matched_registration_id = None
if gst_match:
matches = set(erp_gstins).intersection(tally_gstins)
for registration, registration_type in registrations or []:
if str(getattr(registration_type, "code", "") or "").strip().upper() == "GSTIN" and _normalise_gstin(getattr(registration, "registration_number", "")) in matches:
matched_registration_id = int(registration.id)
break
elif len([1 for _r, _t in registrations or [] if str(getattr(_t, "code", "") or "").strip().upper() == "GSTIN"]) == 1:
for registration, registration_type in registrations or []:
if str(getattr(registration_type, "code", "") or "").strip().upper() == "GSTIN":
matched_registration_id = int(registration.id)
break
return {
"requires_confirmation": requires_confirmation,
"reason": reason,
"erp_client_name": str(getattr(client, "client_name", "") or "").strip(),
"erp_best_name": best_name,
"erp_gstins": erp_gstins,
"tally_company_name": tally_name,
"tally_gstins": tally_gstins,
"name_match": name_match,
"name_score": round(float(best_score), 4),
"gst_match": gst_match,
"gst_mismatch": gst_mismatch,
"matched_registration_id": matched_registration_id,
}
def _identity_warning_message(identity: dict) -> str:
erp_gstin = ", ".join(identity.get("erp_gstins") or []) or "Not available"
tally_gstin = ", ".join(identity.get("tally_gstins") or []) or "Not available"
return (
f"The selected Tally company does not clearly match the ERP client.\n\n"
f"ERP Client: {identity.get('erp_client_name') or '-'}\n"
f"ERP GSTIN: {erp_gstin}\n\n"
f"Tally Company: {identity.get('tally_company_name') or '-'}\n"
f"Tally GSTIN: {tally_gstin}\n\n"
f"Reason: {identity.get('reason') or 'Identity mismatch'}\n\n"
"Continue only if you have verified that this is the correct Tally data for this client."
)
def _erp_client_gstins(registrations) -> list[str]:
values: list[str] = []
for registration, registration_type in registrations or []:
@@ -917,6 +1020,45 @@ def sync_tally_transactions(
@router.get("/mirror-preflight")
def mirror_preflight(request: Request, client_id: int, financial_year: str, tally_guid: 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)
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
if not node or not _node_online(node):
return JSONResponse({"ok": False, "error": "ERP Local Agent is offline."}, status_code=503)
payload = _accounting_storage_payload(client, financial_year, db=db, tenant_id=scope.tenant_id)
status = request_agent_command(node.node_code, "phase4_status", payload, timeout_seconds=20)
if not status.get("ok"):
return JSONResponse({"ok": False, "error": str(status.get("error") or "Unable to read Tally companies.")}, status_code=500)
tally = (status.get("result") or {}).get("tally") or {}
company = next((row for row in (tally.get("companies") or []) if str(row.get("guid") or "").strip() == str(tally_guid or "").strip()), None)
if not company:
return JSONResponse({"ok": False, "error": "The selected Tally company is no longer loaded. Refresh and try again."}, status_code=400)
registrations = _client_registrations(db, client, scope.tenant_id)
identity = _tally_company_identity_check(client, registrations, company)
return JSONResponse({
"ok": True,
"financial_year": financial_year,
"company": company,
"identity": identity,
"requires_confirmation": bool(identity.get("requires_confirmation")),
"warning": _identity_warning_message(identity) if identity.get("requires_confirmation") else "",
})
finally:
db.close()
@router.get("/mirror-registration/status")
def mirror_registration_status(request: Request, client_id: int, financial_year: str):
db = CommonSessionLocal()
@@ -975,6 +1117,7 @@ def start_full_accounting_export(
date_to: str = Form(...),
financial_year: str = Form(""),
replace_existing: str = Form(""),
allow_identity_mismatch: str = Form(""),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
@@ -1004,7 +1147,82 @@ def start_full_accounting_export(
if not node or not _node_online(node):
return RedirectResponse(url=f"/tools/tally?client_id={client.id}&error={quote('ERP Local Agent is offline for the active branch.')}", status_code=303)
storage_payload = _accounting_storage_payload(client, target_fy)
# Normal workflow is Client -> FY -> Tally Data. Storage initialization and
# the legacy GUID mapping are maintained automatically as internal compatibility
# details; users no longer have to perform either step before mirroring.
storage_payload = _accounting_storage_payload(
client, target_fy, db=db, tenant_id=scope.tenant_id
)
status_response = request_agent_command(
node.node_code, "phase4_status", storage_payload, timeout_seconds=20
)
if not status_response.get("ok"):
raise RuntimeError(str(status_response.get("error") or "Unable to read Tally companies."))
status_result = status_response.get("result") or {}
tally_status = status_result.get("tally") or {}
company = next(
(row for row in (tally_status.get("companies") or [])
if str(row.get("guid") or "").strip() == str(tally_guid or "").strip()),
None,
)
if not company:
raise RuntimeError("The selected Tally company is no longer loaded. Refresh Tally companies and try again.")
registrations = _client_registrations(db, client, scope.tenant_id)
identity = _tally_company_identity_check(client, registrations, company)
mismatch_confirmed = str(allow_identity_mismatch or "").strip().lower() in {"1", "true", "yes", "on"}
if identity.get("requires_confirmation") and not mismatch_confirmed:
return RedirectResponse(
url=(
f"/tools/tally?client_id={client.id}&financial_year={quote(target_fy)}"
f"&error={quote(_identity_warning_message(identity))}"
),
status_code=303,
)
# Ensure the selected Client/FY directory and .act support database exist.
# The path comes exclusively from the ERP directory rules for this FY.
accounting_snapshot = status_result.get("accounting") or {}
if not bool(accounting_snapshot.get("exists")):
initialized = request_agent_command(
node.node_code,
"accounting_initialize",
{
**storage_payload,
"tenant_id": int(scope.tenant_id),
"requested_by_user_id": int(user.id),
},
timeout_seconds=20,
)
if not initialized.get("ok"):
raise RuntimeError(str(initialized.get("error") or "Accounting storage initialization failed."))
# Keep the old mapping table synchronized internally because existing sync and
# write-back features still use it. This is no longer a prerequisite exposed
# to the user. Prefer the GST registration that actually matched Tally.
registration_payload = None
matched_registration_id = identity.get("matched_registration_id")
if matched_registration_id:
for registration, registration_type in registrations:
if int(registration.id) == int(matched_registration_id):
registration_payload = _registration_payload(registration, registration_type)
break
mapped_result = request_agent_command(
node.node_code,
"accounting_map_company",
{
**storage_payload,
"tenant_id": int(scope.tenant_id),
"registration": registration_payload,
"tally_guid": str(tally_guid or "").strip(),
"allow_gstin_mismatch": bool(mismatch_confirmed),
"mapped_by_user_id": int(user.id),
},
timeout_seconds=20,
)
if not mapped_result.get("ok"):
raise RuntimeError(str(mapped_result.get("error") or "Unable to prepare the selected Tally company for mirroring."))
existing_registration = get_registered_mirror(db, scope.tenant_id, int(client.id), target_fy)
existing_file = False
try: