From 7adbbde220f422ae47405dc8b64cda05f42515ba Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Sun, 6 Sep 2026 20:13:51 +0530 Subject: [PATCH] Unify accounting analysis on local SQLite mirror --- app/modules/accounting/opening_balance_ui.py | 112 ++++- app/modules/accounting/tds_ui.py | 42 +- .../accounting/cash_payment_review.html | 7 +- .../templates/accounting/depreciation_it.html | 35 +- .../accounting/opening_balances.html | 31 +- .../templates/accounting/tally.html | 12 +- .../templates/accounting/tds_compliance.html | 2 +- app/modules/accounting/ui.py | 293 ++++++++++-- app/modules/documents/agent_package.py | 2 +- .../erp_local_agent/__init__.py | 2 +- .../erp_local_agent/commands.py | 439 ++++++++++++++++-- 11 files changed, 848 insertions(+), 129 deletions(-) diff --git a/app/modules/accounting/opening_balance_ui.py b/app/modules/accounting/opening_balance_ui.py index 3c7561e..e1f5c88 100644 --- a/app/modules/accounting/opening_balance_ui.py +++ b/app/modules/accounting/opening_balance_ui.py @@ -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)." diff --git a/app/modules/accounting/tds_ui.py b/app/modules/accounting/tds_ui.py index 46eb5ec..f0edc00 100644 --- a/app/modules/accounting/tds_ui.py +++ b/app/modules/accounting/tds_ui.py @@ -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 "")) diff --git a/app/modules/accounting/templates/accounting/cash_payment_review.html b/app/modules/accounting/templates/accounting/cash_payment_review.html index b4bc038..c4a759e 100644 --- a/app/modules/accounting/templates/accounting/cash_payment_review.html +++ b/app/modules/accounting/templates/accounting/cash_payment_review.html @@ -91,10 +91,9 @@ {% if review %}
-
- Analysis completed entirely from the local SQLite Accounting Mirror. TallyPrime was not contacted. - {% if review.cash_ledgers %} Cash ledger(s): {{ review.cash_ledgers|join(', ') }}.{% endif %} - {% if review.mirror_period %} Mirror period: {{ review.mirror_period.from_date or '—' }} → {{ review.mirror_period.to_date or '—' }}.{% endif %} +
{% 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)] %} diff --git a/app/modules/accounting/templates/accounting/depreciation_it.html b/app/modules/accounting/templates/accounting/depreciation_it.html index 85f7a48..e83064c 100644 --- a/app/modules/accounting/templates/accounting/depreciation_it.html +++ b/app/modules/accounting/templates/accounting/depreciation_it.html @@ -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 {} %}
-

Tools · Tally · Phase 6

+

Tools · Accounting

Depreciation (Income Tax)

-

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.

+

Calculate Income-tax depreciation from the client SQLite Accounting Mirror. Approved drafts retain the existing controlled Tally write-back.

Back to Tally
@@ -19,15 +17,14 @@ {% if command_error %}
{{ command_error }}
{% endif %}
-
ERP Local Agent
{{ 'Connected' if agent_online else 'Offline' }}
Agent {{ agent.version if agent and agent.version else '-' }}
-
TallyPrime
{{ 'Connected' if tally and tally.connected else 'Not connected' }}
{{ tally.company_count if tally else 0 }} currently loaded
-
Phase 6
{{ 'Available' if agent and agent.tally_writeback_capability else 'Update Agent' }}
Controlled approved write-back
+
ERP Local Agent
{{ 'Connected' if agent_online else 'Offline' }}
+
SQLite Mirror
{{ 'Ready' if mirror.ready else 'Mirror required' }}
{{ mirror.company.company_name if mirror.ready and mirror.company else '' }}
+
Write-back
Controlled
Tally is contacted only after approval/posting.
-
+
-
@@ -37,14 +34,14 @@ {% set coverage = preview.transaction_coverage %}
{% 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 %}
- +
@@ -82,6 +79,12 @@ {% endif %} {% if depreciation_run %} + {% if depreciation_run.analysis_run_id %} + + {% endif %}

Draft Calculation #{{ depreciation_run.id }}

{{ depreciation_run.company_name }} · {{ depreciation_run.fy_start }} to {{ depreciation_run.fy_end }}

Total Depreciation
₹ {{ '%.2f'|format(depreciation_run.total_depreciation) }}
Journal: Debit {{ depreciation_run.depreciation_expense_ledger or 'Depreciation' }} and credit {{ depreciation_run.depreciation_reserve_ledger or 'Depreciation Reserve' }} for ₹ {{ '%.2f'|format(depreciation_run.total_depreciation) }}. Voucher date will be {{ depreciation_run.fy_end }} and reference ARRR-ITDEP-{{ depreciation_run.id }}.
@@ -148,4 +151,10 @@ })(); + {% if analysis_history %} +
+

Depreciation Analysis History

+
{% for row in analysis_history %}{% endfor %}
RunFYPeriodCompleted
#{{ row.id }}{{ row.financial_year }}{{ row.period_from }} → {{ row.period_to }}{{ row.completed_at_utc }}Excel · PDF
+
+ {% endif %} {% endblock %} diff --git a/app/modules/accounting/templates/accounting/opening_balances.html b/app/modules/accounting/templates/accounting/opening_balances.html index 299f0eb..c22979d 100644 --- a/app/modules/accounting/templates/accounting/opening_balances.html +++ b/app/modules/accounting/templates/accounting/opening_balances.html @@ -22,33 +22,29 @@
-
+ {% if selected_client %} -
+ {% 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 {} %} +
+
Previous FY Mirror · {{ previous_fy }}
{{ 'Ready' if prev.ready else 'Mirror required' }}
{% if prev.ready %}
{{ prev.company.company_name }} · {{ prev.counts.voucher }} vouchers
{% endif %}
+
Current FY Mirror · {{ current_fy }}
{{ 'Ready' if curr.ready else 'Mirror required' }}
{% if curr.ready %}
{{ curr.company.company_name }} · {{ curr.counts.voucher }} vouchers
{% endif %}
+
+ - - -
+ + +
{% endif %}
@@ -77,8 +73,9 @@ {% endif %} {% if selected_run %} -
+
Download Excel Report + {% if analysis_run_id %}Download PDF{% endif %}
diff --git a/app/modules/accounting/templates/accounting/tally.html b/app/modules/accounting/templates/accounting/tally.html index c4cf185..eccc48f 100644 --- a/app/modules/accounting/templates/accounting/tally.html +++ b/app/modules/accounting/templates/accounting/tally.html @@ -48,6 +48,10 @@ Depreciation (IT) Cash Payment Compliance Cash Payment Entry Allocation + TDS Compliance Review + TDS Rules + TDS Liability + TDS Reconciliation Accounting AI Internal Model
@@ -169,7 +173,13 @@ {% 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 %} +
+

Recent Accounting Analysis

Stored in the client local SQLite accounting database.

+
{% for row in analysis_history %}{% endfor %}
RunAnalysisFY / PeriodCompleted
#{{ row.id }}{{ row.analysis_type|replace('_',' ')|title }}{{ row.financial_year }} · {{ row.period_from }} → {{ row.period_to }}{{ row.completed_at_utc }}Excel · PDF
+
+ {% endif %} +
diff --git a/app/modules/accounting/templates/accounting/tds_compliance.html b/app/modules/accounting/templates/accounting/tds_compliance.html index 5865371..ae00337 100644 --- a/app/modules/accounting/templates/accounting/tds_compliance.html +++ b/app/modules/accounting/templates/accounting/tds_compliance.html @@ -1,5 +1,5 @@ {% extends "ui/templates/base/layout.html" %} {% block content %}
-

TDS & Withholding Tax

TDS Compliance Review

Read-only Tally scan across expenses, purchases, capital/service purchases, journals and creditor postings. Rules and thresholds come from the editable TDS Rule Master.

{% if error %}
{{ error }}
{% endif %}
{% if review %}
{% for k,v in review.summary.items() %}
{{ k|replace('_',' ') }}
{{ v }}
{% endfor %}
{% for x in review.transactions %}{% endfor %}
DatePartyLedger / RuleBaseExpectedActualLiabilityStatus
{{ x.date }}{{ x.party }}{{ x.nature_ledger }}
{{ x.rule_name }} · {{ x.legacy_section }}
{{ '%.2f'|format(x.taxable_base) }}{{ '%.2f'|format(x.expected_tds) }}{{ '%.2f'|format(x.actual_tds) }}{{ '%.2f'|format(x.liability) }}{{ x.status }}
{% endif %}
+

TDS & Withholding Tax

TDS Compliance Review

Review expenses, purchases, journals and creditor postings from the client SQLite Accounting Mirror. Rules and thresholds come from the editable TDS Rule Master.

{% if error %}
{{ error }}
{% endif %}
{% if review %}
{% for k,v in review.summary.items() %}
{{ k|replace('_',' ') }}
{{ v }}
{% endfor %}
{% for x in review.transactions %}{% endfor %}
DatePartyLedger / RuleBaseExpectedActualLiabilityStatus
{{ x.date }}{{ x.party }}{{ x.nature_ledger }}
{{ x.rule_name }} · {{ x.legacy_section }}
{{ '%.2f'|format(x.taxable_base) }}{{ '%.2f'|format(x.expected_tds) }}{{ '%.2f'|format(x.actual_tds) }}{{ '%.2f'|format(x.liability) }}{{ x.status }}
{% endif %}{% if history %}

TDS Analysis History

{% for row in history %}{% endfor %}
#{{ row.id }}{{ row.financial_year }}{{ row.period_from }} → {{ row.period_to }}Excel · PDF
{% endif %}
{% endblock %} diff --git a/app/modules/accounting/ui.py b/app/modules/accounting/ui.py index a68958a..8b53d09 100644 --- a/app/modules/accounting/ui.py +++ b/app/modules/accounting/ui.py @@ -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: diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index d567fdb..ee72c0e 100644 --- a/app/modules/documents/agent_package.py +++ b/app/modules/documents/agent_package.py @@ -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) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index d55c3c4..cb2b376 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py @@ -1,2 +1,2 @@ -__version__ = "1.24.6" +__version__ = "1.25.0" AGENT_NAME = "ERP Local Agent" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py index 220793d..5467876 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py @@ -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"))