Simplify Client FY Tally mirror workflow
This commit is contained in:
@@ -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();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user