Simplify Tally mirror workflow and use GUID company identity
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Accounting</p>
|
||||
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Tally Accounting</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Company mapping, master sync and transaction sync remain available. Accounting tools are now grouped into Purchases & Expenses and Sales & Income without changing existing routes or controls.</p>
|
||||
<p class="mt-1 text-sm text-slate-500">Mirror Tally once into the client SQLite Accounting Mirror, then run accounting and audit analysis from the local .act database. Existing mapping and legacy sync controls remain available under Advanced controls.</p>
|
||||
</div>
|
||||
<div class="w-full space-y-3 xl:max-w-5xl">
|
||||
<div class="rounded-2xl border border-blue-200 bg-blue-50/40 p-4">
|
||||
@@ -222,8 +222,8 @@
|
||||
<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">Full Accounting Export · ODBC → SQLite</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Export the selected mapped Tally company into the client Accounting Mirror .act database in local storage. This refreshes ledgers, vouchers, voucher ledger entries, stock items, stock movements, HSN and GST rates, then refreshes the existing client .act snapshots used by older accounting tools.</p>
|
||||
<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>
|
||||
@@ -243,7 +243,7 @@
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">From Date<input 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 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">Export All to SQLite</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">Mirror Tally to SQLite</button>
|
||||
</form>
|
||||
<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>
|
||||
@@ -254,8 +254,19 @@
|
||||
</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">Advanced / Legacy Tally Controls</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Manual master sync, transaction sync, active mappings and loaded-company diagnostics are retained for compatibility and troubleshooting. Normal work only requires Mirror Tally to SQLite above.</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">Optional</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="space-y-6 border-t border-slate-100 p-5">
|
||||
{% 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>
|
||||
<h2 class="font-semibold text-slate-900">Phase 3 · Accounting Master Sync</h2>
|
||||
@@ -293,11 +304,11 @@
|
||||
<div class="mt-4 rounded-xl bg-slate-50 px-4 py-3 text-xs text-slate-600">
|
||||
Synced into the selected client's .act database: Groups, Ledgers, Voucher Types, Stock Groups, Stock Categories, Stock Items, Units, Cost Categories and Cost Centres. No voucher or transaction data is imported in Phase 3.
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% 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>
|
||||
<h2 class="font-semibold text-slate-900">Phase 4 · Accounting Transaction Sync</h2>
|
||||
@@ -341,11 +352,11 @@
|
||||
<div class="mt-4 rounded-xl bg-slate-50 px-4 py-3 text-xs text-slate-600">
|
||||
Stored locally in the client's .act database: vouchers, ledger entries, inventory entries, bill allocations, cost-centre allocations and bank allocations. Raw voucher XML is preserved. Re-syncing a date range replaces only that company's vouchers in the selected range. No Tally data is modified.
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if accounting and accounting.exists %}
|
||||
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||
<div class="border-b border-slate-100 px-5 py-4">
|
||||
<h2 class="font-semibold text-slate-900">Active Tally Mappings</h2>
|
||||
<p class="text-sm text-slate-500">Mappings remain valid across Tally company renames because the GUID is the persistent key.</p>
|
||||
@@ -419,11 +430,11 @@
|
||||
{% else %}
|
||||
<div class="p-6 text-sm text-slate-500">No Tally company has been mapped for this client yet.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if tally %}
|
||||
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||
<div class="border-b border-slate-100 px-5 py-4">
|
||||
<h2 class="font-semibold text-slate-900">Loaded Tally Companies</h2>
|
||||
<p class="text-sm text-slate-500">Read-only company discovery from the local TallyPrime instance.</p>
|
||||
@@ -446,12 +457,15 @@
|
||||
{% else %}
|
||||
<div class="p-6 text-sm text-slate-500">TallyPrime responded, but no loaded company was returned.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<div class="rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">
|
||||
Phase 4 synchronizes accounting transactions read-only. Tally write-back remains disabled and is reserved for Phase 6.
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import io
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
ERP_LOCAL_AGENT_VERSION = "1.24.3"
|
||||
ERP_LOCAL_AGENT_VERSION = "1.24.4"
|
||||
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
||||
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
||||
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
__version__ = "1.24.3"
|
||||
__version__ = "1.24.4"
|
||||
AGENT_NAME = "ERP Local Agent"
|
||||
|
||||
@@ -354,10 +354,17 @@ class AccountingMirrorEngine:
|
||||
actual_guid = _text(company.get("company_guid"))
|
||||
exp_name = _text(expected_company_name)
|
||||
exp_guid = _text(expected_company_guid)
|
||||
if exp_name and actual_name.casefold() != exp_name.casefold():
|
||||
raise RuntimeError(f"Active Tally company mismatch. ERP expected '{exp_name}', ODBC returned '{actual_name}'.")
|
||||
validation_warnings: list[str] = []
|
||||
# The Tally GUID is the permanent company identity. Company names are descriptive
|
||||
# snapshots only and commonly contain FY/year suffixes (for example "-25-26").
|
||||
# Never reject a valid mirror merely because the descriptive name changed.
|
||||
if exp_guid and actual_guid and exp_guid != actual_guid:
|
||||
raise RuntimeError(f"Tally company GUID mismatch. ERP expected {exp_guid}, ODBC returned {actual_guid}.")
|
||||
if exp_name and actual_name and actual_name.casefold() != exp_name.casefold():
|
||||
validation_warnings.append(
|
||||
f"Tally company name differs from the ERP snapshot: ERP '{exp_name}', Tally '{actual_name}'. "
|
||||
"The export was accepted because company identity is based on the Tally GUID."
|
||||
)
|
||||
|
||||
report(96, "Publishing Accounting Mirror", "Publishing the verified SQLite mirror inside the client's Accounting folder.", rows=dict(rows))
|
||||
paths.current_mirror.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -377,6 +384,7 @@ class AccountingMirrorEngine:
|
||||
result = {
|
||||
"ok": True, "synced_at_utc": _utc_now(), "company_name": actual_name,
|
||||
"company_guid": actual_guid, "company_cache_path": str(cache),
|
||||
"validation_warnings": validation_warnings,
|
||||
"extractor_log_tail": log[-1500:], "loader_log_tail": llog[-1500:],
|
||||
**self.status(client_id),
|
||||
}
|
||||
@@ -385,22 +393,37 @@ class AccountingMirrorEngine:
|
||||
|
||||
def company_db(self, client_id: int, company_name: str, company_guid: str = "") -> Path:
|
||||
paths = self.paths(client_id)
|
||||
requested_name = _text(company_name)
|
||||
requested_guid = _text(company_guid)
|
||||
current = self._summary(paths.current_mirror)
|
||||
c = current.get("company") or {}
|
||||
if current.get("ready") and _text(c.get("company_name")).casefold() == _text(company_name).casefold():
|
||||
if not company_guid or not _text(c.get("company_guid")) or _text(c.get("company_guid")) == _text(company_guid):
|
||||
current_name = _text(c.get("company_name"))
|
||||
current_guid = _text(c.get("company_guid"))
|
||||
|
||||
# GUID is authoritative and survives Tally company renames / FY suffix changes.
|
||||
if current.get("ready"):
|
||||
if requested_guid and current_guid and requested_guid == current_guid:
|
||||
return paths.current_mirror
|
||||
exact = self._cache_file(paths, company_name, company_guid)
|
||||
if not requested_guid and current_name.casefold() == requested_name.casefold():
|
||||
return paths.current_mirror
|
||||
|
||||
exact = self._cache_file(paths, requested_name, requested_guid)
|
||||
if exact.is_file():
|
||||
return exact
|
||||
# GUID may be omitted by callers. Search only for an exact company-name prefix.
|
||||
prefix = _safe_key(company_name) + "_"
|
||||
candidates = sorted(paths.cache_root.glob(prefix + "*.act"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
for candidate in candidates:
|
||||
s = self._summary(candidate)
|
||||
c = s.get("company") or {}
|
||||
if _text(c.get("company_name")).casefold() == _text(company_name).casefold():
|
||||
return candidate
|
||||
|
||||
candidates = sorted(paths.cache_root.glob("*.act"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
if requested_guid:
|
||||
for candidate in candidates:
|
||||
snapshot = self._summary(candidate)
|
||||
company = snapshot.get("company") or {}
|
||||
if _text(company.get("company_guid")) == requested_guid:
|
||||
return candidate
|
||||
else:
|
||||
for candidate in candidates:
|
||||
snapshot = self._summary(candidate)
|
||||
company = snapshot.get("company") or {}
|
||||
if _text(company.get("company_name")).casefold() == requested_name.casefold():
|
||||
return candidate
|
||||
raise RuntimeError(
|
||||
f"No .NET/.act mirror snapshot is cached for Tally company '{company_name}'. "
|
||||
"Make that company active in TallyPrime and run Master/Transaction Sync once."
|
||||
|
||||
@@ -2950,18 +2950,21 @@ class AgentCommandProcessor:
|
||||
timeout_seconds=max(300, int(payload.get("timeout_seconds") or 1800)),
|
||||
progress_callback=mirror_progress,
|
||||
)
|
||||
publish(status="running", percent=98, stage="Updating client accounting database", message="Refreshing the existing client .act transaction snapshot from the completed ODBC mirror. Master analysis remains available directly from the mirror without clearing any existing auxiliary masters.", mirror_db_path=str(mirror_result.get("mirror_db_path") or ""))
|
||||
transactions = self.tally.mirror.transactions(client_id, company_name, date_from, date_to, tally_guid)
|
||||
mirror_company_name = str(mirror_result.get("company_name") or company_name).strip() or company_name
|
||||
validation_warnings = list(mirror_result.get("validation_warnings") or [])
|
||||
publish(status="running", percent=98, stage="Updating client accounting database", message="Refreshing the existing client .act transaction snapshot from the completed ODBC mirror. Master analysis remains available directly from the mirror without clearing any existing auxiliary masters.", mirror_db_path=str(mirror_result.get("mirror_db_path") or ""), warnings=validation_warnings)
|
||||
transactions = self.tally.mirror.transactions(client_id, mirror_company_name, date_from, date_to, tally_guid)
|
||||
transaction_sync = self.store.replace_transaction_snapshot(
|
||||
client_id, mapping={**mapping, "company_name": company_name}, transactions=transactions,
|
||||
client_id, mapping={**mapping, "company_name": mirror_company_name}, transactions=transactions,
|
||||
requested_by_user_id=int(requested_by) if requested_by not in (None, "") else None,
|
||||
)
|
||||
mirror_summary = (mirror_result.get("mirror") or {})
|
||||
counts = mirror_summary.get("counts") or {}
|
||||
publish(
|
||||
status="completed", percent=100, stage="Complete",
|
||||
message="Full Accounting Export completed. ERP analysis tools can now read the refreshed SQLite data without re-extracting from Tally.",
|
||||
finished_at_utc=datetime.now(timezone.utc).isoformat(), error="",
|
||||
message=("Full Accounting Export completed. ERP analysis tools can now read the refreshed SQLite data without re-extracting from Tally."
|
||||
+ ((" " + validation_warnings[0]) if validation_warnings else "")),
|
||||
finished_at_utc=datetime.now(timezone.utc).isoformat(), error="", warnings=validation_warnings,
|
||||
rows=counts, mirror=mirror_summary, mirror_db_path=str(mirror_result.get("mirror_db_path") or ""),
|
||||
accounting_db_path=str(self.store.db_path(client_id)),
|
||||
transaction_sync=transaction_sync,
|
||||
|
||||
Reference in New Issue
Block a user