mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-26 04:18:35 +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:
@@ -1433,6 +1433,10 @@ class PurchaseInvoice(BuyingController):
|
||||
# tax table gl entries
|
||||
valuation_tax = {}
|
||||
|
||||
# Amount of each valuation charge actually capitalized into stock/asset valuation, keyed by
|
||||
# tax row name - a non-stock item's share of a spread-across-all-items charge is excluded.
|
||||
capitalized_valuation_tax = self.get_capitalized_valuation_tax()
|
||||
|
||||
for tax in self.get("taxes"):
|
||||
amount, base_amount = self.get_tax_amounts(tax, None)
|
||||
if tax.category in ("Total", "Valuation and Total") and flt(base_amount):
|
||||
@@ -1469,8 +1473,7 @@ class PurchaseInvoice(BuyingController):
|
||||
tax.idx, _(tax.category)
|
||||
)
|
||||
)
|
||||
valuation_tax.setdefault(tax.name, 0)
|
||||
valuation_tax[tax.name] += (tax.add_deduct_tax == "Add" and 1 or -1) * flt(base_amount)
|
||||
valuation_tax[tax.name] = capitalized_valuation_tax.get(tax.name, 0.0)
|
||||
|
||||
if self.is_opening == "No" and self.negative_expense_to_be_booked and valuation_tax:
|
||||
# credit valuation tax amount in "Expenses Included In Valuation"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"add_deduct_tax",
|
||||
"charge_type",
|
||||
"row_id",
|
||||
"allocate_full_amount_to_stock_items",
|
||||
"included_in_print_rate",
|
||||
"included_in_paid_amount",
|
||||
"col_break1",
|
||||
@@ -78,6 +79,14 @@
|
||||
"oldfieldname": "row_id",
|
||||
"oldfieldtype": "Data"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"depends_on": "eval:doc.charge_type=='Actual' && ['Valuation', 'Valuation and Total'].includes(doc.category)",
|
||||
"description": "If checked, the entire amount (e.g. Freight) is allocated to the valuation of stock & asset items only. If unchecked, the amount is distributed across all items and the portion belonging to non-stock items is not added to valuation.",
|
||||
"fieldname": "allocate_full_amount_to_stock_items",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allocate Full Amount to Stock Items"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "If checked, the tax amount will be considered as already included in the Print Rate / Print Amount",
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -886,6 +886,12 @@ class PurchaseReceipt(BuyingController):
|
||||
|
||||
def make_tax_gl_entries(self, gl_entries, via_landed_cost_voucher=False):
|
||||
negative_expense_to_be_booked = sum([flt(d.item_tax_amount) for d in self.get("items")])
|
||||
|
||||
# Amount of each valuation charge actually capitalized into stock/asset valuation, keyed by
|
||||
# tax row name. This is what must be credited to each tax account - a non-stock item's share
|
||||
# of a spread-across-all-items charge is not capitalized, so it is excluded here.
|
||||
capitalized_valuation_tax = self.get_capitalized_valuation_tax()
|
||||
|
||||
# Cost center-wise amount breakup for other charges included for valuation
|
||||
valuation_tax = {}
|
||||
for tax in self.get("taxes"):
|
||||
@@ -898,10 +904,8 @@ class PurchaseReceipt(BuyingController):
|
||||
tax.idx, _(tax.category)
|
||||
)
|
||||
)
|
||||
valuation_tax.setdefault(tax.name, 0)
|
||||
valuation_tax[tax.name] += (tax.add_deduct_tax == "Add" and 1 or -1) * flt(
|
||||
tax.base_tax_amount_after_discount_amount
|
||||
)
|
||||
|
||||
valuation_tax[tax.name] = capitalized_valuation_tax.get(tax.name, 0.0)
|
||||
|
||||
if negative_expense_to_be_booked and valuation_tax:
|
||||
# Backward compatibility:
|
||||
|
||||
@@ -1334,11 +1334,12 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
pr.delete()
|
||||
|
||||
def test_valuation_tax_distribution_with_non_stock_item(self):
|
||||
"""A "Valuation and Total" tax is distributed across all items by net amount, but only
|
||||
stock/asset items can carry valuation. For a document with 2 stock items + 1 service
|
||||
item (each net 100) and a 30 valuation tax, each item's share is 10; only the two stock
|
||||
items capitalize their share (20 total), so the non-stock item's 10 share must not be
|
||||
capitalized onto the stock items."""
|
||||
"""When "Allocate Full Amount to Stock Items" is unchecked, a "Valuation and Total"
|
||||
actual charge is distributed across all items by net amount, but only stock/asset items
|
||||
can carry valuation. For a document with 2 stock items + 1 service item (each net 100)
|
||||
and a 30 valuation charge, each item's share is 10; only the two stock items capitalize
|
||||
their share (20 total), so the non-stock item's 10 share must not be capitalized onto the
|
||||
stock items."""
|
||||
company = "_Test Company with perpetual inventory"
|
||||
warehouse = "Stores - TCP1"
|
||||
|
||||
@@ -1373,6 +1374,8 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
"cost_center": "Main - TCP1",
|
||||
"description": "Valuation Tax",
|
||||
"tax_amount": 30,
|
||||
# Spread across all items (incl. non-stock); do not allocate full amount to stock items
|
||||
"allocate_full_amount_to_stock_items": 0,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1400,6 +1403,231 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
# Only the stock items' share (20) is capitalized; the service item's 10 is excluded
|
||||
self.assertAlmostEqual(gl_map["_Test Account Shipping Charges - TCP1"].credit, 20.0, places=2)
|
||||
|
||||
def test_full_actual_charge_capitalized_on_stock_items_only(self):
|
||||
"""When "Allocate Full Amount to Stock Items" is checked (the default), an actual
|
||||
valuation charge such as Freight is fully capitalized onto stock/asset items only. For a
|
||||
document with 2 stock items + 1 service item (each net 100) and a 30 freight charge, the
|
||||
charge is distributed over the 200 stock net only: 15 per stock item, and the entire 30
|
||||
is capitalized (nothing is lost to the non-stock item)."""
|
||||
company = "_Test Company with perpetual inventory"
|
||||
warehouse = "Stores - TCP1"
|
||||
|
||||
stock_item1 = make_item(properties={"is_stock_item": 1}).name
|
||||
stock_item2 = make_item(properties={"is_stock_item": 1}).name
|
||||
service_item = make_item(properties={"is_stock_item": 0}).name
|
||||
|
||||
pr = frappe.new_doc("Purchase Receipt")
|
||||
pr.company = company
|
||||
pr.supplier = "_Test Supplier"
|
||||
pr.currency = "INR"
|
||||
# Order matters: stock, service, stock (service item in the middle)
|
||||
for code in (stock_item1, service_item, stock_item2):
|
||||
pr.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": code,
|
||||
"qty": 1,
|
||||
"rate": 100,
|
||||
"warehouse": warehouse,
|
||||
"cost_center": "Main - TCP1",
|
||||
"expense_account": "Cost of Goods Sold - TCP1",
|
||||
},
|
||||
)
|
||||
|
||||
pr.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "Actual",
|
||||
"account_head": "_Test Account Shipping Charges - TCP1",
|
||||
"category": "Valuation and Total",
|
||||
"cost_center": "Main - TCP1",
|
||||
"description": "Freight",
|
||||
"tax_amount": 30,
|
||||
# Default behavior: allocate the full amount to stock/asset items only
|
||||
"allocate_full_amount_to_stock_items": 1,
|
||||
},
|
||||
)
|
||||
|
||||
pr.insert()
|
||||
|
||||
# 30 freight / 200 stock net = 15 per stock item. The service item carries nothing.
|
||||
self.assertAlmostEqual(pr.items[0].item_tax_amount, 15.0, places=2)
|
||||
self.assertAlmostEqual(pr.items[1].item_tax_amount, 0.0, places=2)
|
||||
self.assertAlmostEqual(pr.items[2].item_tax_amount, 15.0, places=2)
|
||||
self.assertAlmostEqual(pr.items[0].valuation_rate, 115.0, places=2)
|
||||
self.assertAlmostEqual(pr.items[2].valuation_rate, 115.0, places=2)
|
||||
|
||||
pr.submit()
|
||||
|
||||
gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True, as_dict=True)
|
||||
gl_map = {row.account: row for row in gl_entries}
|
||||
|
||||
warehouse_account = get_warehouse_account_map(company)
|
||||
stock_account = warehouse_account[warehouse]["account"]
|
||||
|
||||
# Stock asset = 200 (goods) + 30 (the entire freight charge)
|
||||
self.assertAlmostEqual(gl_map[stock_account].debit, 230.0, places=2)
|
||||
self.assertAlmostEqual(gl_map["Stock Received But Not Billed - TCP1"].credit, 200.0, places=2)
|
||||
# The whole freight charge (30) is capitalized
|
||||
self.assertAlmostEqual(gl_map["_Test Account Shipping Charges - TCP1"].credit, 30.0, places=2)
|
||||
|
||||
def test_actual_charge_distribution_with_both_allocation_modes(self):
|
||||
"""Both allocation modes can coexist on the same document, and each item's share from
|
||||
each charge adds up. For 2 stock items + 1 service item (each net 100):
|
||||
- a 30 charge with the flag unchecked spreads over all 3 items (10 each); the service
|
||||
item's 10 is not capitalized, so each stock item keeps 10.
|
||||
- a 20 charge with the flag checked spreads over the 2 stock items only (10 each).
|
||||
So each stock item carries 10 + 10 = 20, and the service item carries nothing."""
|
||||
company = "_Test Company with perpetual inventory"
|
||||
warehouse = "Stores - TCP1"
|
||||
|
||||
stock_item1 = make_item(properties={"is_stock_item": 1}).name
|
||||
stock_item2 = make_item(properties={"is_stock_item": 1}).name
|
||||
service_item = make_item(properties={"is_stock_item": 0}).name
|
||||
|
||||
pr = frappe.new_doc("Purchase Receipt")
|
||||
pr.company = company
|
||||
pr.supplier = "_Test Supplier"
|
||||
pr.currency = "INR"
|
||||
# Order matters: stock, service, stock (service item in the middle)
|
||||
for code in (stock_item1, service_item, stock_item2):
|
||||
pr.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": code,
|
||||
"qty": 1,
|
||||
"rate": 100,
|
||||
"warehouse": warehouse,
|
||||
"cost_center": "Main - TCP1",
|
||||
"expense_account": "Cost of Goods Sold - TCP1",
|
||||
},
|
||||
)
|
||||
|
||||
# Spread across all items (service share dropped)
|
||||
pr.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "Actual",
|
||||
"account_head": "_Test Account Shipping Charges - TCP1",
|
||||
"category": "Valuation and Total",
|
||||
"cost_center": "Main - TCP1",
|
||||
"description": "Valuation Tax",
|
||||
"tax_amount": 30,
|
||||
"allocate_full_amount_to_stock_items": 0,
|
||||
},
|
||||
)
|
||||
# Allocate the full amount to stock items only
|
||||
pr.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "Actual",
|
||||
"account_head": "_Test Account Customs Duty - TCP1",
|
||||
"category": "Valuation and Total",
|
||||
"cost_center": "Main - TCP1",
|
||||
"description": "Freight",
|
||||
"tax_amount": 20,
|
||||
"allocate_full_amount_to_stock_items": 1,
|
||||
},
|
||||
)
|
||||
|
||||
pr.insert()
|
||||
|
||||
# Each stock item: 10 (all-items charge) + 10 (stock-only charge) = 20
|
||||
self.assertAlmostEqual(pr.items[0].item_tax_amount, 20.0, places=2)
|
||||
self.assertAlmostEqual(pr.items[1].item_tax_amount, 0.0, places=2)
|
||||
self.assertAlmostEqual(pr.items[2].item_tax_amount, 20.0, places=2)
|
||||
self.assertAlmostEqual(pr.items[0].valuation_rate, 120.0, places=2)
|
||||
self.assertAlmostEqual(pr.items[2].valuation_rate, 120.0, places=2)
|
||||
|
||||
pr.submit()
|
||||
|
||||
gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True, as_dict=True)
|
||||
gl_map = {row.account: row for row in gl_entries}
|
||||
|
||||
warehouse_account = get_warehouse_account_map(company)
|
||||
stock_account = warehouse_account[warehouse]["account"]
|
||||
|
||||
# Stock asset = 200 (goods) + 20 (stock share of the spread charge) + 20 (the full freight)
|
||||
self.assertAlmostEqual(gl_map[stock_account].debit, 240.0, places=2)
|
||||
self.assertAlmostEqual(gl_map["Stock Received But Not Billed - TCP1"].credit, 200.0, places=2)
|
||||
# Only the stock items' 20 share of the spread charge is capitalized (service 10 excluded)
|
||||
self.assertAlmostEqual(gl_map["_Test Account Shipping Charges - TCP1"].credit, 20.0, places=2)
|
||||
# The whole freight charge (20) is capitalized
|
||||
self.assertAlmostEqual(gl_map["_Test Account Customs Duty - TCP1"].credit, 20.0, places=2)
|
||||
|
||||
def test_multiple_actual_charges_per_item_matches_gl_per_account(self):
|
||||
"""With multiple "Actual" valuation charges over unevenly valued stock items, each charge
|
||||
is distributed individually so the per-item item_tax_amount decomposes exactly into the
|
||||
per-tax-row amount capitalized in the GL (no rounding drift between the two paths).
|
||||
|
||||
2 stock items with net 100 and 200 (total 300), and two freight charges of 10 each, both
|
||||
flagged to capitalize fully onto stock items. Distributing each charge separately gives
|
||||
item1 = round(100/300*10) * 2 = 3.33 * 2 = 6.66 and item2 = 6.67 * 2 = 13.34. Pooling the
|
||||
two charges into 20 first and spreading the aggregate would instead put 6.67 on item1,
|
||||
which no longer matches the 3.33 + 3.33 implied by the two per-account GL credits."""
|
||||
company = "_Test Company with perpetual inventory"
|
||||
warehouse = "Stores - TCP1"
|
||||
|
||||
stock_item1 = make_item(properties={"is_stock_item": 1}).name
|
||||
stock_item2 = make_item(properties={"is_stock_item": 1}).name
|
||||
|
||||
pr = frappe.new_doc("Purchase Receipt")
|
||||
pr.company = company
|
||||
pr.supplier = "_Test Supplier"
|
||||
pr.currency = "INR"
|
||||
for code, rate in ((stock_item1, 100), (stock_item2, 200)):
|
||||
pr.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": code,
|
||||
"qty": 1,
|
||||
"rate": rate,
|
||||
"warehouse": warehouse,
|
||||
"cost_center": "Main - TCP1",
|
||||
"expense_account": "Cost of Goods Sold - TCP1",
|
||||
},
|
||||
)
|
||||
|
||||
for account, amount in (
|
||||
("_Test Account Shipping Charges - TCP1", 10),
|
||||
("_Test Account Customs Duty - TCP1", 10),
|
||||
):
|
||||
pr.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "Actual",
|
||||
"account_head": account,
|
||||
"category": "Valuation and Total",
|
||||
"cost_center": "Main - TCP1",
|
||||
"description": account,
|
||||
"tax_amount": amount,
|
||||
"allocate_full_amount_to_stock_items": 1,
|
||||
},
|
||||
)
|
||||
|
||||
pr.insert()
|
||||
|
||||
# Each charge spread on its own: 3.33 + 3.33 = 6.66 and 6.67 + 6.67 = 13.34 (total 20)
|
||||
self.assertAlmostEqual(pr.items[0].item_tax_amount, 6.66, places=2)
|
||||
self.assertAlmostEqual(pr.items[1].item_tax_amount, 13.34, places=2)
|
||||
self.assertAlmostEqual(pr.items[0].valuation_rate, 106.66, places=2)
|
||||
self.assertAlmostEqual(pr.items[1].valuation_rate, 213.34, places=2)
|
||||
|
||||
pr.submit()
|
||||
|
||||
gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True, as_dict=True)
|
||||
gl_map = {row.account: row for row in gl_entries}
|
||||
|
||||
warehouse_account = get_warehouse_account_map(company)
|
||||
stock_account = warehouse_account[warehouse]["account"]
|
||||
|
||||
# Stock asset = 300 (goods) + 10 + 10 (both freight charges fully capitalized)
|
||||
self.assertAlmostEqual(gl_map[stock_account].debit, 320.0, places=2)
|
||||
self.assertAlmostEqual(gl_map["Stock Received But Not Billed - TCP1"].credit, 300.0, places=2)
|
||||
# Each charge is credited in full to its own account
|
||||
self.assertAlmostEqual(gl_map["_Test Account Shipping Charges - TCP1"].credit, 10.0, places=2)
|
||||
self.assertAlmostEqual(gl_map["_Test Account Customs Duty - TCP1"].credit, 10.0, places=2)
|
||||
|
||||
def test_po_to_pi_and_po_to_pr_worflow_full(self):
|
||||
"""Test following behaviour:
|
||||
- Create PO
|
||||
|
||||
Reference in New Issue
Block a user