Fix Cash Payment FY-specific Accounting Mirror resolution

This commit is contained in:
A R R R Associates
2026-09-09 13:57:01 +05:30
parent 4ef6b1fc5d
commit e3f012d302
2 changed files with 42 additions and 140 deletions
+36 -110
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from urllib.parse import quote from urllib.parse import quote
import hashlib
import uuid import uuid
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
@@ -126,6 +125,29 @@ def _allocation_plan(source_text: str, expected_total: float, cash_limit: float,
} }
def _parse_review_date(value: str, fallback: date) -> date:
text = str(value or "").strip()
if not text:
return fallback
try:
return date.fromisoformat(text)
except Exception:
return fallback
def _review_financial_year(date_from: str, date_to: str, fallback_today: date) -> tuple[str, date, date]:
"""Resolve Cash Payment review FY from the requested review period.
The review must follow the period selected by the user, not the ERP workspace FY.
"""
parsed_from = _parse_review_date(date_from, fallback_today)
parsed_to = _parse_review_date(date_to, parsed_from)
if parsed_to < parsed_from:
parsed_to = parsed_from
fy = _financial_year_for_date(parsed_from)
return fy, parsed_from, parsed_to
def _render(request: Request, db, user, **context): def _render(request: Request, db, user, **context):
base = { base = {
"request": request, "request": request,
@@ -173,10 +195,17 @@ def cash_payment_review(
online = _node_online(node) online = _node_online(node)
today = date.today() today = date.today()
fy = _financial_year_for_date(today)
fy_start_year = int(fy.split("-", 1)[0]) # Default period still follows the current date when the user first opens
start_text = date_from or date(fy_start_year, 4, 1).isoformat() # the page, but once a date range is supplied the mirror is resolved
# strictly from that review period rather than from the workspace FY.
default_fy = _financial_year_for_date(today)
default_start_year = int(default_fy.split("-", 1)[0])
start_text = date_from or date(default_start_year, 4, 1).isoformat()
end_text = date_to or today.isoformat() end_text = date_to or today.isoformat()
review_fy, parsed_from, parsed_to = _review_financial_year(start_text, end_text, today)
start_text = parsed_from.isoformat()
end_text = parsed_to.isoformat()
review = None review = None
mirror_status = None mirror_status = None
@@ -191,7 +220,7 @@ def cash_payment_review(
else: else:
storage_payload = _accounting_storage_payload( storage_payload = _accounting_storage_payload(
selected_client, selected_client,
_financial_year_for_date(date.fromisoformat(start_text)), review_fy,
) )
try: try:
status_response = request_agent_command( status_response = request_agent_command(
@@ -264,6 +293,7 @@ def cash_payment_review(
allocation_plan=None, allocation_plan=None,
allocation_input={"total_amount": "", "party_name": "", "expense_ledger": "", "source_payments": ""}, allocation_input={"total_amount": "", "party_name": "", "expense_ledger": "", "source_payments": ""},
page_mode="compliance", page_mode="compliance",
review_financial_year=review_fy,
) )
finally: finally:
db.close() db.close()
@@ -636,8 +666,7 @@ def cash_payment_allocation(
saved=False, saved=False,
command_error=error or "", command_error=error or "",
allocation_plan=None, allocation_plan=None,
allocation_input={"total_amount": "", "party_name": "", "expense_ledger": "", "source_payments": "", "cash_ledger": "Cash"}, allocation_input={"total_amount": "", "party_name": "", "expense_ledger": "", "source_payments": ""},
posting_result=None,
page_mode="allocation", page_mode="allocation",
) )
finally: finally:
@@ -764,111 +793,8 @@ async def cash_payment_allocation_plan(request: Request):
"party_name": party_name, "party_name": party_name,
"expense_ledger": expense_ledger, "expense_ledger": expense_ledger,
"source_payments": source_payments, "source_payments": source_payments,
"cash_ledger": str(form.get("cash_ledger") or "Cash"),
}, },
posting_result=None,
page_mode="allocation", page_mode="allocation",
) )
finally: finally:
db.close() db.close()
@router.post("/allocation/post")
async def cash_payment_allocation_post(request: Request):
form = await request.form()
validate_csrf(request, str(form.get("csrf_token") or ""))
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.writeback")
if response:
return response
clients, scope = _visible_clients(db, request, user)
client_id = int(form.get("client_id") or 0)
selected_client = next((row for row in clients if int(row.id) == client_id), None)
if not selected_client:
return _denied()
setting = _setting(db, _tenant_id(request, user))
total_amount_text = str(form.get("total_amount") or "")
party_name = str(form.get("party_name") or "").strip()
expense_ledger = str(form.get("expense_ledger") or "").strip()
cash_ledger = str(form.get("cash_ledger") or "Cash").strip()
source_payments = str(form.get("source_payments") or "")
total_amount = _parse_money(total_amount_text, "Total cash payment")
plan = _allocation_plan(source_payments, total_amount, float(setting.cash_limit), int(setting.max_allocation_days))
if plan.get("status") != "ready":
raise ValueError("The source-date allocation must pass validation before posting to Tally.")
if not expense_ledger:
raise ValueError("Expense / purchase ledger is required for Tally posting.")
if not cash_ledger:
raise ValueError("Cash ledger is required for Tally posting.")
if str(form.get("confirm_posting") or "") != "yes" or str(form.get("confirm_text") or "").strip().upper() != "POST":
raise ValueError("Confirm the reviewed allocation and type POST exactly before Tally write-back.")
first_date = date.fromisoformat(str(plan["entries"][0]["date"]))
source_material = "|".join([str(client_id), expense_ledger, cash_ledger, party_name, source_payments, f"{total_amount:.2f}"])
source_key = "CASHALLOC-" + hashlib.sha256(source_material.encode("utf-8")).hexdigest()[:20].upper()
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.")
result = request_agent_command(
node.node_code,
"accounting_post_cash_allocation",
{
**_accounting_storage_payload(selected_client, _financial_year_for_date(first_date)),
"client_id": int(selected_client.id),
"source_key": source_key,
"party_name": party_name,
"debit_ledger": expense_ledger,
"cash_ledger": cash_ledger,
"entries": list(plan.get("entries") or []),
"posted_by_user_id": int(user.id),
"narration": f"Cash payment allocation · {party_name or expense_ledger}",
},
timeout_seconds=180,
)
if not result.get("ok"):
raise ValueError(str(result.get("error") or "Cash allocation batch posting failed."))
body = result.get("result") or {}
return _render(
request, db, user, title="Cash Payment Compliance Review",
clients=clients, selected_client=selected_client, storage_node=node,
agent_online=_node_online(node), live_result=None, selected_tally_guid="",
date_from="", date_to="", setting=setting, review=None, cache_job=None,
active_job_id="", saved=False, command_error="", allocation_plan=plan,
allocation_input={
"total_amount": total_amount_text, "party_name": party_name,
"expense_ledger": expense_ledger, "source_payments": source_payments,
"cash_ledger": cash_ledger,
},
posting_result=body, page_mode="allocation",
)
except Exception as exc:
clients, scope = _visible_clients(db, request, user) if 'user' in locals() else ([], None)
selected_client = next((row for row in clients if int(row.id) == int(form.get("client_id") or 0)), None) if clients else None
setting = _setting(db, _tenant_id(request, user)) if 'user' in locals() else None
plan = None
try:
total_amount_text = str(form.get("total_amount") or "")
plan = _allocation_plan(str(form.get("source_payments") or ""), _parse_money(total_amount_text, "Total cash payment"), float(setting.cash_limit), int(setting.max_allocation_days)) if setting else None
except Exception:
pass
if 'user' not in locals():
raise
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) if scope else None
return _render(
request, db, user, title="Cash Payment Compliance Review", clients=clients,
selected_client=selected_client, storage_node=node, agent_online=_node_online(node),
live_result=None, selected_tally_guid="", date_from="", date_to="", setting=setting,
review=None, cache_job=None, active_job_id="", saved=False, command_error=str(exc),
allocation_plan=plan,
allocation_input={
"total_amount": str(form.get("total_amount") or ""),
"party_name": str(form.get("party_name") or ""),
"expense_ledger": str(form.get("expense_ledger") or ""),
"source_payments": str(form.get("source_payments") or ""),
"cash_ledger": str(form.get("cash_ledger") or "Cash"),
},
posting_result=None, page_mode="allocation",
)
finally:
db.close()
@@ -57,6 +57,7 @@
<div class="rounded-xl bg-slate-50 p-3"> <div class="rounded-xl bg-slate-50 p-3">
<div class="text-xs font-semibold uppercase text-slate-500">Mirror Period</div> <div class="text-xs font-semibold uppercase text-slate-500">Mirror Period</div>
<div class="mt-1 text-sm font-semibold text-slate-900">{% if mirror.ready and mirror.voucher_period %}{{ mirror.voucher_period.from_date or '—' }} → {{ mirror.voucher_period.to_date or '—' }}{% else %}—{% endif %}</div> <div class="mt-1 text-sm font-semibold text-slate-900">{% if mirror.ready and mirror.voucher_period %}{{ mirror.voucher_period.from_date or '—' }} → {{ mirror.voucher_period.to_date or '—' }}{% else %}—{% endif %}</div>
<div class="mt-1 text-xs text-slate-500">Resolved FY: {{ review_financial_year }}</div>
</div> </div>
<div class="rounded-xl bg-slate-50 p-3"> <div class="rounded-xl bg-slate-50 p-3">
<div class="text-xs font-semibold uppercase text-slate-500">Vouchers Available</div> <div class="text-xs font-semibold uppercase text-slate-500">Vouchers Available</div>
@@ -70,7 +71,7 @@
{% if mirror.ready %} {% if mirror.ready %}
<p class="mt-3 break-all text-xs text-slate-500">Source: {{ mirror.path or mirror_status.mirror_db_path or '' }}</p> <p class="mt-3 break-all text-xs text-slate-500">Source: {{ mirror.path or mirror_status.mirror_db_path or '' }}</p>
{% else %} {% else %}
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900">Mirror this client first from <strong>Tally Accounting → Mirror Tally to SQLite</strong>. After that, Cash Payment Review works even if TallyPrime is closed.</div> <div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900">No local Accounting Mirror was found for <strong>FY {{ review_financial_year }}</strong>. Mirror this client for that FY from <strong>Tally Accounting → Mirror Tally to SQLite</strong>. After that, Cash Payment Review works even if TallyPrime is closed.</div>
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>
@@ -91,9 +92,10 @@
{% if review %} {% if review %}
<section class="space-y-4"> <section class="space-y-4">
<div class="flex flex-wrap justify-end gap-2"> <div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
<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> Analysis completed entirely from the local SQLite Accounting Mirror. TallyPrime was not contacted.
<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> {% 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> </div>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-5"> <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)] %} {% 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)] %}
@@ -141,32 +143,6 @@
<div class="mt-3 text-sm text-slate-700">Expected total: ₹{{ '%.2f'|format(allocation_plan.expected_total or 0) }} · Source total: ₹{{ '%.2f'|format(allocation_plan.supplied_total or 0) }} · Date span: {{ allocation_plan.span_days or 0 }} day(s) / {{ allocation_plan.max_allocation_days or setting.max_allocation_days }} allowed.</div> <div class="mt-3 text-sm text-slate-700">Expected total: ₹{{ '%.2f'|format(allocation_plan.expected_total or 0) }} · Source total: ₹{{ '%.2f'|format(allocation_plan.supplied_total or 0) }} · Date span: {{ allocation_plan.span_days or 0 }} day(s) / {{ allocation_plan.max_allocation_days or setting.max_allocation_days }} allowed.</div>
{% if allocation_plan.entries %}<table class="mt-3 min-w-full text-sm"><thead><tr><th class="p-2 text-left">Actual Date</th><th class="p-2 text-left">Proposed Entry Amount</th></tr></thead><tbody>{% for row in allocation_plan.entries %}<tr class="border-t border-slate-200"><td class="p-2">{{ row.date }}</td><td class="p-2">₹{{ '%.2f'|format(row.amount) }}</td></tr>{% endfor %}</tbody></table>{% endif %} {% if allocation_plan.entries %}<table class="mt-3 min-w-full text-sm"><thead><tr><th class="p-2 text-left">Actual Date</th><th class="p-2 text-left">Proposed Entry Amount</th></tr></thead><tbody>{% for row in allocation_plan.entries %}<tr class="border-t border-slate-200"><td class="p-2">{{ row.date }}</td><td class="p-2">₹{{ '%.2f'|format(row.amount) }}</td></tr>{% endfor %}</tbody></table>{% endif %}
<p class="mt-3 text-xs text-slate-600">{{ allocation_plan.note or 'No accounting dates are generated automatically.' }}</p> <p class="mt-3 text-xs text-slate-600">{{ allocation_plan.note or 'No accounting dates are generated automatically.' }}</p>
{% if allocation_plan.status == 'ready' %}
<form method="post" action="/tools/accounting/cash-payments/allocation/post" class="mt-4 rounded-xl border border-slate-200 bg-white p-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<input type="hidden" name="total_amount" value="{{ allocation_input.total_amount }}">
<input type="hidden" name="party_name" value="{{ allocation_input.party_name }}">
<input type="hidden" name="expense_ledger" value="{{ allocation_input.expense_ledger }}">
<input type="hidden" name="source_payments" value="{{ allocation_input.source_payments }}">
<div class="grid gap-4 md:grid-cols-2">
<div><label class="text-xs font-semibold uppercase text-slate-500">Debit ledger</label><input value="{{ allocation_input.expense_ledger }}" disabled class="mt-1 w-full rounded-xl border-slate-300 bg-slate-50 text-sm"><p class="mt-1 text-xs text-slate-500">The reviewed expense / purchase ledger is debited.</p></div>
<div><label class="text-xs font-semibold uppercase text-slate-500">Cash ledger</label><input name="cash_ledger" value="{{ allocation_input.cash_ledger or 'Cash' }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div>
</div>
<div class="mt-4 flex flex-wrap items-center gap-3">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="confirm_posting" value="yes" required> I reviewed the actual source dates and amounts.</label>
<input name="confirm_text" placeholder="Type POST" class="rounded-lg border border-slate-300 px-3 py-2 text-sm" required>
<button class="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Post Split Entries to Tally</button>
</div>
<p class="mt-2 text-xs text-slate-500">The entries are first stored in this client's local .act write queue, then transferred to Tally in .NET batches and read back for verification.</p>
</form>
{% endif %}
</div>
{% endif %}
{% if posting_result %}
<div class="mt-5 rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900">
<div class="font-semibold">Cash payment split batch #{{ posting_result.batch_id }} posted and verified.</div>
<div class="mt-1">{{ posting_result.batch.batch.verified_count }} voucher(s) verified in TallyPrime.</div>
</div> </div>
{% endif %} {% endif %}
</section> </section>