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
+1
View File
@@ -5,3 +5,4 @@ data/*.db
data/*.sqlite3 data/*.sqlite3
.pytest_cache/ .pytest_cache/
venv/ venv/
app/modules/accounting/gst_operator_agent_runtime/.gst_exe_build_venv/
@@ -127,214 +127,67 @@
</section> </section>
{% if selected_client %} {% 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 class="flex flex-wrap items-start justify-between gap-4">
<div> <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-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> </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 %} {% 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. <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>
<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>
{% else %} {% 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 %} {% endif %}
</div> </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="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="" /> <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"> <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 %}
<option value="{{ fy }}" {% if fy == selected_financial_year %}selected{% endif %}>FY {{ fy }}</option> <option value="{{ fy }}" {% if fy == selected_financial_year %}selected{% endif %}>FY {{ fy }}</option>
{% endfor %} {% endfor %}
</select> </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>
<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"> <label class="text-sm font-medium text-slate-700">Tally Data
<option value="">Select mapped company</option> <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">
{% for mapping in accounting.mappings or [] %} <option value="">Select Tally company / data</option>
{% if mapping.tally_guid and mapping.is_active %} {% for company in tally.companies or [] %}
<option value="{{ mapping.tally_guid }}">{{ mapping.company_name }}{% if mapping.gstin %} · {{ mapping.gstin }}{% endif %}</option> {% if company.guid %}
<option value="{{ company.guid }}">{{ company.name }}{% if company.gstin %} · GSTIN {{ company.gstin }}{% endif %}</option>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</select> </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>
<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 %}Refresh Mirror{% else %}Create Mirror{% 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 %}Create New Version{% else %}Mirror Tally to SQLite{% endif %}</button>
</form> </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> <div id="mirrorIdentityStatus" class="mt-3 hidden rounded-xl border px-4 py-3 text-sm"></div>
<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="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>
<div id="fullExportProgress" class="mt-5 hidden rounded-xl border border-slate-200 bg-slate-50 p-4"> <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="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> <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,6 +195,13 @@
<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="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 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> </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> </section>
{% endif %} {% endif %}
@@ -356,6 +216,39 @@
</div> </div>
</summary> </summary>
<div class="space-y-6 border-t border-slate-100 p-5"> <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 %} {% if accounting and accounting.exists and tally and tally.connected %}
<section class="rounded-2xl bg-white p-5 shadow-soft"> <section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex flex-wrap items-start justify-between gap-4">
@@ -556,7 +449,7 @@
{% endif %} {% endif %}
<div class="rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800"> <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>
</div> </div>
@@ -607,10 +500,19 @@
<script> <script>
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
const clientId = {{ selected_client.id if selected_client else 'null' }};
const fy=document.getElementById("mirrorFinancialYear"); const fy=document.getElementById("mirrorFinancialYear");
const tallyGuid=document.getElementById("mirrorTallyGuid");
const from=document.getElementById("mirrorDateFrom"); const from=document.getElementById("mirrorDateFrom");
const to=document.getElementById("mirrorDateTo"); 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){ function applyFY(value){
const m=/^(\d{4})-(\d{2})$/.exec(value||""); const m=/^(\d{4})-(\d{2})$/.exec(value||"");
if(!m||!from||!to)return; if(!m||!from||!to)return;
@@ -618,18 +520,7 @@ document.addEventListener("DOMContentLoaded", function () {
from.value=String(y).padStart(4,"0")+"-04-01"; from.value=String(y).padStart(4,"0")+"-04-01";
to.value=String(y+1).padStart(4,"0")+"-03-31"; 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(){ async function refreshMirrorRegistrationStatus(){
if(!fy||!clientId)return; if(!fy||!clientId)return;
try{ try{
@@ -638,35 +529,70 @@ document.addEventListener("DOMContentLoaded", function () {
if(!r.ok||!d.ok)return; if(!r.ok||!d.ok)return;
if(registryStatus){ if(registryStatus){
registryStatus.innerHTML=d.exists 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>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> Creating the mirror will register it to this client and financial year for Accounting/Audit tools.'; : '<strong>No registered mirror for FY '+fy.value+'.</strong> The mirror will be created under the Client/FY directory rules and registered automatically.';
const exportButton=document.getElementById('fullExportButton');
if(exportButton) exportButton.textContent=d.exists?'Create New Version':'Mirror Tally to SQLite';
} }
if(exportButton) exportButton.textContent=d.exists?'Refresh Mirror':'Create Mirror';
}catch(e){} }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(); refreshMirrorRegistrationStatus();
if(mirrorForm){ if(mirrorForm){
mirrorForm.addEventListener("submit",async function(event){ mirrorForm.addEventListener("submit",async function(event){
if(confirmingMirror || (replaceField&&replaceField.value==='1'))return; if(confirmingMirror)return;
event.preventDefault(); event.preventDefault();
try{ 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 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(); const d=await r.json();
if(!r.ok||!d.ok){ mirrorForm.submit(); return; } if(r.ok&&d.ok&&d.exists){
if(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.');
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(!ok)return; if(!ok)return;
if(replaceField)replaceField.value='1'; if(replaceField)replaceField.value='1';
} }
confirmingMirror=true; confirmingMirror=true;
mirrorForm.submit(); mirrorForm.submit();
}catch(e){ }catch(e){
confirmingMirror=true; window.alert(e.message||'Unable to verify the selected Tally company.');
mirrorForm.submit();
} }
}); });
} }
+219 -1
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from io import BytesIO from io import BytesIO
from difflib import SequenceMatcher
import json import json
import textwrap import textwrap
from pathlib import Path from pathlib import Path
@@ -254,6 +255,108 @@ def _normalise_gstin(value: str) -> str:
return re.sub(r"\s+", "", str(value or "").strip().upper()) 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]: def _erp_client_gstins(registrations) -> list[str]:
values: list[str] = [] values: list[str] = []
for registration, registration_type in registrations or []: 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") @router.get("/mirror-registration/status")
def mirror_registration_status(request: Request, client_id: int, financial_year: str): def mirror_registration_status(request: Request, client_id: int, financial_year: str):
db = CommonSessionLocal() db = CommonSessionLocal()
@@ -975,6 +1117,7 @@ def start_full_accounting_export(
date_to: str = Form(...), date_to: str = Form(...),
financial_year: str = Form(""), financial_year: str = Form(""),
replace_existing: str = Form(""), replace_existing: str = Form(""),
allow_identity_mismatch: str = Form(""),
csrf_token: str = Form(...), csrf_token: str = Form(...),
): ):
validate_csrf(request, csrf_token) validate_csrf(request, csrf_token)
@@ -1004,7 +1147,82 @@ def start_full_accounting_export(
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) # 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_registration = get_registered_mirror(db, scope.tenant_id, int(client.id), target_fy)
existing_file = False existing_file = False
try: try: