feat: allocate full actual charge to stock items only (e.g. Freight) (backport #56102) (#56222)

* feat: allocate full actual charge to stock items only (e.g. Freight)

Backport of #56102 to version-16-hotfix. Adapts the GL valuation-tax change
to the inline make_tax_gl_entries in purchase_receipt.py (no services/ refactor
on hotfix) and additionally applies it to purchase_invoice.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: distribute each Actual valuation charge individually

distribute_actual_tax_amount pooled all "Actual" valuation charges (both
the spread-across-all-items charges and the allocate_full_amount_to_stock_items
freight charges) into single totals before spreading, while the GL path
(get_capitalized_valuation_tax) capitalizes each tax row separately. For
multiple charges over unevenly valued items, pool-then-spread can drift by a
rounding cent from spread-each-then-sum, so a row's item_tax_amount no longer
decomposed exactly into the per-account capitalized GL amounts (the document
total still balanced).

get_tax_details now returns the per-row charge amounts as lists and
distribute_actual_tax_amount spreads each charge on its own, mirroring
get_capitalized_valuation_tax. Per-item valuation now reconciles exactly with
per-account GL credits. Single-charge behaviour is unchanged.

Adds test_multiple_actual_charges_per_item_matches_gl_per_account covering two
freight charges over items of net 100 and 200 (asserts 6.66 / 13.34, which the
old pooled logic would have rounded to 6.67 / 13.33).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rohitwaghchaure
2026-06-21 16:21:06 +05:30
committed by GitHub
parent 6e61ee8d70
commit 9469889bd5
5 changed files with 352 additions and 45 deletions

View File

@@ -414,39 +414,29 @@ class BuyingController(SubcontractingController):
stock_and_asset_items = []
stock_and_asset_items = self.get_stock_items() + self.get_asset_items()
stock_and_asset_items_qty, stock_and_asset_items_amount = 0, 0
last_item_idx = 1
for d in self.get("items"):
if d.item_code:
stock_and_asset_items_qty += flt(d.qty)
stock_and_asset_items_amount += flt(d.base_net_amount)
(
tax_accounts,
total_valuation_amount,
all_item_charges,
stock_item_charges,
) = self.get_tax_details()
last_item_idx = d.idx
# Pre-compute each item's share of the "Actual" valuation charges (keyed by row idx).
actual_charge_per_item = self.distribute_actual_tax_amount(
stock_and_asset_items, all_item_charges, stock_item_charges
)
tax_accounts, total_valuation_amount, total_actual_tax_amount = self.get_tax_details()
remaining_amount = total_actual_tax_amount
last_item_idx = max((d.idx for d in self.get("items")), default=1)
for i, item in enumerate(self.get("items")):
if item.item_code and (item.qty or item.get("rejected_qty")):
item_tax_amount, actual_tax_amount = 0.0, 0.0
if i == (last_item_idx - 1):
# dump any rounding remainder of the On Net Total valuation on the last item
item_tax_amount = total_valuation_amount
actual_tax_amount = remaining_amount
else:
# calculate item tax amount
item_tax_amount = self.get_item_tax_amount(item, tax_accounts)
total_valuation_amount -= item_tax_amount
if total_actual_tax_amount:
actual_tax_amount = self.get_item_actual_tax_amount(
item,
total_actual_tax_amount,
stock_and_asset_items_amount,
stock_and_asset_items_qty,
)
remaining_amount -= actual_tax_amount
# This code is required here to calculate the correct valuation for stock items
if item.item_code not in stock_and_asset_items:
item.valuation_rate = 0.0
@@ -454,7 +444,8 @@ class BuyingController(SubcontractingController):
# Item tax amount is the total tax amount applied on that item and actual tax type amount
item.item_tax_amount = flt(
item_tax_amount + actual_tax_amount, self.precision("item_tax_amount", item)
item_tax_amount + actual_charge_per_item.get(item.idx, 0.0),
self.precision("item_tax_amount", item),
)
self.round_floats_in(item)
@@ -503,7 +494,11 @@ class BuyingController(SubcontractingController):
def get_tax_details(self):
tax_accounts = []
total_valuation_amount = 0.0
total_actual_tax_amount = 0.0
# Per-row "Actual" valuation charge amounts, kept separate (not pooled) so each can be
# distributed individually - this keeps the per-item item_tax_amount in lockstep with the
# per-tax-row amount capitalized in the GL (see get_capitalized_valuation_tax).
all_item_charges = []
stock_item_charges = []
for d in self.get("taxes"):
if d.category not in ["Valuation", "Valuation and Total"]:
@@ -516,10 +511,13 @@ class BuyingController(SubcontractingController):
if d.charge_type == "On Net Total":
total_valuation_amount += amount
tax_accounts.append(d.account_head)
elif d.charge_type == "Actual" and d.get("allocate_full_amount_to_stock_items"):
# Capitalize the full amount onto stock/asset items only (e.g. Freight)
stock_item_charges.append(amount)
else:
total_actual_tax_amount += amount
all_item_charges.append(amount)
return tax_accounts, total_valuation_amount, total_actual_tax_amount
return tax_accounts, total_valuation_amount, all_item_charges, stock_item_charges
def get_item_tax_amount(self, item, tax_accounts):
item_tax_amount = 0.0
@@ -540,16 +538,81 @@ class BuyingController(SubcontractingController):
return item_tax_amount
def get_item_actual_tax_amount(
self, item, actual_tax_amount, stock_and_asset_items_amount, stock_and_asset_items_qty
):
item_proportion = (
flt(item.base_net_amount) / stock_and_asset_items_amount
if stock_and_asset_items_amount
else flt(item.qty) / stock_and_asset_items_qty
def distribute_actual_tax_amount(self, stock_and_asset_items, all_item_charges, stock_item_charges):
"""Distribute "Actual" valuation charges to each item, keyed by row idx.
Each charge is spread individually (not pooled together) so the resulting per-item
item_tax_amount decomposes exactly into the per-tax-row amount capitalized in the GL
(see get_capitalized_valuation_tax) - pooling first and spreading the aggregate can drift
by rounding for multiple charges over unevenly valued items. A charge in `all_item_charges`
is spread across every item by net amount; a non-stock item's share is computed but never
capitalized (e.g. a genuine tax). A charge in `stock_item_charges` (flagged
`allocate_full_amount_to_stock_items`) is spread across stock/asset items only, so the whole
charge is capitalized (e.g. Freight).
"""
all_items = [d for d in self.get("items") if d.item_code]
stock_items = [d for d in all_items if d.item_code in stock_and_asset_items]
charge_per_item = {}
for charge in all_item_charges:
self._spread_charge_over_items(charge_per_item, charge, all_items)
for charge in stock_item_charges:
self._spread_charge_over_items(charge_per_item, charge, stock_items)
return charge_per_item
def _spread_charge_over_items(self, charge_per_item, total_charge, items):
"""Add each item's proportional share of `total_charge` into `charge_per_item`.
Proportion is by net amount (falling back to qty); any rounding remainder is assigned
to the last item in the group."""
if not total_charge or not items:
return
total_amount = sum(flt(d.base_net_amount) for d in items)
total_qty = sum(flt(d.qty) for d in items)
# Nothing to proportion against (all rows have zero amount and zero qty)
if not total_amount and not total_qty:
return
remaining = total_charge
for d in items[:-1]:
proportion = flt(d.base_net_amount) / total_amount if total_amount else flt(d.qty) / total_qty
charge = flt(proportion * total_charge, self.precision("item_tax_amount", d))
charge_per_item[d.idx] = charge_per_item.get(d.idx, 0.0) + charge
remaining -= charge
last = items[-1]
charge_per_item[last.idx] = charge_per_item.get(last.idx, 0.0) + flt(
remaining, self.precision("item_tax_amount", last)
)
return flt(item_proportion * actual_tax_amount, self.precision("item_tax_amount", item))
def get_capitalized_valuation_tax(self):
stock_and_asset_items = self.get_stock_items() + self.get_asset_items()
all_items = [d for d in self.get("items") if d.item_code]
stock_item_idx = {d.idx for d in all_items if d.item_code in stock_and_asset_items}
capitalized = {}
for tax in self.get("taxes"):
if tax.category not in ("Valuation", "Valuation and Total"):
continue
amount = flt(tax.base_tax_amount_after_discount_amount) * (
-1 if tax.get("add_deduct_tax") == "Deduct" else 1
)
if not amount:
continue
if tax.charge_type == "Actual" and not tax.get("allocate_full_amount_to_stock_items"):
# Spread across all items; only the stock/asset items' share is capitalized.
charge_per_item = {}
self._spread_charge_over_items(charge_per_item, amount, all_items)
amount = sum(
charge for item_idx, charge in charge_per_item.items() if item_idx in stock_item_idx
)
capitalized[tax.name] = amount
return capitalized
def set_incoming_rate(self):
"""