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,7 +173,13 @@
{% set active_mappings = (accounting.mappings or []) | selectattr('is_active') | list %}
{% if active_mappings %}
{% set current_mapping = active_mappings[0] %}
<details class="rounded-2xl border border-slate-200 bg-white shadow-soft">
{% 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">
<div>
@@ -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 %}
+265 -28
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})
return templates.TemplateResponse("modules/accounting/templates/accounting/depreciation_it.html",base)
finally: db.close()
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()
@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: