Unify accounting analysis on local SQLite mirror

This commit is contained in:
A R R R Associates
2026-09-06 20:13:51 +05:30
parent 6c2a2081ed
commit 7adbbde220
11 changed files with 848 additions and 129 deletions
+90 -22
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
from io import BytesIO
from urllib.parse import urlencode
from datetime import datetime
from datetime import date, datetime
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse, StreamingResponse
@@ -24,6 +24,7 @@ from app.modules.accounting.opening_balance_service import (
)
from app.modules.accounting.ui import (
_accounting_storage_payload,
_financial_year_for_date,
_find_visible_client,
_node_online,
_require_partner,
@@ -118,6 +119,7 @@ def page(
show_zero_new: int = 0,
message: str = "",
error: str = "",
analysis_run_id: int = 0,
):
db = CommonSessionLocal()
try:
@@ -136,20 +138,35 @@ def page(
tally = None
companies = []
command_error = error or ""
today = date.today()
current_fy = _financial_year_for_date(today)
current_start = int(current_fy.split("-", 1)[0])
previous_fy = f"{current_start-1}-{str(current_start)[-2:]}"
previous_mirror_status = None
current_mirror_status = None
if selected and node and online:
try:
response = request_agent_command(
prev_response = request_agent_command(
node.node_code,
"phase6_status",
_accounting_storage_payload(selected),
"accounting_mirror_status",
_accounting_storage_payload(selected, previous_fy),
timeout_seconds=20,
)
if response.get("ok"):
tally = (response.get("result") or {}).get("tally")
companies = _company_rows(tally)
else:
command_error = str(response.get("error") or "Local Agent status failed.")
curr_response = request_agent_command(
node.node_code,
"accounting_mirror_status",
_accounting_storage_payload(selected, current_fy),
timeout_seconds=20,
)
if prev_response.get("ok"):
previous_mirror_status = prev_response.get("result") or {}
if curr_response.get("ok"):
current_mirror_status = curr_response.get("result") or {}
if not prev_response.get("ok"):
command_error = str(prev_response.get("error") or "Previous-year Accounting Mirror status failed.")
elif not curr_response.get("ok"):
command_error = str(curr_response.get("error") or "Current-year Accounting Mirror status failed.")
except Exception as exc:
command_error = str(exc)
@@ -467,8 +484,8 @@ def download_report(request: Request, run_id: int, client_id: int | None = None)
def compare(
request: Request,
client_id: int = Form(...),
previous_company_name: str = Form(...),
current_company_name: str = Form(...),
previous_fy: str = Form(...),
current_fy: str = Form(...),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
@@ -477,32 +494,30 @@ def compare(
user, denied = _require_partner(request, db, "accounting.tally.sync_masters")
if denied:
return denied
client, _clients, scope = _find_visible_client(db, request, user, client_id)
if not client:
return _go(error="Client is not visible.")
if previous_company_name.strip().casefold() == current_company_name.strip().casefold():
raise ValueError(
"Previous-year and current-year companies must be different in Phase 21A. "
"This protects against using the current company's latest closing balance as the prior-year closing."
)
if previous_fy.strip() == current_fy.strip():
raise ValueError("Previous-year and current-year mirrors must be different.")
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
if not node or not _node_online(node):
raise ValueError("ERP Local Agent is offline for the active branch.")
previous_payload = _accounting_storage_payload(client, previous_fy)
current_payload = _accounting_storage_payload(client, current_fy)
response = request_agent_command(
node.node_code,
"accounting_opening_balance_snapshot",
"accounting_opening_balance_mirror_snapshot",
{
**_accounting_storage_payload(client),
"previous_company_name": previous_company_name,
"current_company_name": current_company_name,
**current_payload,
"previous_accounting_relative_dir": previous_payload["accounting_relative_dir"],
"current_accounting_relative_dir": current_payload["accounting_relative_dir"],
},
timeout_seconds=180,
)
if not response.get("ok"):
raise RuntimeError(str(response.get("error") or "Opening balance snapshot failed."))
raise RuntimeError(str(response.get("error") or "Opening balance mirror comparison failed."))
result = response.get("result") or {}
run = create_comparison_run(
@@ -515,10 +530,63 @@ def compare(
current_masters=result.get("current_masters") or {},
user_id=user.id,
)
ledger_rows = ledger_items(db, run_id=run.id)
stock_rows = stock_items(db, run_id=run.id)
try:
saved = request_agent_command(
node.node_code,
"accounting_analysis_save",
{
**current_payload,
"analysis_type": "OPENING_BALANCE_REVIEW",
"financial_year": current_fy,
"period_from": previous_fy,
"period_to": current_fy,
"company_guid": run.current_company_guid,
"company_name": run.current_company_name,
"requested_by_user_id": int(user.id),
"source_mirror_path": str(result.get("current_mirror_path") or ""),
"parameters": {"previous_fy": previous_fy, "current_fy": current_fy, "erp_run_id": run.id},
"summary": json.loads(run.summary_json or "{}"),
"result": {
"erp_run_id": run.id,
"previous_company": result.get("previous_company") or {},
"current_company": result.get("current_company") or {},
"ledgers": [
{
"previous_name": x.previous_name,
"current_name": x.current_name,
"previous_closing_balance": x.previous_closing_balance,
"current_opening_balance": x.current_opening_balance,
"difference": x.difference,
"status": x.status,
} for x in ledger_rows
],
"stock_items": [
{
"previous_name": x.previous_name,
"current_name": x.current_name,
"previous_closing_qty": x.previous_closing_qty,
"current_opening_qty": x.current_opening_qty,
"previous_closing_value": x.previous_closing_value,
"current_opening_value": x.current_opening_value,
"status": x.status,
} for x in stock_rows
],
},
},
timeout_seconds=30,
)
analysis_run_id = int(((saved.get("result") or {}).get("analysis_run_id") or 0)) if saved.get("ok") else 0
except Exception:
analysis_run_id = 0
return _go(
client.id,
run_id=run.id,
tab="summary",
analysis_run_id=analysis_run_id,
message=(
f"Opening balance comparison #{run.id} completed: "
f"{run.ledger_count} ledger row(s), {run.stock_item_count} stock row(s)."
+31 -11
View File
@@ -67,16 +67,36 @@ def _workspace(request, db, user, client_id, tally_guid, date_from, date_to, run
online = _node_online(node)
today=date.today(); fy=_financial_year_for_date(today); sy=int(fy.split('-',1)[0])
start=date_from or date(sy,4,1).isoformat(); end=date_to or today.isoformat()
live=None; review=None; error=""
live=None; review=None; error=""; history=[]
if selected and node and online:
st=request_agent_command(node.node_code,"phase4_status",_accounting_storage_payload(selected,_financial_year_for_date(date.fromisoformat(start))),timeout_seconds=20)
storage = _accounting_storage_payload(selected,_financial_year_for_date(date.fromisoformat(start)))
st=request_agent_command(node.node_code,"accounting_mirror_status",storage,timeout_seconds=20)
if st.get("ok"): live=st.get("result") or {}
else: error=str(st.get("error") or "Local Agent status failed.")
if run and tally_guid:
rr=request_agent_command(node.node_code,"accounting_tds_compliance",{**_accounting_storage_payload(selected,_financial_year_for_date(date.fromisoformat(start))),"tally_guid":tally_guid,"date_from":start,"date_to":end,"rules":_rule_payload(_rules(db,_tenant_id(request,user)))},timeout_seconds=120)
else: error=str(st.get("error") or "Accounting Mirror status failed.")
if run and not error:
rr=request_agent_command(
node.node_code,
"accounting_tds_compliance",
{
**storage,
"date_from":start,
"date_to":end,
"rules":_rule_payload(_rules(db,_tenant_id(request,user))),
"requested_by_user_id":int(user.id),
},
timeout_seconds=120,
)
if rr.get("ok"): review=(rr.get("result") or {}).get("tds_review") or {}
else: error=str(rr.get("error") or "TDS review failed.")
return clients, selected, node, online, live, review, start, end, error
try:
hist=request_agent_command(
node.node_code,"accounting_analysis_history",
{**storage,"analysis_type":"TDS_COMPLIANCE","limit":20},timeout_seconds=20
)
if hist.get("ok"): history=(hist.get("result") or {}).get("runs") or []
except Exception:
history=[]
return clients, selected, node, online, live, review, start, end, error, history
@router.get("/rules")
@@ -116,8 +136,8 @@ def compliance(request:Request,client_id:int|None=None,tally_guid:str="",date_fr
try:
user,response=_require_partner(request,db,"accounting.tally.view")
if response:return response
clients,selected,node,online,live,review,start,end,error=_workspace(request,db,user,client_id,tally_guid,date_from,date_to,run)
return _render(request,db,user,"modules/accounting/templates/accounting/tds_compliance.html",clients=clients,selected_client=selected,node=node,node_online=online,live=live,review=review,date_from=start,date_to=end,tally_guid=tally_guid,error=error,rules=_rules(db,_tenant_id(request,user)))
clients,selected,node,online,live,review,start,end,error,history=_workspace(request,db,user,client_id,tally_guid,date_from,date_to,run)
return _render(request,db,user,"modules/accounting/templates/accounting/tds_compliance.html",clients=clients,selected_client=selected,node=node,node_online=online,live=live,review=review,date_from=start,date_to=end,tally_guid=tally_guid,error=error,rules=_rules(db,_tenant_id(request,user)),history=history)
finally: db.close()
@@ -127,10 +147,10 @@ def liability(request:Request,client_id:int|None=None,tally_guid:str="",date_fro
try:
user,response=_require_partner(request,db,"accounting.tally.view")
if response:return response
clients,selected,node,online,live,review,start,end,run_error=_workspace(request,db,user,client_id,tally_guid,date_from,date_to,run)
clients,selected,node,online,live,review,start,end,run_error,history=_workspace(request,db,user,client_id,tally_guid,date_from,date_to,run)
instructions=[]
if selected: instructions=list(db.execute(select(AccountingTDSPostingInstruction).where(AccountingTDSPostingInstruction.tenant_id==_tenant_id(request,user),AccountingTDSPostingInstruction.client_id==selected.id).order_by(AccountingTDSPostingInstruction.id.desc()).limit(100)).scalars())
return _render(request,db,user,"modules/accounting/templates/accounting/tds_liability.html",clients=clients,selected_client=selected,node=node,node_online=online,live=live,review=review,date_from=start,date_to=end,tally_guid=tally_guid,error=error or run_error,saved=saved,instructions=instructions)
return _render(request,db,user,"modules/accounting/templates/accounting/tds_liability.html",clients=clients,selected_client=selected,node=node,node_online=online,live=live,review=review,date_from=start,date_to=end,tally_guid=tally_guid,error=error or run_error,saved=saved,instructions=instructions,history=history)
finally:db.close()
@@ -209,7 +229,7 @@ async def reconciliation_run(request:Request):
node=get_active_storage_node_for_branch(db,scope.tenant_id,scope.branch_id)
if not node or not _node_online(node): raise ValueError("Local Agent is offline.")
dfrom=str(form.get("date_from") or ""); dto=str(form.get("date_to") or ""); guid=str(form.get("tally_guid") or "")
rr=request_agent_command(node.node_code,"accounting_tds_compliance",{**_accounting_storage_payload(selected,_financial_year_for_date(date.fromisoformat(dfrom))),"tally_guid":guid,"date_from":dfrom,"date_to":dto,"rules":_rule_payload(_rules(db,_tenant_id(request,user)))},timeout_seconds=120)
rr=request_agent_command(node.node_code,"accounting_tds_compliance",{**_accounting_storage_payload(selected,_financial_year_for_date(date.fromisoformat(dfrom))),"date_from":dfrom,"date_to":dto,"rules":_rule_payload(_rules(db,_tenant_id(request,user)))},timeout_seconds=120)
if not rr.get("ok"): raise ValueError(str(rr.get("error") or "Tally review failed."))
review=(rr.get("result") or {}).get("tds_review") or {}; tally_total=round(sum(float(x.get("actual_tds") or 0) for x in review.get("transactions",[])),2); expected_total=round(sum(float(x.get("expected_tds") or 0) for x in review.get("transactions",[])),2)
returns=_parse_external(str(form.get("return_data") or "")); challans=_parse_external(str(form.get("challan_data") or "")); traces=_parse_external(str(form.get("traces_data") or ""))
@@ -91,10 +91,9 @@
{% if review %}
<section class="space-y-4">
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
Analysis completed entirely from the local SQLite Accounting Mirror. TallyPrime was not contacted.
{% if review.cash_ledgers %} Cash ledger(s): <strong>{{ review.cash_ledgers|join(', ') }}</strong>.{% endif %}
{% if review.mirror_period %} Mirror period: {{ review.mirror_period.from_date or '—' }} → {{ review.mirror_period.to_date or '—' }}.{% endif %}
<div class="flex flex-wrap justify-end gap-2">
<a href="/tools/tally/analysis/{{ review.analysis_run_id }}/download?client_id={{ selected_client.id }}&financial_year={{ date_from[:4] }}-{{ date_to[2:4] }}&format=xlsx" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-800">Download Excel</a>
<a href="/tools/tally/analysis/{{ review.analysis_run_id }}/download?client_id={{ selected_client.id }}&financial_year={{ date_from[:4] }}-{{ date_to[2:4] }}&format=pdf" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Download PDF</a>
</div>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
{% for label, value in [('Vouchers reviewed', review.summary.vouchers_reviewed), ('Cash payments', review.summary.cash_payment_vouchers), ('Single-voucher exceptions', review.summary.single_voucher_exceptions), ('Same-day exceptions', review.summary.same_day_exceptions), ('Possible split patterns', review.summary.possible_split_patterns)] %}
@@ -1,14 +1,12 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% set agent = live_result.agent if live_result else None %}
{% set tally = live_result.tally if live_result else None %}
{% set accounting = live_result.accounting if live_result else None %}
{% set mirror = mirror_status.mirror if mirror_status and mirror_status.mirror else {} %}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Tally · Phase 6</p>
<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">Depreciation (Income Tax)</h1>
<p class="mt-1 text-sm text-slate-500">Calculate Income-tax depreciation, approve the reviewed draft and—only after explicit confirmation—post the approved Journal to the mapped company currently open in TallyPrime.</p>
<p class="mt-1 text-sm text-slate-500">Calculate Income-tax depreciation from the client SQLite Accounting Mirror. Approved drafts retain the existing controlled Tally write-back.</p>
</div>
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}&refresh=1{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to Tally</a>
</div>
@@ -19,15 +17,14 @@
{% if command_error %}<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">{{ command_error }}</div>{% endif %}
<section class="grid gap-4 md:grid-cols-3">
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">ERP Local Agent</div><div class="mt-2 text-lg font-semibold {{ 'text-emerald-700' if agent_online else 'text-red-700' }}">{{ 'Connected' if agent_online else 'Offline' }}</div><div class="mt-1 text-xs text-slate-500">Agent {{ agent.version if agent and agent.version else '-' }}</div></div>
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">TallyPrime</div><div class="mt-2 text-lg font-semibold {{ 'text-emerald-700' if tally and tally.connected else 'text-slate-700' }}">{{ 'Connected' if tally and tally.connected else 'Not connected' }}</div><div class="mt-1 text-xs text-slate-500">{{ tally.company_count if tally else 0 }} currently loaded</div></div>
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Phase 6</div><div class="mt-2 text-lg font-semibold {{ 'text-emerald-700' if agent and agent.tally_writeback_capability else 'text-slate-700' }}">{{ 'Available' if agent and agent.tally_writeback_capability else 'Update Agent' }}</div><div class="mt-1 text-xs text-slate-500">Controlled approved write-back</div></div>
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">ERP Local Agent</div><div class="mt-2 text-lg font-semibold {{ 'text-emerald-700' if agent_online else 'text-red-700' }}">{{ 'Connected' if agent_online else 'Offline' }}</div></div>
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">SQLite Mirror</div><div class="mt-2 text-lg font-semibold {{ 'text-emerald-700' if mirror.ready else 'text-amber-700' }}">{{ 'Ready' if mirror.ready else 'Mirror required' }}</div><div class="mt-1 text-xs text-slate-500">{{ mirror.company.company_name if mirror.ready and mirror.company else '' }}</div></div>
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Write-back</div><div class="mt-2 text-lg font-semibold text-slate-700">Controlled</div><div class="mt-1 text-xs text-slate-500">Tally is contacted only after approval/posting.</div></div>
</section>
<section class="rounded-2xl bg-white p-5 shadow-soft">
<form method="get" action="/tools/tally/depreciation" class="grid gap-4 lg:grid-cols-4 lg:items-end">
<form method="get" action="/tools/tally/depreciation" class="grid gap-4 lg:grid-cols-3 lg:items-end">
<div><label class="text-sm font-medium text-slate-700">ERP Client</label><select name="client_id" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" onchange="this.form.submit()"><option value="">Select client</option>{% for client in clients %}<option value="{{ client.id }}" {% if selected_client and selected_client.id == client.id %}selected{% endif %}>{{ client.client_name }}</option>{% endfor %}</select></div>
<div><label class="text-sm font-medium text-slate-700">Currently Open in TallyPrime</label><select name="tally_guid" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" {% if not selected_client %}disabled{% endif %}><option value="">Select mapped open company</option>{% if tally %}{% for company in tally.companies or [] %}<option value="{{ company.guid }}" {% if selected_tally_guid == company.guid %}selected{% endif %}>{{ company.name }}{% if company.gstin %} · {{ company.gstin }}{% endif %}</option>{% endfor %}{% endif %}</select></div>
<div><label class="text-sm font-medium text-slate-700">Financial Year</label><div class="mt-1 grid grid-cols-2 gap-2"><input type="date" name="fy_start" value="{{ fy_start }}" class="w-full rounded-xl border border-slate-300 px-3 py-2"><input type="date" name="fy_end" value="{{ fy_end }}" class="w-full rounded-xl border border-slate-300 px-3 py-2"></div></div>
<button type="submit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Load Fixed Assets</button>
</form>
@@ -37,14 +34,14 @@
{% set coverage = preview.transaction_coverage %}
<div class="rounded-xl border {{ 'border-emerald-200 bg-emerald-50 text-emerald-800' if coverage.complete else 'border-amber-200 bg-amber-50 text-amber-900' }} px-4 py-3 text-sm">
{% if coverage.complete %}
Phase 4 transaction data covers the required period {{ coverage.required_from }} to {{ coverage.required_to }}{% if coverage.provisional %}. This is a provisional current-FY calculation; future dates are not required yet{% endif %}.
SQLite mirror data covers the required period {{ coverage.required_from }} to {{ coverage.required_to }}{% if coverage.provisional %}. This is a provisional current-FY calculation; future dates are not required yet{% endif %}.
{% else %}
Phase 4 transaction data does not yet cover {{ coverage.required_from }} to {{ coverage.required_to }}. You may save a draft, but approval/write-back will remain blocked until the missing period is synchronized.
SQLite mirror data does not yet cover {{ coverage.required_from }} to {{ coverage.required_to }}. You may save a draft, but approval/write-back will remain blocked until the required FY is mirrored.
{% endif %}
</div>
<form method="post" action="/tools/tally/depreciation/calculate" class="space-y-5">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}"><input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}"><input type="hidden" name="fy_start" value="{{ fy_start }}"><input type="hidden" name="fy_end" value="{{ fy_end }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}"><input type="hidden" name="fy_start" value="{{ fy_start }}"><input type="hidden" name="fy_end" value="{{ fy_end }}">
<section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="grid gap-4 md:grid-cols-2">
<div><label class="text-sm font-medium text-slate-700">Depreciation Expense Ledger</label><select name="depreciation_expense_ledger" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"><option value="">Depreciation</option>{% for name in preview.all_ledgers %}<option value="{{ name }}">{{ name }}</option>{% endfor %}</select></div>
@@ -82,6 +79,12 @@
{% endif %}
{% if depreciation_run %}
{% if depreciation_run.analysis_run_id %}
<div class="flex justify-end gap-2">
<a href="/tools/tally/analysis/{{ depreciation_run.analysis_run_id }}/download?client_id={{ selected_client.id }}&financial_year={{ fy_start[:4] }}-{{ fy_end[2:4] }}&format=xlsx" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-800">Download Excel</a>
<a href="/tools/tally/analysis/{{ depreciation_run.analysis_run_id }}/download?client_id={{ selected_client.id }}&financial_year={{ fy_start[:4] }}-{{ fy_end[2:4] }}&format=pdf" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold">Download PDF</a>
</div>
{% endif %}
<section class="rounded-2xl bg-white p-5 shadow-soft"><div class="flex flex-wrap justify-between gap-4"><div><h2 class="font-semibold text-slate-900">Draft Calculation #{{ depreciation_run.id }}</h2><p class="mt-1 text-sm text-slate-500">{{ depreciation_run.company_name }} · {{ depreciation_run.fy_start }} to {{ depreciation_run.fy_end }}</p></div><div class="text-right"><div class="text-xs uppercase tracking-wide text-slate-500">Total Depreciation</div><div class="text-2xl font-semibold text-slate-900">₹ {{ '%.2f'|format(depreciation_run.total_depreciation) }}</div></div></div>
<div class="mt-4 rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">Journal: Debit <strong>{{ depreciation_run.depreciation_expense_ledger or 'Depreciation' }}</strong> and credit <strong>{{ depreciation_run.depreciation_reserve_ledger or 'Depreciation Reserve' }}</strong> for ₹ {{ '%.2f'|format(depreciation_run.total_depreciation) }}. Voucher date will be {{ depreciation_run.fy_end }} and reference ARRR-ITDEP-{{ depreciation_run.id }}.</div>
<div class="mt-4 grid gap-3 md:grid-cols-3">
@@ -148,4 +151,10 @@
})();
</script>
{% if analysis_history %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<h2 class="font-semibold text-slate-900">Depreciation Analysis History</h2>
<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">FY</th><th class="p-2">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">{{ row.financial_year }}</td><td class="p-2">{{ 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 %}
{% endblock %}
@@ -22,33 +22,29 @@
<section class="rounded-2xl bg-white p-5 shadow-soft">
<form method="get" class="grid gap-4 md:grid-cols-[1.5fr_auto]">
<label class="text-sm font-semibold">ERP Client
<select name="client_id" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2">
<select name="client_id" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2" onchange="this.form.submit()">
<option value="">Select client</option>
{% for client in clients %}
<option value="{{ client.id }}" {% if selected_client and selected_client.id==client.id %}selected{% endif %}>{{ client.client_code }} · {{ client.client_name }}</option>
{% endfor %}
</select>
</label>
<div class="flex items-end"><button class="rounded-xl bg-brand-600 px-5 py-2 text-sm font-semibold text-white">Load Tally Companies</button></div>
<div class="flex items-end"><a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-5 py-2 text-sm font-semibold">Manage Mirrors</a></div>
</form>
{% if selected_client %}
<form method="post" action="/tools/accounting/opening-balances/compare" class="mt-5 grid gap-4 rounded-xl border border-slate-200 bg-slate-50 p-4 md:grid-cols-[1fr_1fr_auto]">
{% set prev = previous_mirror_status.mirror if previous_mirror_status and previous_mirror_status.mirror else {} %}
{% set curr = current_mirror_status.mirror if current_mirror_status and current_mirror_status.mirror else {} %}
<div class="mt-5 grid gap-3 md:grid-cols-2">
<div class="rounded-xl border p-4 {{ 'border-emerald-200 bg-emerald-50' if prev.ready else 'border-amber-200 bg-amber-50' }}"><div class="text-xs font-semibold uppercase text-slate-500">Previous FY Mirror · {{ previous_fy }}</div><div class="mt-1 font-semibold">{{ 'Ready' if prev.ready else 'Mirror required' }}</div>{% if prev.ready %}<div class="mt-1 text-xs text-slate-500">{{ prev.company.company_name }} · {{ prev.counts.voucher }} vouchers</div>{% endif %}</div>
<div class="rounded-xl border p-4 {{ 'border-emerald-200 bg-emerald-50' if curr.ready else 'border-amber-200 bg-amber-50' }}"><div class="text-xs font-semibold uppercase text-slate-500">Current FY Mirror · {{ current_fy }}</div><div class="mt-1 font-semibold">{{ 'Ready' if curr.ready else 'Mirror required' }}</div>{% if curr.ready %}<div class="mt-1 text-xs text-slate-500">{{ curr.company.company_name }} · {{ curr.counts.voucher }} vouchers</div>{% endif %}</div>
</div>
<form method="post" action="/tools/accounting/opening-balances/compare" class="mt-4 flex flex-wrap items-end gap-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<label class="text-xs font-semibold">Previous-Year Tally Company
<select name="previous_company_name" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
<option value="">Select previous-year company</option>
{% for company in companies %}<option value="{{ company.name }}">{{ company.name }}</option>{% endfor %}
</select>
</label>
<label class="text-xs font-semibold">Current-Year Tally Company
<select name="current_company_name" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
<option value="">Select current-year company</option>
{% for company in companies %}<option value="{{ company.name }}">{{ company.name }}</option>{% endfor %}
</select>
</label>
<div class="flex items-end"><button class="rounded-xl bg-slate-900 px-5 py-2 text-sm font-semibold text-white" {% if not node_online %}disabled{% endif %}>Compare Opening Balances</button></div>
<input type="hidden" name="previous_fy" value="{{ previous_fy }}">
<input type="hidden" name="current_fy" value="{{ current_fy }}">
<button class="rounded-xl bg-slate-900 px-5 py-2 text-sm font-semibold text-white" {% if not node_online or not prev.ready or not curr.ready %}disabled{% endif %}>Compare Opening Balances</button>
</form>
{% endif %}
</section>
@@ -77,8 +73,9 @@
{% endif %}
{% if selected_run %}
<div class="mb-4 flex justify-end">
<div class="mb-4 flex justify-end gap-2">
<a href="/tools/accounting/opening-balances/download/{{ selected_run.id }}?client_id={{ selected_client.id }}" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-800">Download Excel Report</a>
{% if analysis_run_id %}<a href="/tools/tally/analysis/{{ analysis_run_id }}/download?client_id={{ selected_client.id }}&financial_year={{ current_fy }}&format=pdf" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold">Download PDF</a>{% endif %}
</div>
<section class="rounded-2xl bg-white p-5 shadow-soft">
@@ -48,6 +48,10 @@
<a href="/tools/tally/depreciation{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Depreciation (IT)</a>
<a href="/tools/accounting/cash-payments{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-orange-300 bg-white px-3 py-2 text-sm font-semibold text-orange-800">Cash Payment Compliance</a>
<a href="/tools/accounting/cash-payments/allocation{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-amber-300 bg-white px-3 py-2 text-sm font-semibold text-amber-800">Cash Payment Entry Allocation</a>
<a href="/tools/accounting/tds{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-violet-300 bg-white px-3 py-2 text-sm font-semibold text-violet-800">TDS Compliance Review</a>
<a href="/tools/accounting/tds/rules" class="rounded-xl border border-violet-200 bg-white px-3 py-2 text-sm font-semibold text-violet-700">TDS Rules</a>
<a href="/tools/accounting/tds/liability{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-violet-200 bg-white px-3 py-2 text-sm font-semibold text-violet-700">TDS Liability</a>
<a href="/tools/accounting/tds/reconciliation{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-violet-200 bg-white px-3 py-2 text-sm font-semibold text-violet-700">TDS Reconciliation</a>
<a href="/tools/accounting/ai{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-indigo-300 bg-indigo-50 px-3 py-2 text-sm font-semibold text-indigo-800">Accounting AI</a>
<a href="/tools/accounting/internal-model" class="rounded-xl border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-800">Internal Model</a>
</div>
@@ -169,6 +173,12 @@
{% 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">
@@ -1,5 +1,5 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-3"><div><p class="text-xs font-semibold uppercase tracking-[0.16em] text-violet-600">TDS &amp; Withholding Tax</p><h1 class="mt-1 text-2xl font-semibold text-slate-900">TDS Compliance Review</h1><p class="mt-1 text-sm text-slate-500">Read-only Tally scan across expenses, purchases, capital/service purchases, journals and creditor postings. Rules and thresholds come from the editable TDS Rule Master.</p></div><div class="flex gap-2"><a href="/tools/accounting/tds/rules" class="rounded-xl border px-3 py-2 text-sm font-semibold">Rule Master</a><a href="/tools/tally" class="rounded-xl border px-3 py-2 text-sm font-semibold">Tally</a></div></div>{% if error %}<div class="rounded-xl bg-red-50 p-3 text-sm text-red-800">{{ error }}</div>{% endif %}<form method="get" class="rounded-2xl bg-white p-5 shadow-soft"><div class="grid gap-3 lg:grid-cols-4"><label class="text-sm">Client<select name="client_id" required class="mt-1 w-full rounded-xl border px-3 py-2"><option value="">Select client</option>{% for c in clients %}<option value="{{ c.id }}" {% if selected_client and c.id==selected_client.id %}selected{% endif %}>{{ c.client_name }}</option>{% endfor %}</select></label><label class="text-sm">Tally company GUID<input name="tally_guid" value="{{ tally_guid or '' }}" required class="mt-1 w-full rounded-xl border px-3 py-2" placeholder="Select/copy GUID from mapped/open company"></label><label class="text-sm">From<input type="date" name="date_from" value="{{ date_from }}" required class="mt-1 w-full rounded-xl border px-3 py-2"></label><label class="text-sm">To<input type="date" name="date_to" value="{{ date_to }}" required class="mt-1 w-full rounded-xl border px-3 py-2"></label></div><input type="hidden" name="run" value="1"><button class="mt-4 rounded-xl bg-violet-700 px-4 py-2 text-sm font-semibold text-white">Run TDS Review</button></form>{% if review %}<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">{% for k,v in review.summary.items() %}<div class="rounded-xl bg-white p-4 shadow-soft"><div class="text-xs uppercase text-slate-500">{{ k|replace('_',' ') }}</div><div class="mt-1 text-xl font-semibold">{{ v }}</div></div>{% endfor %}</div><div class="overflow-x-auto rounded-2xl bg-white shadow-soft"><table class="min-w-full text-sm"><thead class="bg-slate-50"><tr><th class="p-3 text-left">Date</th><th class="p-3 text-left">Party</th><th class="p-3 text-left">Ledger / Rule</th><th class="p-3 text-right">Base</th><th class="p-3 text-right">Expected</th><th class="p-3 text-right">Actual</th><th class="p-3 text-right">Liability</th><th class="p-3 text-left">Status</th></tr></thead><tbody>{% for x in review.transactions %}<tr class="border-t"><td class="p-3">{{ x.date }}</td><td class="p-3">{{ x.party }}</td><td class="p-3">{{ x.nature_ledger }}<div class="text-xs text-slate-500">{{ x.rule_name }} · {{ x.legacy_section }}</div></td><td class="p-3 text-right">{{ '%.2f'|format(x.taxable_base) }}</td><td class="p-3 text-right">{{ '%.2f'|format(x.expected_tds) }}</td><td class="p-3 text-right">{{ '%.2f'|format(x.actual_tds) }}</td><td class="p-3 text-right font-semibold">{{ '%.2f'|format(x.liability) }}</td><td class="p-3">{{ x.status }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}</div>
<div class="flex flex-wrap items-start justify-between gap-3"><div><p class="text-xs font-semibold uppercase tracking-[0.16em] text-violet-600">TDS &amp; Withholding Tax</p><h1 class="mt-1 text-2xl font-semibold text-slate-900">TDS Compliance Review</h1><p class="mt-1 text-sm text-slate-500">Review expenses, purchases, journals and creditor postings from the client SQLite Accounting Mirror. Rules and thresholds come from the editable TDS Rule Master.</p></div><div class="flex gap-2"><a href="/tools/accounting/tds/rules" class="rounded-xl border px-3 py-2 text-sm font-semibold">Rule Master</a><a href="/tools/tally" class="rounded-xl border px-3 py-2 text-sm font-semibold">Tally</a></div></div>{% if error %}<div class="rounded-xl bg-red-50 p-3 text-sm text-red-800">{{ error }}</div>{% endif %}<form method="get" class="rounded-2xl bg-white p-5 shadow-soft"><div class="grid gap-3 lg:grid-cols-3"><label class="text-sm">Client<select name="client_id" required class="mt-1 w-full rounded-xl border px-3 py-2"><option value="">Select client</option>{% for c in clients %}<option value="{{ c.id }}" {% if selected_client and c.id==selected_client.id %}selected{% endif %}>{{ c.client_name }}</option>{% endfor %}</select></label><label class="text-sm">From<input type="date" name="date_from" value="{{ date_from }}" required class="mt-1 w-full rounded-xl border px-3 py-2"></label><label class="text-sm">To<input type="date" name="date_to" value="{{ date_to }}" required class="mt-1 w-full rounded-xl border px-3 py-2"></label></div><input type="hidden" name="run" value="1"><button class="mt-4 rounded-xl bg-violet-700 px-4 py-2 text-sm font-semibold text-white">Run TDS Review</button></form>{% if review %}<div class="flex justify-end gap-2"><a href="/tools/tally/analysis/{{ review.analysis_run_id }}/download?client_id={{ selected_client.id }}&financial_year={{ date_from[:4] }}-{{ date_to[2:4] }}&format=xlsx" class="rounded-xl border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-800">Download Excel</a><a href="/tools/tally/analysis/{{ review.analysis_run_id }}/download?client_id={{ selected_client.id }}&financial_year={{ date_from[:4] }}-{{ date_to[2:4] }}&format=pdf" class="rounded-xl border px-3 py-2 text-sm font-semibold">Download PDF</a></div><div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">{% for k,v in review.summary.items() %}<div class="rounded-xl bg-white p-4 shadow-soft"><div class="text-xs uppercase text-slate-500">{{ k|replace('_',' ') }}</div><div class="mt-1 text-xl font-semibold">{{ v }}</div></div>{% endfor %}</div><div class="overflow-x-auto rounded-2xl bg-white shadow-soft"><table class="min-w-full text-sm"><thead class="bg-slate-50"><tr><th class="p-3 text-left">Date</th><th class="p-3 text-left">Party</th><th class="p-3 text-left">Ledger / Rule</th><th class="p-3 text-right">Base</th><th class="p-3 text-right">Expected</th><th class="p-3 text-right">Actual</th><th class="p-3 text-right">Liability</th><th class="p-3 text-left">Status</th></tr></thead><tbody>{% for x in review.transactions %}<tr class="border-t"><td class="p-3">{{ x.date }}</td><td class="p-3">{{ x.party }}</td><td class="p-3">{{ x.nature_ledger }}<div class="text-xs text-slate-500">{{ x.rule_name }} · {{ x.legacy_section }}</div></td><td class="p-3 text-right">{{ '%.2f'|format(x.taxable_base) }}</td><td class="p-3 text-right">{{ '%.2f'|format(x.expected_tds) }}</td><td class="p-3 text-right">{{ '%.2f'|format(x.actual_tds) }}</td><td class="p-3 text-right font-semibold">{{ '%.2f'|format(x.liability) }}</td><td class="p-3">{{ x.status }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}{% if history %}<div class="rounded-2xl bg-white p-5 shadow-soft"><h2 class="font-semibold">TDS Analysis History</h2><div class="mt-3 overflow-x-auto"><table class="min-w-full text-sm"><tbody>{% for row in history %}<tr class="border-t"><td class="p-2">#{{ row.id }}</td><td class="p-2">{{ row.financial_year }}</td><td class="p-2">{{ row.period_from }} → {{ row.period_to }}</td><td class="p-2 text-right"><a class="font-semibold text-violet-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-violet-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></div>{% endif %}</div>
{% endblock %}
+264 -27
View File
@@ -1,13 +1,17 @@
from __future__ import annotations
from datetime import date, datetime, timezone
from io import BytesIO
import json
import textwrap
from pathlib import Path
import re
from urllib.parse import quote
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse, JSONResponse
from fastapi.responses import RedirectResponse, JSONResponse, StreamingResponse
from sqlalchemy import select
from openpyxl import Workbook
from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
@@ -213,6 +217,7 @@ def tally_tool(
online = _node_online(node)
live_result = None
analysis_history = []
command_error = error or ""
should_query_agent = bool(refresh or selected_client)
@@ -236,6 +241,17 @@ def tally_tool(
if response_data.get("ok"):
live_result = response_data.get("result") or {}
_enrich_gstin_display(live_result, registrations)
try:
hist = request_agent_command(
node.node_code,
"accounting_analysis_history",
{**payload, "limit": 20},
timeout_seconds=20,
)
if hist.get("ok"):
analysis_history = (hist.get("result") or {}).get("runs") or []
except Exception:
analysis_history = []
else:
command_error = str(response_data.get("error") or "Local agent command failed.")
except Exception as exc:
@@ -265,6 +281,161 @@ def tally_tool(
transaction_date_from=transaction_date_from,
transaction_date_to=transaction_date_to,
command_error=command_error,
analysis_history=analysis_history,
)
finally:
db.close()
def _analysis_flat_lines(value, prefix=""):
lines = []
if isinstance(value, dict):
for key, item in value.items():
label = f"{prefix}.{key}" if prefix else str(key)
lines.extend(_analysis_flat_lines(item, label))
elif isinstance(value, list):
for idx, item in enumerate(value, 1):
label = f"{prefix}[{idx}]"
lines.extend(_analysis_flat_lines(item, label))
else:
lines.append((prefix, "" if value is None else str(value)))
return lines
def _simple_pdf_bytes(title: str, lines: list[str]) -> bytes:
def esc(text):
return str(text).replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
page_chunks = [lines[i:i+48] for i in range(0, max(1, len(lines)), 48)] or [[]]
objects = []
# Object 1 catalog, 2 pages, 3 font. Page/content objects follow.
page_refs = []
obj_no = 4
page_objects = []
for chunk in page_chunks:
page_no = obj_no
content_no = obj_no + 1
page_refs.append(f"{page_no} 0 R")
content = ["BT", "/F1 10 Tf", "48 790 Td", f"({esc(title)}) Tj", "0 -20 Td"]
for line in chunk:
content.append(f"({esc(line[:150])}) Tj")
content.append("0 -14 Td")
content.append("ET")
stream = "\n".join(content).encode("latin-1", "replace")
page_objects.append((page_no, f"<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 3 0 R >> >> /MediaBox [0 0 595 842] /Contents {content_no} 0 R >>".encode()))
page_objects.append((content_no, b"<< /Length %d >>\nstream\n" % len(stream) + stream + b"\nendstream"))
obj_no += 2
objects.append((1, b"<< /Type /Catalog /Pages 2 0 R >>"))
objects.append((2, f"<< /Type /Pages /Kids [{' '.join(page_refs)}] /Count {len(page_refs)} >>".encode()))
objects.append((3, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"))
objects.extend(page_objects)
objects.sort()
out = bytearray(b"%PDF-1.4\n")
offsets = {0: 0}
for number, body in objects:
offsets[number] = len(out)
out.extend(f"{number} 0 obj\n".encode())
out.extend(body)
out.extend(b"\nendobj\n")
xref = len(out)
max_obj = max(offsets)
out.extend(f"xref\n0 {max_obj+1}\n".encode())
out.extend(b"0000000000 65535 f \n")
for i in range(1, max_obj + 1):
out.extend(f"{offsets.get(i,0):010d} 00000 n \n".encode())
out.extend(f"trailer\n<< /Size {max_obj+1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF".encode())
return bytes(out)
@router.get("/analysis/{analysis_run_id}/download")
def download_analysis_report(
request: Request,
analysis_run_id: int,
client_id: int,
financial_year: str,
format: str = "xlsx",
):
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.view")
if response:
return response
client, _clients, scope = _find_visible_client(db, request, user, client_id)
if not client:
return _denied()
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
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,
)
result = request_agent_command(
node.node_code,
"accounting_analysis_get",
{
**_accounting_storage_payload(client, financial_year),
"run_id": int(analysis_run_id),
},
timeout_seconds=30,
)
if not result.get("ok"):
raise RuntimeError(str(result.get("error") or "Analysis report was not found."))
run = (result.get("result") or {}).get("run") or {}
safe_type = re.sub(r"[^A-Za-z0-9_-]+", "_", str(run.get("analysis_type") or "analysis")).strip("_")
filename_base = f"{safe_type}_{analysis_run_id}"
if str(format).lower() == "pdf":
lines = [
f"Client: {client.client_name}",
f"Analysis: {run.get('analysis_type','')}",
f"Financial year: {run.get('financial_year','')}",
f"Period: {run.get('period_from','')} to {run.get('period_to','')}",
f"Company: {run.get('company_name','')}",
f"Completed: {run.get('completed_at_utc','')}",
"",
"Summary",
]
for key, value in (run.get("summary") or {}).items():
lines.append(f"{str(key).replace('_',' ').title()}: {value}")
lines.append("")
lines.append("Details")
for key, value in _analysis_flat_lines(run.get("result") or {}):
lines.append(f"{key}: {value}")
payload = _simple_pdf_bytes(f"{safe_type.replace('_',' ')} Report", lines)
return StreamingResponse(
BytesIO(payload),
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename_base}.pdf"'},
)
wb = Workbook()
ws = wb.active
ws.title = "Summary"
ws.append(["Field", "Value"])
for row in (
("Client", client.client_name),
("Analysis Type", run.get("analysis_type", "")),
("Financial Year", run.get("financial_year", "")),
("Period From", run.get("period_from", "")),
("Period To", run.get("period_to", "")),
("Company", run.get("company_name", "")),
("Completed At UTC", run.get("completed_at_utc", "")),
):
ws.append(list(row))
ws.append([])
ws.append(["Summary", ""])
for key, value in (run.get("summary") or {}).items():
ws.append([str(key).replace("_", " ").title(), value])
details = wb.create_sheet("Details")
details.append(["Path", "Value"])
for key, value in _analysis_flat_lines(run.get("result") or {}):
details.append([key, value])
stream = BytesIO()
wb.save(stream)
stream.seek(0)
return StreamingResponse(
stream,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename_base}.xlsx"'},
)
finally:
db.close()
@@ -621,7 +792,6 @@ def full_accounting_export_status(request: Request, client_id: int):
def depreciation_it_tool(
request: Request,
client_id: int | None = None,
tally_guid: str = "",
fy_start: str = "",
fy_end: str = "",
calculated: int = 0,
@@ -633,32 +803,100 @@ def depreciation_it_tool(
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.depreciation_it")
if response: return response
if response:
return response
clients, scope = _visible_clients(db, request, user)
selected_client = next((row for row in clients if client_id and int(row.id) == int(client_id)), None)
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
online = _node_online(node)
today = date.today(); start_year = today.year if today.month >= 4 else today.year - 1
start_text = fy_start or date(start_year,4,1).isoformat(); end_text = fy_end or date(start_year+1,3,31).isoformat()
live_result = None; preview = None; depreciation_run = None; command_error = error or ""
today = date.today()
start_year = today.year if today.month >= 4 else today.year - 1
start_text = fy_start or date(start_year, 4, 1).isoformat()
end_text = fy_end or date(start_year + 1, 3, 31).isoformat()
preview = None
depreciation_run = None
mirror_status = None
command_error = error or ""
analysis_history = []
if selected_client and node and online:
storage_payload = _accounting_storage_payload(
selected_client, _financial_year_for_date(date.fromisoformat(start_text))
)
try:
status_response = request_agent_command(node.node_code,"phase6_status",_accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))),timeout_seconds=20)
if status_response.get("ok"): live_result=status_response.get("result") or {}
else: command_error=str(status_response.get("error") or "Local Agent status failed.")
chosen_guid=str(tally_guid or "").strip()
if chosen_guid:
preview_response=request_agent_command(node.node_code,"accounting_depreciation_preview",{**_accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))),"tally_guid":chosen_guid,"fy_start":start_text,"fy_end":end_text},timeout_seconds=60)
if preview_response.get("ok"): preview=(preview_response.get("result") or {}).get("preview")
else: command_error=str(preview_response.get("error") or "Depreciation preview failed.")
status_response = request_agent_command(
node.node_code, "accounting_mirror_status", storage_payload, timeout_seconds=20
)
if status_response.get("ok"):
mirror_status = status_response.get("result") or {}
else:
command_error = str(status_response.get("error") or "Accounting Mirror status failed.")
if mirror_status and (mirror_status.get("mirror") or {}).get("ready"):
preview_response = request_agent_command(
node.node_code,
"accounting_depreciation_preview",
{
**storage_payload,
"fy_start": start_text,
"fy_end": end_text,
"requested_by_user_id": int(user.id),
},
timeout_seconds=60,
)
if preview_response.get("ok"):
preview = (preview_response.get("result") or {}).get("preview")
else:
command_error = str(preview_response.get("error") or "Depreciation preview failed.")
if run_id:
run_response=request_agent_command(node.node_code,"accounting_get_it_depreciation_run",{**_accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))),"run_id":int(run_id)},timeout_seconds=30)
if run_response.get("ok"): depreciation_run=(run_response.get("result") or {}).get("depreciation")
except Exception as exc: command_error=str(exc)
base={"request":request,"current_user":user,"current_user_roles":get_user_roles(db,user.id),"current_user_permissions":get_user_permissions(db,user.id),"csrf_token":get_or_create_csrf_token(request)}
base.update({"title":"Income-tax Depreciation","clients":clients,"selected_client":selected_client,"storage_node":node,"agent_online":online,"live_result":live_result,"preview":preview,"selected_tally_guid":str(tally_guid or ""),"fy_start":start_text,"fy_end":end_text,"calculated":bool(calculated),"approved":bool(approved),"posted":bool(posted),"depreciation_run":depreciation_run,"command_error":command_error})
run_response = request_agent_command(
node.node_code,
"accounting_get_it_depreciation_run",
{**storage_payload, "run_id": int(run_id)},
timeout_seconds=30,
)
if run_response.get("ok"):
depreciation_run = (run_response.get("result") or {}).get("depreciation")
hist = request_agent_command(
node.node_code,
"accounting_analysis_history",
{**storage_payload, "analysis_type": "DEPRECIATION_IT", "limit": 20},
timeout_seconds=20,
)
if hist.get("ok"):
analysis_history = (hist.get("result") or {}).get("runs") or []
except Exception as exc:
command_error = str(exc)
base = {
"request": request,
"current_user": user,
"current_user_roles": get_user_roles(db, user.id),
"current_user_permissions": get_user_permissions(db, user.id),
"csrf_token": get_or_create_csrf_token(request),
}
base.update({
"title": "Income-tax Depreciation",
"clients": clients,
"selected_client": selected_client,
"storage_node": node,
"agent_online": online,
"mirror_status": mirror_status,
"preview": preview,
"fy_start": start_text,
"fy_end": end_text,
"calculated": bool(calculated),
"approved": bool(approved),
"posted": bool(posted),
"depreciation_run": depreciation_run,
"analysis_history": analysis_history,
"command_error": command_error,
})
return templates.TemplateResponse("modules/accounting/templates/accounting/depreciation_it.html", base)
finally: db.close()
finally:
db.close()
@router.post("/depreciation/calculate")
@@ -673,7 +911,6 @@ async def calculate_it_depreciation(request: Request):
except Exception as exc: return RedirectResponse(url=f"/tools/tally/depreciation?error={quote('Invalid client or financial-year dates.')}",status_code=303)
client, _clients, scope = _find_visible_client(db, request, user, client_id)
if not client: return _denied()
tally_guid=str(form.get("tally_guid") or "").strip()
assignments=[]
for key,value in form.multi_items():
if not str(key).startswith("rate__"): continue
@@ -682,14 +919,14 @@ async def calculate_it_depreciation(request: Request):
except Exception: rate=-1
assignments.append({"ledger_key":ledger_key,"rate_percent":rate})
node=get_active_storage_node_for_branch(db,scope.tenant_id,scope.branch_id)
if not node or not _node_online(node): return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client.id}&tally_guid={quote(tally_guid)}&fy_start={start.isoformat()}&fy_end={end.isoformat()}&error={quote('ERP Local Agent is offline for the active branch.')}",status_code=303)
if not node or not _node_online(node): return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client.id}&fy_start={start.isoformat()}&fy_end={end.isoformat()}&error={quote('ERP Local Agent is offline for the active branch.')}",status_code=303)
try:
result=request_agent_command(node.node_code,"accounting_calculate_it_depreciation",{**_accounting_storage_payload(client, _financial_year_for_date(start)),"tally_guid":tally_guid,"fy_start":start.isoformat(),"fy_end":end.isoformat(),"assignments":assignments,"depreciation_expense_ledger":str(form.get("depreciation_expense_ledger") or ""),"depreciation_reserve_ledger":str(form.get("depreciation_reserve_ledger") or ""),"requested_by_user_id":int(user.id)},timeout_seconds=120)
result=request_agent_command(node.node_code,"accounting_calculate_it_depreciation",{**_accounting_storage_payload(client, _financial_year_for_date(start)),"fy_start":start.isoformat(),"fy_end":end.isoformat(),"assignments":assignments,"depreciation_expense_ledger":str(form.get("depreciation_expense_ledger") or ""),"depreciation_reserve_ledger":str(form.get("depreciation_reserve_ledger") or ""),"financial_year":_financial_year_for_date(start),"requested_by_user_id":int(user.id)},timeout_seconds=120)
if not result.get("ok"): raise RuntimeError(str(result.get("error") or "Income-tax depreciation calculation failed."))
dep=(result.get("result") or {}).get("depreciation") or {}; rid=int(dep.get("run_id"))
except Exception as exc:
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client.id}&tally_guid={quote(tally_guid)}&fy_start={start.isoformat()}&fy_end={end.isoformat()}&error={quote(str(exc))}",status_code=303)
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client.id}&tally_guid={quote(tally_guid)}&fy_start={start.isoformat()}&fy_end={end.isoformat()}&calculated=1&run_id={rid}",status_code=303)
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client.id}&fy_start={start.isoformat()}&fy_end={end.isoformat()}&error={quote(str(exc))}",status_code=303)
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client.id}&fy_start={start.isoformat()}&fy_end={end.isoformat()}&calculated=1&run_id={rid}",status_code=303)
finally: db.close()
@@ -723,7 +960,7 @@ async def approve_it_depreciation(request: Request):
if not result.get("ok"): raise RuntimeError(str(result.get("error") or "Approval failed."))
dep = (result.get("result") or {}).get("depreciation") or {}
guid = quote(str(dep.get("tally_guid") or ""))
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&tally_guid={guid}&fy_start={dep.get('fy_start','')}&fy_end={dep.get('fy_end','')}&run_id={run_id}&approved=1", status_code=303)
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&fy_start={dep.get('fy_start','')}&fy_end={dep.get('fy_end','')}&run_id={run_id}&approved=1", status_code=303)
except Exception as exc:
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&run_id={run_id}&error={quote(str(exc))}", status_code=303)
finally:
@@ -761,7 +998,7 @@ async def post_it_depreciation_to_tally(request: Request):
if not result.get("ok"): raise RuntimeError(str(result.get("error") or "Tally write-back failed."))
dep = (result.get("result") or {}).get("depreciation") or {}
guid = quote(str(dep.get("tally_guid") or ""))
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&tally_guid={guid}&fy_start={dep.get('fy_start','')}&fy_end={dep.get('fy_end','')}&run_id={run_id}&posted=1", status_code=303)
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&fy_start={dep.get('fy_start','')}&fy_end={dep.get('fy_end','')}&run_id={run_id}&posted=1", status_code=303)
except Exception as exc:
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&run_id={run_id}&error={quote(str(exc))}", status_code=303)
finally:
+1 -1
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path
import zipfile
ERP_LOCAL_AGENT_VERSION = "1.24.6"
ERP_LOCAL_AGENT_VERSION = "1.25.0"
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.6"
__version__ = "1.25.0"
AGENT_NAME = "ERP Local Agent"
@@ -178,6 +178,14 @@ class AgentCommandProcessor:
result = self._accounting_full_export_status(payload)
elif action == "accounting_mirror_query":
result = self._accounting_mirror_query(payload)
elif action == "accounting_analysis_save":
result = self._accounting_analysis_save(payload)
elif action == "accounting_analysis_history":
result = self._accounting_analysis_history(payload)
elif action == "accounting_analysis_get":
result = self._accounting_analysis_get(payload)
elif action == "accounting_opening_balance_mirror_snapshot":
result = self._opening_balance_mirror_snapshot(payload)
else:
raise ValueError(f"Unsupported local-agent command: {action}")
ok = True
@@ -205,6 +213,8 @@ class AgentCommandProcessor:
"full_accounting_export_capability": True,
"accounting_mirror_progress_capability": True,
"mirror_first_accounting_reads": True,
"accounting_analysis_history_capability": True,
"cross_fy_mirror_analysis_capability": True,
"historical_learning_read_capability": True,
"purchase_posting_preflight_capability": True,
"purchase_voucher_write_capability": True,
@@ -471,6 +481,274 @@ class AgentCommandProcessor:
def _ensure_analysis_tables(self, client_id: int) -> None:
db = self.store.connect(client_id)
try:
db.executescript(
"""
CREATE TABLE IF NOT EXISTS accounting_analysis_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
analysis_type TEXT NOT NULL,
financial_year TEXT NOT NULL DEFAULT '',
period_from TEXT NOT NULL DEFAULT '',
period_to TEXT NOT NULL DEFAULT '',
company_guid TEXT NOT NULL DEFAULT '',
company_name TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'completed',
requested_by_user_id INTEGER,
source_mirror_path TEXT NOT NULL DEFAULT '',
source_mirror_synced_at TEXT NOT NULL DEFAULT '',
parameters_json TEXT NOT NULL DEFAULT '{}',
summary_json TEXT NOT NULL DEFAULT '{}',
result_json TEXT NOT NULL DEFAULT '{}',
created_at_utc TEXT NOT NULL,
completed_at_utc TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_accounting_analysis_runs_type
ON accounting_analysis_runs(analysis_type, created_at_utc DESC);
"""
)
db.commit()
finally:
db.close()
def _save_analysis_run(
self,
client_id: int,
*,
analysis_type: str,
financial_year: str = "",
period_from: str = "",
period_to: str = "",
company_guid: str = "",
company_name: str = "",
requested_by_user_id: int | None = None,
source_mirror_path: str = "",
source_mirror_synced_at: str = "",
parameters: dict[str, Any] | None = None,
summary: dict[str, Any] | None = None,
result: dict[str, Any] | None = None,
) -> int:
self._ensure_analysis_tables(client_id)
now = datetime.now(timezone.utc).isoformat()
db = self.store.connect(client_id)
try:
cur = db.execute(
"""INSERT INTO accounting_analysis_runs(
analysis_type,financial_year,period_from,period_to,company_guid,company_name,
status,requested_by_user_id,source_mirror_path,source_mirror_synced_at,
parameters_json,summary_json,result_json,created_at_utc,completed_at_utc
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
str(analysis_type or "").strip(),
str(financial_year or "").strip(),
str(period_from or "").strip(),
str(period_to or "").strip(),
str(company_guid or "").strip(),
str(company_name or "").strip(),
"completed",
int(requested_by_user_id) if requested_by_user_id not in (None, "") else None,
str(source_mirror_path or "").strip(),
str(source_mirror_synced_at or "").strip(),
json.dumps(parameters or {}, ensure_ascii=False, default=str),
json.dumps(summary or {}, ensure_ascii=False, default=str),
json.dumps(result or {}, ensure_ascii=False, default=str),
now,
now,
),
)
db.commit()
return int(cur.lastrowid)
finally:
db.close()
def _accounting_analysis_save(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id") or 0)
if client_id <= 0:
raise ValueError("client_id is required.")
run_id = self._save_analysis_run(
client_id,
analysis_type=str(payload.get("analysis_type") or "ACCOUNTING_ANALYSIS"),
financial_year=str(payload.get("financial_year") or ""),
period_from=str(payload.get("period_from") or ""),
period_to=str(payload.get("period_to") or ""),
company_guid=str(payload.get("company_guid") or ""),
company_name=str(payload.get("company_name") or ""),
requested_by_user_id=payload.get("requested_by_user_id"),
source_mirror_path=str(payload.get("source_mirror_path") or ""),
source_mirror_synced_at=str(payload.get("source_mirror_synced_at") or ""),
parameters=dict(payload.get("parameters") or {}),
summary=dict(payload.get("summary") or {}),
result=dict(payload.get("result") or {}),
)
return {"analysis_run_id": run_id, "agent": self._agent_info()}
def _accounting_analysis_history(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id") or 0)
if client_id <= 0:
raise ValueError("client_id is required.")
self._ensure_analysis_tables(client_id)
limit = max(1, min(100, int(payload.get("limit") or 30)))
analysis_type = str(payload.get("analysis_type") or "").strip()
db = self.store.connect(client_id)
try:
if analysis_type:
rows = db.execute(
"""SELECT id,analysis_type,financial_year,period_from,period_to,company_name,
status,summary_json,created_at_utc,completed_at_utc
FROM accounting_analysis_runs
WHERE analysis_type=?
ORDER BY id DESC LIMIT ?""",
(analysis_type, limit),
).fetchall()
else:
rows = db.execute(
"""SELECT id,analysis_type,financial_year,period_from,period_to,company_name,
status,summary_json,created_at_utc,completed_at_utc
FROM accounting_analysis_runs
ORDER BY id DESC LIMIT ?""",
(limit,),
).fetchall()
result = []
for row in rows:
item = dict(row)
try:
item["summary"] = json.loads(item.pop("summary_json") or "{}")
except Exception:
item["summary"] = {}
result.append(item)
return {"runs": result, "agent": self._agent_info()}
finally:
db.close()
def _accounting_analysis_get(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id") or 0)
run_id = int(payload.get("run_id") or 0)
if client_id <= 0 or run_id <= 0:
raise ValueError("client_id and run_id are required.")
self._ensure_analysis_tables(client_id)
db = self.store.connect(client_id)
try:
row = db.execute(
"SELECT * FROM accounting_analysis_runs WHERE id=?",
(run_id,),
).fetchone()
if not row:
raise ValueError("Analysis run was not found in the client SQLite database.")
item = dict(row)
for field in ("parameters_json", "summary_json", "result_json"):
target = field[:-5]
try:
item[target] = json.loads(item.pop(field) or "{}")
except Exception:
item[target] = {}
return {"run": item, "agent": self._agent_info()}
finally:
db.close()
def _mirror_db_from_relative_dir(self, client_id: int, relative_dir: str):
from pathlib import Path as _Path
import os as _os
relative = _Path(str(relative_dir or "").replace("\\", "/"))
if relative.is_absolute() or ".." in relative.parts:
raise ValueError("Invalid Accounting Mirror storage path.")
base = self.store.storage_root.resolve()
folder = (base / relative).resolve()
if _os.path.commonpath([str(base), str(folder)]) != str(base):
raise ValueError("Accounting Mirror storage path escapes the Storage Node root.")
return folder / f"client_{int(client_id):08d}_mirror.act"
@staticmethod
def _mirror_master_snapshot_from_file(db_path, *, previous_year: bool = False):
import sqlite3 as _sqlite3
if not db_path.is_file():
raise ValueError(f"Accounting Mirror was not found: {db_path}")
db = _sqlite3.connect(db_path, timeout=60)
db.row_factory = _sqlite3.Row
try:
company = db.execute("SELECT * FROM company_master ORDER BY synced_at DESC LIMIT 1").fetchone()
ledgers = []
for row in db.execute("SELECT * FROM ledger_master ORDER BY ledger_name").fetchall():
ledgers.append({
"guid": row["ledger_guid"] or "",
"name": row["ledger_name"] or "",
"parent": row["parent_group"] or "",
"opening_balance": float(row["opening_balance"] or 0),
"closing_balance": float(row["closing_balance"] or 0),
"is_revenue": row["is_revenue"] or "",
"is_active": "Yes",
})
stocks = []
for row in db.execute("SELECT * FROM stock_item_master ORDER BY product_name").fetchall():
opening_qty = float(row["opening_qty"] or 0)
opening_value = float(row["opening_value"] or 0)
closing_qty = opening_qty
closing_value = opening_value
if previous_year:
movement = db.execute(
"""SELECT
COALESCE(SUM(CASE WHEN UPPER(direction)='INWARD' THEN ABS(quantity)
WHEN UPPER(direction)='OUTWARD' THEN -ABS(quantity) ELSE 0 END),0),
COALESCE(SUM(CASE WHEN UPPER(direction)='INWARD' THEN ABS(value)
WHEN UPPER(direction)='OUTWARD' THEN -ABS(value) ELSE 0 END),0)
FROM inventory_movement WHERE stock_item_guid=?""",
(row["stock_item_guid"],),
).fetchone()
closing_qty = opening_qty + float(movement[0] or 0)
closing_value = opening_value + float(movement[1] or 0)
stocks.append({
"guid": row["stock_item_guid"] or "",
"name": row["product_name"] or "",
"parent": row["parent_group"] or "",
"base_units": row["base_uom"] or "",
"opening_balance": opening_qty,
"opening_value": opening_value,
"closing_balance": closing_qty,
"closing_value": closing_value,
"hsn_code": row["current_hsn"] or "",
"is_active": "Yes",
})
company_dict = dict(company) if company else {}
return {
"company": {
"name": str(company_dict.get("company_name") or ""),
"guid": str(company_dict.get("company_guid") or ""),
"gstin": str(company_dict.get("gstin") or ""),
},
"masters": {"ledgers": ledgers, "stock_items": stocks},
"path": str(db_path),
"sync": company_dict,
}
finally:
db.close()
def _opening_balance_mirror_snapshot(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id") or 0)
previous_relative_dir = str(payload.get("previous_accounting_relative_dir") or "").strip()
current_relative_dir = str(payload.get("current_accounting_relative_dir") or payload.get("accounting_relative_dir") or "").strip()
if not previous_relative_dir or not current_relative_dir:
raise ValueError("Previous-year and current-year Accounting Mirror paths are required.")
previous_path = self._mirror_db_from_relative_dir(client_id, previous_relative_dir)
current_path = self._mirror_db_from_relative_dir(client_id, current_relative_dir)
previous = self._mirror_master_snapshot_from_file(previous_path, previous_year=True)
current = self._mirror_master_snapshot_from_file(current_path, previous_year=False)
if previous["company"].get("guid") and current["company"].get("guid") and previous["company"]["guid"] != current["company"]["guid"]:
self.logger.info(
"Opening Balance cross-FY mirror company GUID differs previous=%s current=%s; continuing because FY-split Tally companies are supported.",
previous["company"]["guid"], current["company"]["guid"],
)
return {
"previous_company": previous["company"],
"current_company": current["company"],
"previous_masters": previous["masters"],
"current_masters": current["masters"],
"previous_mirror_path": previous["path"],
"current_mirror_path": current["path"],
"read_only": True,
"agent": self._agent_info(),
}
def _opening_balance_company(self, company_name: str):
wanted=str(company_name or "").strip()
if not wanted: raise ValueError("Tally company name is required.")
@@ -2391,8 +2669,7 @@ class AgentCommandProcessor:
exceptions.sort(key=lambda x: (x["date"], x["party"].casefold()))
split_patterns.sort(key=lambda x: (x["date_from"], x["party"].casefold()))
return {
"cash_payment_review": {
review = {
"company_name": company_name,
"company_guid": company_guid,
"date_from": date_from,
@@ -2418,10 +2695,24 @@ class AgentCommandProcessor:
"tally_retry_count": 0,
"tally_pause_ms": 0,
"read_only": True,
},
"mirror": mirror,
"agent": self._agent_info(),
}
analysis_run_id = self._save_analysis_run(
client_id,
analysis_type="CASH_PAYMENT_COMPLIANCE",
financial_year=str(payload.get("financial_year") or ""),
period_from=date_from,
period_to=date_to,
company_guid=company_guid,
company_name=company_name,
requested_by_user_id=payload.get("requested_by_user_id"),
source_mirror_path=str(mirror_status.get("mirror_db_path") or mirror.get("path") or ""),
source_mirror_synced_at=str((mirror.get("sync") or {}).get("last_sync_at") or ""),
parameters={"cash_limit": cash_limit, "split_window_days": split_window_days, "near_limit_percent": near_limit_percent},
summary=review.get("summary") or {},
result=review,
)
review["analysis_run_id"] = analysis_run_id
return {"cash_payment_review": review, "mirror": mirror, "agent": self._agent_info()}
def _cash_payment_cache_analyze(self, payload: dict[str, Any]) -> dict[str, Any]:
from collections import defaultdict
@@ -2832,15 +3123,24 @@ class AgentCommandProcessor:
from collections import defaultdict
from datetime import date as _date
company, company_name = self._resolve_open_company(payload)
client_id = int(payload.get("client_id") or 0)
if client_id <= 0:
raise ValueError("client_id is required.")
mirror_status = self.tally.mirror.status(client_id)
mirror = mirror_status.get("mirror") or {}
if not mirror.get("ready"):
raise ValueError("Accounting Mirror is not ready. Run 'Mirror Tally to SQLite' first.")
company_info = mirror.get("company") or {}
company_name = str(company_info.get("company_name") or "").strip()
company_guid = str(company_info.get("company_guid") or "").strip()
date_from = str(payload.get("date_from") or "").strip()
date_to = str(payload.get("date_to") or "").strip()
rules = list(payload.get("rules") or [])
if not rules:
raise ValueError("No active TDS rules were supplied by ERP.")
vouchers = self.tally.export_vouchers(company_name, date_from, date_to)
# TDS ledgers are identified by both ledger name and parent/group text; this is deliberately broader than one fixed ledger name.
masters = self.tally.export_master_collection(company_name, "ledgers")
vouchers = (self.tally.mirror.transactions(client_id, company_name, date_from, date_to, company_guid).get("vouchers") or [])
# TDS ledgers are identified from the local mirror ledger master only.
masters = (self.tally.mirror.master_snapshot(client_id, company_name, company_guid).get("ledgers") or [])
tds_ledger_names = set()
for led in masters:
name = str(led.get("name") or "").strip()
@@ -2894,7 +3194,24 @@ class AgentCommandProcessor:
liability=round(max(0.0,expected-actual),2)
status="OK" if expected>0 and liability<=0.009 else ("TDS NOT DEDUCTED" if expected>0 and actual<=0.009 else ("SHORT DEDUCTION" if liability>0 else "BELOW / OUTSIDE THRESHOLD"))
transactions.append({**{k:v for k,v in x.items() if k!='rule'},"rule_id":r.get("id"),"rule_code":r.get("rule_code"),"rule_name":r.get("name"),"legacy_section":r.get("legacy_section"),"statutory_reference":r.get("statutory_reference"),"cumulative_amount":cumulative,"expected_tds":expected,"actual_tds":actual,"liability":liability,"status":status})
return {"tds_review":{"company_name":company_name,"company_guid":str(company.get("guid") or ""),"date_from":date_from,"date_to":date_to,"summary":{"vouchers_reviewed":len(vouchers),"candidate_transactions":len(transactions),"tds_not_deducted":sum(1 for x in transactions if x["status"]=="TDS NOT DEDUCTED"),"short_deduction":sum(1 for x in transactions if x["status"]=="SHORT DEDUCTION"),"purchase_transactions":sum(1 for x in transactions if x["rule_code"]=="PURCHASE_GOODS")},"transactions":transactions,"tds_ledgers":sorted(tds_ledger_names),"read_only":True},"agent":self._agent_info()}
review = {"company_name":company_name,"company_guid":company_guid,"date_from":date_from,"date_to":date_to,"summary":{"vouchers_reviewed":len(vouchers),"candidate_transactions":len(transactions),"tds_not_deducted":sum(1 for x in transactions if x["status"]=="TDS NOT DEDUCTED"),"short_deduction":sum(1 for x in transactions if x["status"]=="SHORT DEDUCTION"),"purchase_transactions":sum(1 for x in transactions if x["rule_code"]=="PURCHASE_GOODS")},"transactions":transactions,"tds_ledgers":sorted(tds_ledger_names),"read_only":True,"sqlite_mirror":True}
analysis_run_id = self._save_analysis_run(
client_id,
analysis_type="TDS_COMPLIANCE",
financial_year=str(payload.get("financial_year") or ""),
period_from=date_from,
period_to=date_to,
company_guid=company_guid,
company_name=company_name,
requested_by_user_id=payload.get("requested_by_user_id"),
source_mirror_path=str(mirror_status.get("mirror_db_path") or mirror.get("path") or ""),
source_mirror_synced_at=str((mirror.get("sync") or {}).get("last_sync_at") or ""),
parameters={"rule_count": len(rules)},
summary=review["summary"],
result=review,
)
review["analysis_run_id"] = analysis_run_id
return {"tds_review":review,"mirror":mirror,"agent":self._agent_info()}
def _post_tds_liability(self, payload: dict[str, Any]) -> dict[str, Any]:
company, company_name = self._resolve_open_company(payload)
@@ -3045,29 +3362,68 @@ class AgentCommandProcessor:
"agent": self._agent_info(),
}
def _depreciation_preview(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id"))
tally_guid = str(payload.get("tally_guid") or "").strip()
def _depreciation_mirror_context(self, payload: dict[str, Any]):
client_id = int(payload.get("client_id") or 0)
if client_id <= 0:
raise ValueError("client_id is required.")
mirror_status = self.tally.mirror.status(client_id)
mirror = mirror_status.get("mirror") or {}
if not mirror.get("ready"):
raise ValueError("Accounting Mirror is not ready for this financial year. Run 'Mirror Tally to SQLite' first.")
company = mirror.get("company") or {}
company_name = str(company.get("company_name") or "").strip()
company_guid = str(company.get("company_guid") or "").strip()
if not company_name:
raise ValueError("Accounting Mirror does not contain a company identity.")
mapping = {}
if company_guid:
try:
mapping = self.store.get_active_mapping_by_guid(client_id, company_guid)
except Exception:
mapping = {}
if not mapping:
mappings = self.store.list_active_mappings(client_id)
mapping = (mappings[0] if mappings else {
"tally_guid": company_guid,
"company_name": company_name,
"gstin": str(company.get("gstin") or ""),
})
return client_id, mirror_status, mirror, company_name, company_guid, mapping
def _depreciation_refresh_local_snapshot(self, payload: dict[str, Any]):
client_id, mirror_status, mirror, company_name, company_guid, mapping = self._depreciation_mirror_context(payload)
fy_start = str(payload.get("fy_start") or "").strip()
fy_end = str(payload.get("fy_end") or "").strip()
if not tally_guid: raise ValueError("Select a mapped Tally company for depreciation.")
sync_payload = dict(payload)
sync_payload["date_from"] = fy_start
sync_payload["date_to"] = fy_end
self._sync_masters(sync_payload)
self._sync_transactions(sync_payload)
return {"preview": self.store.depreciation_preview(client_id, tally_guid=tally_guid, fy_start=fy_start, fy_end=fy_end), "agent": self._agent_info()}
masters = self.tally.mirror.master_snapshot(client_id, company_name, company_guid)
transactions = self.tally.mirror.transactions(client_id, company_name, fy_start, fy_end, company_guid)
self.store.replace_master_snapshot(
client_id,
mapping={**mapping, "company_name": company_name, "tally_guid": company_guid},
masters=masters,
requested_by_user_id=int(payload.get("requested_by_user_id")) if payload.get("requested_by_user_id") not in (None, "") else None,
)
self.store.replace_transaction_snapshot(
client_id,
mapping={**mapping, "company_name": company_name, "tally_guid": company_guid},
transactions=transactions,
requested_by_user_id=int(payload.get("requested_by_user_id")) if payload.get("requested_by_user_id") not in (None, "") else None,
)
return client_id, mirror_status, mirror, company_name, company_guid
def _depreciation_preview(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id, mirror_status, mirror, company_name, company_guid = self._depreciation_refresh_local_snapshot(payload)
fy_start = str(payload.get("fy_start") or "").strip()
fy_end = str(payload.get("fy_end") or "").strip()
preview = self.store.depreciation_preview(
client_id, tally_guid=company_guid, fy_start=fy_start, fy_end=fy_end
)
return {"preview": preview, "mirror": mirror, "agent": self._agent_info()}
def _calculate_it_depreciation(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id"))
sync_payload = dict(payload)
sync_payload["date_from"] = str(payload.get("fy_start") or "").strip()
sync_payload["date_to"] = str(payload.get("fy_end") or "").strip()
self._sync_masters(sync_payload)
self._sync_transactions(sync_payload)
client_id, mirror_status, mirror, company_name, company_guid = self._depreciation_refresh_local_snapshot(payload)
result = self.store.calculate_it_depreciation(
client_id,
tally_guid=str(payload.get("tally_guid") or "").strip(),
tally_guid=company_guid,
fy_start=str(payload.get("fy_start") or "").strip(),
fy_end=str(payload.get("fy_end") or "").strip(),
assignments=list(payload.get("assignments") or []),
@@ -3075,8 +3431,31 @@ class AgentCommandProcessor:
depreciation_reserve_ledger=str(payload.get("depreciation_reserve_ledger") or "").strip(),
requested_by_user_id=int(payload.get("requested_by_user_id")) if payload.get("requested_by_user_id") not in (None, "") else None,
)
self.logger.info("IT depreciation draft calculated client_id=%s company=%s run_id=%s total=%s", client_id, result.get("company_name"), result.get("run_id"), result.get("total_depreciation"))
return {"calculated": True, "depreciation": result, "accounting": self.store.snapshot(client_id), "agent": self._agent_info()}
analysis_run_id = self._save_analysis_run(
client_id,
analysis_type="DEPRECIATION_IT",
financial_year=str(payload.get("financial_year") or ""),
period_from=str(payload.get("fy_start") or ""),
period_to=str(payload.get("fy_end") or ""),
company_guid=company_guid,
company_name=company_name,
requested_by_user_id=payload.get("requested_by_user_id"),
source_mirror_path=str(mirror_status.get("mirror_db_path") or mirror.get("path") or ""),
source_mirror_synced_at=str((mirror.get("sync") or {}).get("last_sync_at") or ""),
parameters={
"depreciation_expense_ledger": str(payload.get("depreciation_expense_ledger") or ""),
"depreciation_reserve_ledger": str(payload.get("depreciation_reserve_ledger") or ""),
},
summary={
"total_depreciation": result.get("total_depreciation"),
"line_count": len(result.get("lines") or []),
"status": result.get("status"),
},
result=result,
)
result["analysis_run_id"] = analysis_run_id
self.logger.info("IT depreciation draft calculated from SQLite mirror client_id=%s company=%s run_id=%s total=%s", client_id, result.get("company_name"), result.get("run_id"), result.get("total_depreciation"))
return {"calculated": True, "depreciation": result, "mirror": mirror, "accounting": self.store.snapshot(client_id), "agent": self._agent_info()}
def _get_it_depreciation_run(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id")); run_id = int(payload.get("run_id"))