Fix Tally ODBC selector GUID and workspace FY mirror defaults

This commit is contained in:
A R R R Associates
2026-09-07 12:38:50 +05:30
parent a4a749f014
commit 532ddf736b
5 changed files with 82 additions and 14 deletions
@@ -316,6 +316,7 @@
<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>
</label> </label>
<label class="text-sm font-medium text-slate-700">Mapped Tally Company <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"> <select name="tally_guid" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
+18 -3
View File
@@ -67,6 +67,19 @@ def _financial_year_for_date(value: date | None = None) -> str:
return f"{start_year}-{str(start_year + 1)[-2:]}" return f"{start_year}-{str(start_year + 1)[-2:]}"
def _active_financial_year(request: Request) -> str:
"""Return the FY selected in the ERP workspace header."""
value = (
request.session.get("active_financial_year")
or getattr(request.state, "year_code", None)
or ""
)
value = str(value or "").strip()
if re.fullmatch(r"\d{4}-\d{2}", value):
return value
return _financial_year_for_date()
def _financial_year_bounds(financial_year: str) -> tuple[date, date]: def _financial_year_bounds(financial_year: str) -> tuple[date, date]:
match = re.fullmatch(r"(\d{4})-(\d{2})", str(financial_year or "").strip()) match = re.fullmatch(r"(\d{4})-(\d{2})", str(financial_year or "").strip())
if not match: if not match:
@@ -237,13 +250,14 @@ def tally_tool(
live_result = None live_result = None
analysis_history = [] analysis_history = []
command_error = error or "" command_error = error or ""
selected_financial_year = str(financial_year or _financial_year_for_date()).strip() workspace_financial_year = _active_financial_year(request)
selected_financial_year = str(financial_year or workspace_financial_year).strip()
try: try:
selected_fy_start, selected_fy_end = _financial_year_bounds(selected_financial_year) selected_fy_start, selected_fy_end = _financial_year_bounds(selected_financial_year)
except Exception: except Exception:
selected_financial_year = _financial_year_for_date() selected_financial_year = workspace_financial_year
selected_fy_start, selected_fy_end = _financial_year_bounds(selected_financial_year) selected_fy_start, selected_fy_end = _financial_year_bounds(selected_financial_year)
mirror_fy_options = _financial_year_options(_financial_year_for_date(), 7) mirror_fy_options = _financial_year_options(workspace_financial_year, 7)
should_query_agent = bool(refresh or selected_client) should_query_agent = bool(refresh or selected_client)
if refresh: if refresh:
@@ -309,6 +323,7 @@ def tally_tool(
transaction_date_from=transaction_date_from, transaction_date_from=transaction_date_from,
transaction_date_to=transaction_date_to, transaction_date_to=transaction_date_to,
selected_financial_year=selected_financial_year, selected_financial_year=selected_financial_year,
workspace_financial_year=workspace_financial_year,
mirror_fy_options=mirror_fy_options, mirror_fy_options=mirror_fy_options,
command_error=command_error, command_error=command_error,
analysis_history=analysis_history, analysis_history=analysis_history,
+1 -1
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path from pathlib import Path
import zipfile import zipfile
ERP_LOCAL_AGENT_VERSION = "1.26.0" ERP_LOCAL_AGENT_VERSION = "1.26.1"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent" ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
@@ -1,2 +1,2 @@
__version__ = "1.26.0" __version__ = "1.26.1"
AGENT_NAME = "ERP Local Agent" AGENT_NAME = "ERP Local Agent"
@@ -31,6 +31,36 @@ def _number(value: Any) -> float:
m = re.search(r"[-+]?\d+(?:\.\d+)?", s) m = re.search(r"[-+]?\d+(?:\.\d+)?", s)
return float(m.group(0)) if m else 0.0 return float(m.group(0)) if m else 0.0
def _is_placeholder_company_guid(value: Any) -> bool:
"""True when ODBC returned a company selector token rather than a GUID."""
text = _text(value).strip()
if not text:
return True
normal = re.sub(r"[^A-Z0-9]+", "_", text.upper()).strip("_")
return normal in {
"ACTIVE_COMPANY",
"CURRENT_COMPANY",
"SELECTED_COMPANY",
"TALLY_ACTIVE_COMPANY",
"COMPANY",
"ACTIVE",
"CURRENT",
"N_A",
"NA",
"NONE",
"NULL",
}
def _company_identity_name(value: Any) -> str:
"""Normalize descriptive company name for GUID-unavailable fallback."""
text = _text(value).strip().casefold()
# Ignore only a trailing FY/year suffix such as -25-26 or 2025-26.
text = re.sub(r"[\s_-]+(?:fy\s*)?\d{2,4}\s*[-/]\s*\d{2,4}\s*$", "", text).strip()
text = re.sub(r"[^a-z0-9]+", " ", text)
return re.sub(r"\s+", " ", text).strip()
def _safe_key(value: str) -> str: def _safe_key(value: str) -> str:
text = re.sub(r"[^A-Za-z0-9_.-]+", "_", _text(value)).strip("_") text = re.sub(r"[^A-Za-z0-9_.-]+", "_", _text(value)).strip("_")
return text[:80] or "company" return text[:80] or "company"
@@ -361,15 +391,34 @@ class AccountingMirrorEngine:
exp_name = _text(expected_company_name) exp_name = _text(expected_company_name)
exp_guid = _text(expected_company_guid) exp_guid = _text(expected_company_guid)
validation_warnings: list[str] = [] validation_warnings: list[str] = []
# The Tally GUID is the permanent company identity. Company names are descriptive # A genuine Tally GUID remains authoritative. Some ODBC builds return a
# snapshots only and commonly contain FY/year suffixes (for example "-25-26"). # selector token such as ACTIVE_COMPANY instead of the real GUID.
# Never reject a valid mirror merely because the descriptive name changed. actual_guid_is_placeholder = _is_placeholder_company_guid(actual_guid)
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_guid and actual_guid and not actual_guid_is_placeholder and exp_guid != actual_guid:
if exp_name and actual_name and actual_name.casefold() != exp_name.casefold(): raise RuntimeError(
f"Tally company GUID mismatch. ERP expected {exp_guid}, ODBC returned {actual_guid}."
)
if exp_guid and actual_guid_is_placeholder:
expected_name_key = _company_identity_name(exp_name)
actual_name_key = _company_identity_name(actual_name)
if not expected_name_key or not actual_name_key or expected_name_key != actual_name_key:
raise RuntimeError(
"Tally ODBC did not return a usable company GUID "
f"(returned '{actual_guid or 'blank'}') and the company name could not safely "
f"confirm the saved mapping. ERP expected '{exp_name}', ODBC returned '{actual_name}'."
)
validation_warnings.append(
"Tally ODBC returned a selector token instead of the real company GUID "
f"('{actual_guid or 'blank'}'). The mirror was accepted because the normalized "
"company name matches the saved ERP mapping. The saved ERP GUID was preserved."
)
if exp_name and actual_name and _company_identity_name(actual_name) != _company_identity_name(exp_name):
validation_warnings.append( validation_warnings.append(
f"Tally company name differs from the ERP snapshot: ERP '{exp_name}', Tally '{actual_name}'. " 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." "The saved ERP mapping was not changed."
) )
report(96, "Publishing Accounting Mirror", "Publishing the verified SQLite mirror inside the client's Accounting folder.", rows=dict(rows)) report(96, "Publishing Accounting Mirror", "Publishing the verified SQLite mirror inside the client's Accounting folder.", rows=dict(rows))
@@ -385,11 +434,14 @@ class AccountingMirrorEngine:
dst.close(); src.close() dst.close(); src.close()
temp.replace(paths.current_mirror) temp.replace(paths.current_mirror)
cache = self._cache_file(paths, actual_name, actual_guid) effective_guid = exp_guid if _is_placeholder_company_guid(actual_guid) else actual_guid
cache = self._cache_file(paths, actual_name, effective_guid)
shutil.copy2(paths.current_mirror, cache) shutil.copy2(paths.current_mirror, cache)
result = { result = {
"ok": True, "synced_at_utc": _utc_now(), "company_name": actual_name, "ok": True, "synced_at_utc": _utc_now(), "company_name": actual_name,
"company_guid": actual_guid, "company_cache_path": str(cache), "company_guid": effective_guid,
"odbc_company_guid": actual_guid,
"company_cache_path": str(cache),
"validation_warnings": validation_warnings, "validation_warnings": validation_warnings,
"extractor_log_tail": log[-1500:], "loader_log_tail": llog[-1500:], "extractor_log_tail": log[-1500:], "loader_log_tail": llog[-1500:],
**self.status(client_id), **self.status(client_id),