Fix sundry creditors ageing using purchase bill FIFO
This commit is contained in:
@@ -269,9 +269,9 @@ def sundry_creditors_aging(
|
|||||||
"""Age closing Sundry Creditors using FIFO from the Accounting Mirror.
|
"""Age closing Sundry Creditors using FIFO from the Accounting Mirror.
|
||||||
|
|
||||||
The selected-FY closing is reconstructed from the mirror's ledger opening
|
The selected-FY closing is reconstructed from the mirror's ledger opening
|
||||||
balance plus all creditor movements available up to the selected FY end.
|
balance plus all creditor movements in the selected FY. For trade-creditor
|
||||||
FIFO means the oldest credit lots are settled first; therefore the closing
|
ageing, that closing control is allocated to actual Purchase voucher credits
|
||||||
unpaid balance is reconstructed from the newest surviving credit lots.
|
from newest to oldest, which is the year-end result of FIFO settlement.
|
||||||
|
|
||||||
Voucher dates are normalized before ageing. This is intentionally strict:
|
Voucher dates are normalized before ageing. This is intentionally strict:
|
||||||
a dated movement that cannot be parsed is not silently treated as an old
|
a dated movement that cannot be parsed is not silently treated as an old
|
||||||
@@ -472,24 +472,73 @@ def sundry_creditors_aging(
|
|||||||
if closing_balance <= 0.009:
|
if closing_balance <= 0.009:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# True forward FIFO reconstruction. The selected-FY opening balance is
|
# FIFO purchase-bill reconstruction.
|
||||||
# the oldest outstanding lot. Every creditor-increasing movement creates
|
#
|
||||||
# a new dated lot; every creditor-reducing movement settles the oldest lot
|
# The selected-FY closing balance above is the control total and already
|
||||||
# first. The lots that survive at FY end are therefore the actual FIFO
|
# includes every debit/credit movement in the party ledger (payments,
|
||||||
# composition of the closing balance.
|
# receipts, sales adjustments, journals, etc.). For ageing of TRADE
|
||||||
|
# CREDITOR BILLS, however, only genuine Purchase voucher credits create
|
||||||
|
# purchase-bill lots. Under FIFO, settlements clear the oldest purchase
|
||||||
|
# bills first, so the year-end closing balance is represented by the
|
||||||
|
# newest purchase bills working backwards from FY end. Treating every
|
||||||
|
# CR movement as a bill (for example a Receipt credit in a mixed
|
||||||
|
# purchase/sales ledger) gives a false bill date and wrong ageing.
|
||||||
closing_lots: list[dict[str, Any]] = []
|
closing_lots: list[dict[str, Any]] = []
|
||||||
|
|
||||||
# A Sundry Creditor ledger can legitimately open with either a credit
|
def _is_purchase_voucher(row: dict[str, Any]) -> bool:
|
||||||
# balance (amount payable) or a debit balance (advance / sales-side net
|
voucher_type = str(row.get("voucher_type") or "").strip().casefold()
|
||||||
# receivable in the same party ledger). The earlier FIFO code created
|
# Tally voucher types can be renamed/extended (e.g. "Purchase GST").
|
||||||
# a lot only for a positive opening balance and silently discarded a
|
# The standard and derived Purchase types retain "purchase" in the
|
||||||
# debit opening balance. That inflated the surviving creditor lots by
|
# exported voucher type name.
|
||||||
# exactly the unconsumed debit opening. Preserve both sides: a credit
|
return "purchase" in voucher_type
|
||||||
# opening becomes the oldest payable lot; a debit opening becomes a
|
|
||||||
# carry-forward that offsets the earliest future creditor credits.
|
|
||||||
debit_carry = round(max(0.0, -opening_balance), 2)
|
|
||||||
|
|
||||||
if opening_balance > 0.009:
|
purchase_rows = [
|
||||||
|
row for row in fy_rows
|
||||||
|
if str(row.get("dr_cr") or "").strip().upper() == "CR"
|
||||||
|
and _is_purchase_voucher(row)
|
||||||
|
and _money(row.get("amount")) > 0.009
|
||||||
|
]
|
||||||
|
|
||||||
|
# Allocate the closing balance to the newest purchase bills first. This
|
||||||
|
# is the direct outstanding-lot consequence of FIFO settlement: oldest
|
||||||
|
# purchases are deemed paid first, newest purchases remain outstanding.
|
||||||
|
amount_to_allocate = closing_balance
|
||||||
|
for row in sorted(
|
||||||
|
purchase_rows,
|
||||||
|
key=lambda r: (
|
||||||
|
r.get("_parsed_date") or opening_fallback_date,
|
||||||
|
int(r.get("line_no") or 0),
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
):
|
||||||
|
if amount_to_allocate <= 0.009:
|
||||||
|
break
|
||||||
|
original_credit = _money(row.get("amount"))
|
||||||
|
outstanding = round(min(original_credit, amount_to_allocate), 2)
|
||||||
|
if outstanding <= 0.009:
|
||||||
|
continue
|
||||||
|
closing_lots.append({
|
||||||
|
"party_name": party_name,
|
||||||
|
"source": "Purchase voucher",
|
||||||
|
"voucher_date": str(row.get("voucher_date") or ""),
|
||||||
|
"voucher_type": str(row.get("voucher_type") or ""),
|
||||||
|
"voucher_number": str(row.get("voucher_number") or ""),
|
||||||
|
"reference": str(row.get("reference") or ""),
|
||||||
|
"original_credit": original_credit,
|
||||||
|
"remaining": outstanding,
|
||||||
|
"outstanding_at_fy_end": outstanding,
|
||||||
|
"paid_subsequently": 0.0,
|
||||||
|
"final_payment_date": "",
|
||||||
|
})
|
||||||
|
amount_to_allocate = round(amount_to_allocate - outstanding, 2)
|
||||||
|
|
||||||
|
# Any closing amount not represented by Purchase vouchers in the
|
||||||
|
# selected FY is brought forward / outside the selected FY purchase
|
||||||
|
# history. It remains part of the control total and is aged as old.
|
||||||
|
# This also keeps the report honest when a party closing balance contains
|
||||||
|
# non-purchase credits that cannot be tied to a purchase bill from the
|
||||||
|
# current mirror period.
|
||||||
|
if amount_to_allocate > 0.009:
|
||||||
closing_lots.append({
|
closing_lots.append({
|
||||||
"party_name": party_name,
|
"party_name": party_name,
|
||||||
"source": "Opening / brought forward",
|
"source": "Opening / brought forward",
|
||||||
@@ -497,81 +546,19 @@ def sundry_creditors_aging(
|
|||||||
"voucher_type": "Opening",
|
"voucher_type": "Opening",
|
||||||
"voucher_number": "",
|
"voucher_number": "",
|
||||||
"reference": "",
|
"reference": "",
|
||||||
"original_credit": opening_balance,
|
"original_credit": amount_to_allocate,
|
||||||
"remaining": opening_balance,
|
"remaining": amount_to_allocate,
|
||||||
"outstanding_at_fy_end": 0.0,
|
"outstanding_at_fy_end": amount_to_allocate,
|
||||||
"paid_subsequently": 0.0,
|
"paid_subsequently": 0.0,
|
||||||
"final_payment_date": "",
|
"final_payment_date": "",
|
||||||
})
|
})
|
||||||
|
|
||||||
def settle_fifo(lots: list[dict[str, Any]], amount: float) -> float:
|
fifo_total = round(sum(_money(lot.get("outstanding_at_fy_end")) for lot in closing_lots), 2)
|
||||||
"""Settle oldest payable lots and return any excess debit amount.
|
if abs(fifo_total - closing_balance) > 0.01:
|
||||||
|
|
||||||
Excess debit is not discarded. In a mixed purchase/sales party
|
|
||||||
ledger it represents an advance / receivable that must offset the
|
|
||||||
next creditor-increasing credit before a new payable lot is born.
|
|
||||||
"""
|
|
||||||
remaining_payment = max(0.0, _money(amount))
|
|
||||||
for lot in lots:
|
|
||||||
if remaining_payment <= 0.009:
|
|
||||||
break
|
|
||||||
available = max(0.0, _money(lot.get("remaining")))
|
|
||||||
if available <= 0.009:
|
|
||||||
continue
|
|
||||||
applied = round(min(available, remaining_payment), 2)
|
|
||||||
lot["remaining"] = round(available - applied, 2)
|
|
||||||
remaining_payment = round(remaining_payment - applied, 2)
|
|
||||||
return remaining_payment
|
|
||||||
|
|
||||||
for row in fy_rows:
|
|
||||||
side = str(row.get("dr_cr") or "").strip().upper()
|
|
||||||
amount = _money(row.get("amount"))
|
|
||||||
if amount <= 0.009:
|
|
||||||
continue
|
|
||||||
if side == "CR":
|
|
||||||
# Consume any brought-forward / accumulated debit balance first.
|
|
||||||
# Only the residual credit creates a creditor FIFO lot.
|
|
||||||
if debit_carry > 0.009:
|
|
||||||
offset = round(min(debit_carry, amount), 2)
|
|
||||||
debit_carry = round(debit_carry - offset, 2)
|
|
||||||
amount = round(amount - offset, 2)
|
|
||||||
if amount <= 0.009:
|
|
||||||
continue
|
|
||||||
closing_lots.append({
|
|
||||||
"party_name": party_name,
|
|
||||||
"source": "Voucher",
|
|
||||||
"voucher_date": str(row.get("voucher_date") or ""),
|
|
||||||
"voucher_type": str(row.get("voucher_type") or ""),
|
|
||||||
"voucher_number": str(row.get("voucher_number") or ""),
|
|
||||||
"reference": str(row.get("reference") or ""),
|
|
||||||
"original_credit": amount,
|
|
||||||
"remaining": amount,
|
|
||||||
"outstanding_at_fy_end": 0.0,
|
|
||||||
"paid_subsequently": 0.0,
|
|
||||||
"final_payment_date": "",
|
|
||||||
})
|
|
||||||
elif side == "DR":
|
|
||||||
excess_debit = settle_fifo(closing_lots, amount)
|
|
||||||
if excess_debit > 0.009:
|
|
||||||
debit_carry = round(debit_carry + excess_debit, 2)
|
|
||||||
|
|
||||||
# Remove fully settled lots and freeze the surviving FY-end outstanding
|
|
||||||
# amounts before any optional follow-up-year settlement is applied.
|
|
||||||
closing_lots = [
|
|
||||||
lot for lot in closing_lots
|
|
||||||
if _money(lot.get("remaining")) > 0.009
|
|
||||||
]
|
|
||||||
for lot in closing_lots:
|
|
||||||
lot["outstanding_at_fy_end"] = _money(lot.get("remaining"))
|
|
||||||
|
|
||||||
fifo_total = round(sum(_money(lot.get("remaining")) for lot in closing_lots), 2)
|
|
||||||
fifo_net = round(fifo_total - debit_carry, 2)
|
|
||||||
if abs(fifo_net - historical_closing) > 0.01 or abs(fifo_total - closing_balance) > 0.01:
|
|
||||||
raise AccountingMirrorError(
|
raise AccountingMirrorError(
|
||||||
f"FIFO movement reconstruction does not reconcile for {party_name}: "
|
f"Purchase-bill FIFO reconstruction does not reconcile for {party_name}: "
|
||||||
f"selected-FY closing {closing_balance:.2f}, FIFO lots {fifo_total:.2f}, "
|
f"selected-FY closing {closing_balance:.2f}, allocated lots {fifo_total:.2f}. "
|
||||||
f"debit carry {debit_carry:.2f}. Refresh the Accounting Mirror for the "
|
"Refresh the Accounting Mirror for the selected FY and retry."
|
||||||
"selected FY and retry."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
closing_lots = sorted(
|
closing_lots = sorted(
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
<div class="rounded-xl border border-slate-200 bg-white">
|
<div class="rounded-xl border border-slate-200 bg-white">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-100 px-4 py-3">
|
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-100 px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-slate-900">{{ row.party_name }} · FIFO Outstanding Bill Drill-down</div>
|
<div class="font-semibold text-slate-900">{{ row.party_name }} · FIFO Purchase Bill Drill-down</div>
|
||||||
<div class="mt-1 text-xs text-slate-500">Closing ₹ {{ '%.2f'|format(row.closing_balance) }} · ≤180 ₹ {{ '%.2f'|format(row.within_180) }} · >180 ₹ {{ '%.2f'|format(row.over_180) }}</div>
|
<div class="mt-1 text-xs text-slate-500">Closing ₹ {{ '%.2f'|format(row.closing_balance) }} · ≤180 ₹ {{ '%.2f'|format(row.within_180) }} · >180 ₹ {{ '%.2f'|format(row.over_180) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t border-slate-100 px-4 py-3 text-xs text-slate-500">
|
<div class="border-t border-slate-100 px-4 py-3 text-xs text-slate-500">
|
||||||
<strong>Note:</strong> “Opening / brought forward” represents an old balance for which the originating purchase voucher is outside the selected FY/mirror history. Voucher rows are FIFO-derived outstanding lots from the same calculation used in the party summary; they are not Tally bill-reference allocations.
|
<strong>Note:</strong> “Opening / brought forward” represents an old balance for which the originating purchase voucher is outside the selected FY/mirror history. Purchase-voucher rows are FIFO-derived outstanding purchase bills from the same closing-balance control used in the party summary; Receipt/Sales/other credit movements are not presented as purchase bills. These are not Tally bill-reference allocations.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user