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