Fix Cash Payment FY-specific Accounting Mirror resolution
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user