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
+219 -1
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import date, datetime, timezone
from io import BytesIO
from difflib import SequenceMatcher
import json
import textwrap
from pathlib import Path
@@ -254,6 +255,108 @@ def _normalise_gstin(value: str) -> str:
return re.sub(r"\s+", "", str(value or "").strip().upper())
def _normalise_company_name(value: str) -> str:
text = str(value or "").strip().upper()
replacements = {
"PRIVATE LIMITED": "PVT LTD",
"PVT. LTD.": "PVT LTD",
"PVT. LTD": "PVT LTD",
"LIMITED": "LTD",
}
for source, target in replacements.items():
text = text.replace(source, target)
text = re.sub(r"\b(AUDIT|AUDITED|BOOKS|TALLY)\b", " ", text)
return re.sub(r"[^A-Z0-9]+", "", text)
def _tally_company_identity_check(client, registrations, company: dict | None) -> dict:
company = company or {}
tally_name = str(company.get("name") or "").strip()
tally_gstins: list[str] = []
for value in list(company.get("gstins") or []) + [company.get("gstin")]:
gstin = _normalise_gstin(value)
if gstin and gstin not in tally_gstins:
tally_gstins.append(gstin)
erp_gstins = _erp_client_gstins(registrations)
erp_names = [str(getattr(client, "client_name", "") or "").strip()]
for registration, registration_type in registrations or []:
if str(getattr(registration_type, "code", "") or "").strip().upper() != "GSTIN":
continue
for value in (getattr(registration, "legal_name", ""), getattr(registration, "trade_name", "")):
name = str(value or "").strip()
if name and name not in erp_names:
erp_names.append(name)
tally_normal = _normalise_company_name(tally_name)
name_scores = []
for name in erp_names:
normal = _normalise_company_name(name)
if not normal or not tally_normal:
continue
score = 1.0 if normal == tally_normal else SequenceMatcher(None, normal, tally_normal).ratio()
name_scores.append((score, name))
best_score, best_name = max(name_scores, default=(0.0, erp_names[0] if erp_names else ""))
name_match = bool(best_score >= 0.82)
gst_match = bool(erp_gstins and tally_gstins and set(erp_gstins).intersection(tally_gstins))
gst_mismatch = bool(erp_gstins and tally_gstins and not gst_match)
if gst_match:
requires_confirmation = False
reason = "GSTIN matched"
elif gst_mismatch:
requires_confirmation = True
reason = "GSTIN differs"
elif name_match:
requires_confirmation = False
reason = "Company name matched"
else:
requires_confirmation = True
reason = "Company identity could not be matched automatically"
matched_registration_id = None
if gst_match:
matches = set(erp_gstins).intersection(tally_gstins)
for registration, registration_type in registrations or []:
if str(getattr(registration_type, "code", "") or "").strip().upper() == "GSTIN" and _normalise_gstin(getattr(registration, "registration_number", "")) in matches:
matched_registration_id = int(registration.id)
break
elif len([1 for _r, _t in registrations or [] if str(getattr(_t, "code", "") or "").strip().upper() == "GSTIN"]) == 1:
for registration, registration_type in registrations or []:
if str(getattr(registration_type, "code", "") or "").strip().upper() == "GSTIN":
matched_registration_id = int(registration.id)
break
return {
"requires_confirmation": requires_confirmation,
"reason": reason,
"erp_client_name": str(getattr(client, "client_name", "") or "").strip(),
"erp_best_name": best_name,
"erp_gstins": erp_gstins,
"tally_company_name": tally_name,
"tally_gstins": tally_gstins,
"name_match": name_match,
"name_score": round(float(best_score), 4),
"gst_match": gst_match,
"gst_mismatch": gst_mismatch,
"matched_registration_id": matched_registration_id,
}
def _identity_warning_message(identity: dict) -> str:
erp_gstin = ", ".join(identity.get("erp_gstins") or []) or "Not available"
tally_gstin = ", ".join(identity.get("tally_gstins") or []) or "Not available"
return (
f"The selected Tally company does not clearly match the ERP client.\n\n"
f"ERP Client: {identity.get('erp_client_name') or '-'}\n"
f"ERP GSTIN: {erp_gstin}\n\n"
f"Tally Company: {identity.get('tally_company_name') or '-'}\n"
f"Tally GSTIN: {tally_gstin}\n\n"
f"Reason: {identity.get('reason') or 'Identity mismatch'}\n\n"
"Continue only if you have verified that this is the correct Tally data for this client."
)
def _erp_client_gstins(registrations) -> list[str]:
values: list[str] = []
for registration, registration_type in registrations or []:
@@ -917,6 +1020,45 @@ def sync_tally_transactions(
@router.get("/mirror-preflight")
def mirror_preflight(request: Request, client_id: int, financial_year: str, tally_guid: str):
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.view")
if response:
return JSONResponse({"ok": False, "error": "Access denied."}, status_code=403)
client, _clients, scope = _find_visible_client(db, request, user, client_id)
if not client:
return JSONResponse({"ok": False, "error": "Client is not available."}, status_code=404)
try:
_financial_year_bounds(financial_year)
except Exception:
return JSONResponse({"ok": False, "error": "Invalid financial year."}, status_code=400)
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
if not node or not _node_online(node):
return JSONResponse({"ok": False, "error": "ERP Local Agent is offline."}, status_code=503)
payload = _accounting_storage_payload(client, financial_year, db=db, tenant_id=scope.tenant_id)
status = request_agent_command(node.node_code, "phase4_status", payload, timeout_seconds=20)
if not status.get("ok"):
return JSONResponse({"ok": False, "error": str(status.get("error") or "Unable to read Tally companies.")}, status_code=500)
tally = (status.get("result") or {}).get("tally") or {}
company = next((row for row in (tally.get("companies") or []) if str(row.get("guid") or "").strip() == str(tally_guid or "").strip()), None)
if not company:
return JSONResponse({"ok": False, "error": "The selected Tally company is no longer loaded. Refresh and try again."}, status_code=400)
registrations = _client_registrations(db, client, scope.tenant_id)
identity = _tally_company_identity_check(client, registrations, company)
return JSONResponse({
"ok": True,
"financial_year": financial_year,
"company": company,
"identity": identity,
"requires_confirmation": bool(identity.get("requires_confirmation")),
"warning": _identity_warning_message(identity) if identity.get("requires_confirmation") else "",
})
finally:
db.close()
@router.get("/mirror-registration/status")
def mirror_registration_status(request: Request, client_id: int, financial_year: str):
db = CommonSessionLocal()
@@ -975,6 +1117,7 @@ def start_full_accounting_export(
date_to: str = Form(...),
financial_year: str = Form(""),
replace_existing: str = Form(""),
allow_identity_mismatch: str = Form(""),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
@@ -1004,7 +1147,82 @@ def start_full_accounting_export(
if not node or not _node_online(node):
return RedirectResponse(url=f"/tools/tally?client_id={client.id}&error={quote('ERP Local Agent is offline for the active branch.')}", status_code=303)
storage_payload = _accounting_storage_payload(client, target_fy)
# Normal workflow is Client -> FY -> Tally Data. Storage initialization and
# the legacy GUID mapping are maintained automatically as internal compatibility
# details; users no longer have to perform either step before mirroring.
storage_payload = _accounting_storage_payload(
client, target_fy, db=db, tenant_id=scope.tenant_id
)
status_response = request_agent_command(
node.node_code, "phase4_status", storage_payload, timeout_seconds=20
)
if not status_response.get("ok"):
raise RuntimeError(str(status_response.get("error") or "Unable to read Tally companies."))
status_result = status_response.get("result") or {}
tally_status = status_result.get("tally") or {}
company = next(
(row for row in (tally_status.get("companies") or [])
if str(row.get("guid") or "").strip() == str(tally_guid or "").strip()),
None,
)
if not company:
raise RuntimeError("The selected Tally company is no longer loaded. Refresh Tally companies and try again.")
registrations = _client_registrations(db, client, scope.tenant_id)
identity = _tally_company_identity_check(client, registrations, company)
mismatch_confirmed = str(allow_identity_mismatch or "").strip().lower() in {"1", "true", "yes", "on"}
if identity.get("requires_confirmation") and not mismatch_confirmed:
return RedirectResponse(
url=(
f"/tools/tally?client_id={client.id}&financial_year={quote(target_fy)}"
f"&error={quote(_identity_warning_message(identity))}"
),
status_code=303,
)
# Ensure the selected Client/FY directory and .act support database exist.
# The path comes exclusively from the ERP directory rules for this FY.
accounting_snapshot = status_result.get("accounting") or {}
if not bool(accounting_snapshot.get("exists")):
initialized = request_agent_command(
node.node_code,
"accounting_initialize",
{
**storage_payload,
"tenant_id": int(scope.tenant_id),
"requested_by_user_id": int(user.id),
},
timeout_seconds=20,
)
if not initialized.get("ok"):
raise RuntimeError(str(initialized.get("error") or "Accounting storage initialization failed."))
# Keep the old mapping table synchronized internally because existing sync and
# write-back features still use it. This is no longer a prerequisite exposed
# to the user. Prefer the GST registration that actually matched Tally.
registration_payload = None
matched_registration_id = identity.get("matched_registration_id")
if matched_registration_id:
for registration, registration_type in registrations:
if int(registration.id) == int(matched_registration_id):
registration_payload = _registration_payload(registration, registration_type)
break
mapped_result = request_agent_command(
node.node_code,
"accounting_map_company",
{
**storage_payload,
"tenant_id": int(scope.tenant_id),
"registration": registration_payload,
"tally_guid": str(tally_guid or "").strip(),
"allow_gstin_mismatch": bool(mismatch_confirmed),
"mapped_by_user_id": int(user.id),
},
timeout_seconds=20,
)
if not mapped_result.get("ok"):
raise RuntimeError(str(mapped_result.get("error") or "Unable to prepare the selected Tally company for mirroring."))
existing_registration = get_registered_mirror(db, scope.tenant_id, int(client.id), target_fy)
existing_file = False
try: