mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-15 09:53:09 +00:00
* 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:
@@ -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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user