Merge pull request #49834 from rohitwaghchaure/feat-track-purchases

feat: track purchases in accounting and configure item / item group / brand wise COGS
This commit is contained in:
rohitwaghchaure
2025-10-06 16:46:14 +05:30
committed by GitHub
13 changed files with 270 additions and 16 deletions

View File

@@ -884,6 +884,7 @@ class PurchaseInvoice(BuyingController):
self.make_write_off_gl_entry(gl_entries) self.make_write_off_gl_entry(gl_entries)
self.make_gle_for_rounding_adjustment(gl_entries) self.make_gle_for_rounding_adjustment(gl_entries)
self.set_transaction_currency_and_rate_in_gl_map(gl_entries) self.set_transaction_currency_and_rate_in_gl_map(gl_entries)
self.set_gl_entry_for_purchase_expense(gl_entries)
return gl_entries return gl_entries
def check_asset_cwip_enabled(self): def check_asset_cwip_enabled(self):

View File

@@ -17,7 +17,7 @@ from erpnext.accounts.party import get_party_details
from erpnext.buying.utils import update_last_purchase_rate, validate_for_items from erpnext.buying.utils import update_last_purchase_rate, validate_for_items
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return from erpnext.controllers.sales_and_purchase_return import get_rate_for_return
from erpnext.controllers.subcontracting_controller import SubcontractingController from erpnext.controllers.subcontracting_controller import SubcontractingController
from erpnext.stock.get_item_details import get_conversion_factor from erpnext.stock.get_item_details import get_conversion_factor, get_item_defaults
from erpnext.stock.utils import get_incoming_rate from erpnext.stock.utils import get_incoming_rate
@@ -307,6 +307,60 @@ class BuyingController(SubcontractingController):
address_display_field, render_address(self.get(address_field), check_permissions=False) address_display_field, render_address(self.get(address_field), check_permissions=False)
) )
def set_gl_entry_for_purchase_expense(self, gl_entries):
if self.doctype == "Purchase Invoice" and not self.update_stock:
return
for row in self.items:
details = get_purchase_expense_account(row.item_code, self.company)
if not details.purchase_expense_account:
details.purchase_expense_account = frappe.get_cached_value(
"Company", self.company, "purchase_expense_account"
)
if not details.purchase_expense_account:
return
if not details.purchase_expense_contra_account:
details.purchase_expense_contra_account = frappe.get_cached_value(
"Company", self.company, "purchase_expense_contra_account"
)
if not details.purchase_expense_contra_account:
frappe.throw(
_("Please set Purchase Expense Contra Account in Company {0}").format(self.company)
)
amount = flt(row.valuation_rate * row.stock_qty, row.precision("base_amount"))
self.add_gl_entry(
gl_entries=gl_entries,
account=details.purchase_expense_account,
cost_center=row.cost_center,
debit=amount,
credit=0.0,
remarks=_("Purchase Expense for Item {0}").format(row.item_code),
against_account=details.purchase_expense_contra_account,
account_currency=frappe.get_cached_value(
"Account", details.purchase_expense_account, "account_currency"
),
item=row,
)
self.add_gl_entry(
gl_entries=gl_entries,
account=details.purchase_expense_contra_account,
cost_center=row.cost_center,
debit=0.0,
credit=amount,
remarks=_("Purchase Expense for Item {0}").format(row.item_code),
against_account=details.purchase_expense_account,
account_currency=frappe.get_cached_value(
"Account", details.purchase_expense_contra_account, "account_currency"
),
item=row,
)
def set_total_in_words(self): def set_total_in_words(self):
from frappe.utils import money_in_words from frappe.utils import money_in_words
@@ -1171,3 +1225,33 @@ def validate_item_type(doc, fieldname, message):
@erpnext.allow_regional @erpnext.allow_regional
def update_regional_item_valuation_rate(doc): def update_regional_item_valuation_rate(doc):
pass pass
@frappe.request_cache
def get_purchase_expense_account(item_code, company):
defaults = get_item_defaults(item_code, company)
details = frappe._dict(
{
"purchase_expense_account": defaults.get("purchase_expense_account"),
"purchase_expense_contra_account": defaults.get("purchase_expense_contra_account"),
}
)
if not details.purchase_expense_account:
details = frappe.db.get_value(
"Item Default",
{"parent": defaults.item_group, "company": company},
["purchase_expense_account", "purchase_expense_contra_account"],
as_dict=1,
) or frappe._dict({})
if not details.purchase_expense_account:
details = frappe.db.get_value(
"Item Default",
{"parent": defaults.brand, "company": company},
["purchase_expense_account", "purchase_expense_contra_account"],
as_dict=1,
)
return details or frappe._dict({})

View File

@@ -278,6 +278,8 @@ erpnext.company.setup_queries = function (frm) {
["depreciation_expense_account", { root_type: "Expense", account_type: "Depreciation" }], ["depreciation_expense_account", { root_type: "Expense", account_type: "Depreciation" }],
["disposal_account", { report_type: "Profit and Loss" }], ["disposal_account", { report_type: "Profit and Loss" }],
["default_inventory_account", { account_type: "Stock" }], ["default_inventory_account", { account_type: "Stock" }],
["purchase_expense_account", { root_type: "Expense" }],
["purchase_expense_contra_account", { root_type: "Expense" }],
["cost_center", {}], ["cost_center", {}],
["round_off_cost_center", {}], ["round_off_cost_center", {}],
["depreciation_cost_center", {}], ["depreciation_cost_center", {}],

View File

@@ -106,6 +106,10 @@
"default_warehouse_for_sales_return", "default_warehouse_for_sales_return",
"credit_limit", "credit_limit",
"transactions_annual_history", "transactions_annual_history",
"purchase_expense_section",
"purchase_expense_account",
"column_break_ereg",
"purchase_expense_contra_account",
"stock_tab", "stock_tab",
"auto_accounting_for_stock_settings", "auto_accounting_for_stock_settings",
"enable_perpetual_inventory", "enable_perpetual_inventory",
@@ -844,6 +848,27 @@
"options": "Currency", "options": "Currency",
"print_hide": 1, "print_hide": 1,
"read_only": 1 "read_only": 1
},
{
"fieldname": "purchase_expense_section",
"fieldtype": "Section Break",
"label": "Purchase Expense"
},
{
"fieldname": "column_break_ereg",
"fieldtype": "Column Break"
},
{
"fieldname": "purchase_expense_account",
"fieldtype": "Link",
"label": "Purchase Expense Account",
"options": "Account"
},
{
"fieldname": "purchase_expense_contra_account",
"fieldtype": "Link",
"label": "Purchase Expense Contra Account",
"options": "Account"
} }
], ],
"icon": "fa fa-building", "icon": "fa fa-building",
@@ -851,7 +876,7 @@
"image_field": "company_logo", "image_field": "company_logo",
"is_tree": 1, "is_tree": 1,
"links": [], "links": [],
"modified": "2025-08-25 18:34:03.602046", "modified": "2025-10-01 17:34:10.971627",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Setup", "module": "Setup",
"name": "Company", "name": "Company",

View File

@@ -86,6 +86,8 @@ class Company(NestedSet):
parent_company: DF.Link | None parent_company: DF.Link | None
payment_terms: DF.Link | None payment_terms: DF.Link | None
phone_no: DF.Data | None phone_no: DF.Data | None
purchase_expense_account: DF.Link | None
purchase_expense_contra_account: DF.Link | None
reconcile_on_advance_payment_date: DF.Check reconcile_on_advance_payment_date: DF.Check
reconciliation_takes_effect_on: DF.Literal[ reconciliation_takes_effect_on: DF.Literal[
"Advance Payment Date", "Oldest Of Invoice Or Advance", "Reconciliation Date" "Advance Payment Date", "Oldest Of Invoice Or Advance", "Reconciliation Date"

View File

@@ -488,6 +488,21 @@ $.extend(erpnext.item, {
}, },
}; };
}); });
let fields = ["purchase_expense_account", "purchase_expense_contra_account", "default_cogs_account"];
fields.forEach((field) => {
frm.set_query(field, "item_defaults", (doc, cdt, cdn) => {
let row = locals[cdt][cdn];
return {
filters: {
company: row.company,
root_type: "Expense",
is_group: 0,
},
};
});
});
}, },
make_dashboard: function (frm) { make_dashboard: function (frm) {

View File

@@ -16,20 +16,22 @@
"item_name", "item_name",
"item_group", "item_group",
"stock_uom", "stock_uom",
"opening_stock",
"valuation_rate",
"standard_rate",
"column_break0", "column_break0",
"disabled", "disabled",
"allow_alternative_item", "allow_alternative_item",
"is_stock_item", "is_stock_item",
"has_variants", "has_variants",
"opening_stock",
"valuation_rate",
"standard_rate",
"is_fixed_asset", "is_fixed_asset",
"auto_create_assets", "auto_create_assets",
"is_grouped_asset", "is_grouped_asset",
"asset_category", "asset_category",
"asset_naming_series", "asset_naming_series",
"section_break_znra",
"over_delivery_receipt_allowance", "over_delivery_receipt_allowance",
"column_break_wugd",
"over_billing_allowance", "over_billing_allowance",
"image", "image",
"section_break_11", "section_break_11",
@@ -63,6 +65,8 @@
"column_break_37", "column_break_37",
"has_serial_no", "has_serial_no",
"serial_no_series", "serial_no_series",
"defaults_tab",
"item_defaults",
"variants_section", "variants_section",
"variant_of", "variant_of",
"variant_based_on", "variant_based_on",
@@ -74,8 +78,6 @@
"column_break_9s9o", "column_break_9s9o",
"enable_deferred_revenue", "enable_deferred_revenue",
"no_of_months", "no_of_months",
"section_break_avcp",
"item_defaults",
"purchasing_tab", "purchasing_tab",
"purchase_uom", "purchase_uom",
"min_order_qty", "min_order_qty",
@@ -887,10 +889,6 @@
"fieldname": "column_break_9s9o", "fieldname": "column_break_9s9o",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{
"fieldname": "section_break_avcp",
"fieldtype": "Section Break"
},
{ {
"collapsible": 1, "collapsible": 1,
"fieldname": "deferred_accounting_section", "fieldname": "deferred_accounting_section",
@@ -936,6 +934,19 @@
"fieldname": "production_capacity", "fieldname": "production_capacity",
"fieldtype": "Int", "fieldtype": "Int",
"label": "Production Capacity" "label": "Production Capacity"
},
{
"fieldname": "defaults_tab",
"fieldtype": "Tab Break",
"label": "Defaults"
},
{
"fieldname": "section_break_znra",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_wugd",
"fieldtype": "Column Break"
} }
], ],
"icon": "fa fa-tag", "icon": "fa fa-tag",
@@ -943,7 +954,7 @@
"image_field": "image", "image_field": "image",
"links": [], "links": [],
"make_attachments_public": 1, "make_attachments_public": 1,
"modified": "2025-08-14 23:35:56.293048", "modified": "2025-10-01 16:58:40.946604",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Item", "name": "Item",

View File

@@ -305,6 +305,7 @@ class TestItem(IntegrationTestCase):
"company": "_Test Company", "company": "_Test Company",
"default_warehouse": "_Test Warehouse 2 - _TC", # no override "default_warehouse": "_Test Warehouse 2 - _TC", # no override
"expense_account": "_Test Account Stock Expenses - _TC", # override brand default "expense_account": "_Test Account Stock Expenses - _TC", # override brand default
"default_cogs_account": "_Test Account Cost for Goods Sold - _TC", # override brand default
"buying_cost_center": "_Test Write Off Cost Center - _TC", # override item group default "buying_cost_center": "_Test Write Off Cost Center - _TC", # override item group default
} }
], ],
@@ -315,7 +316,7 @@ class TestItem(IntegrationTestCase):
"item_code": "Test Item With Defaults", "item_code": "Test Item With Defaults",
"warehouse": "_Test Warehouse 2 - _TC", # from item "warehouse": "_Test Warehouse 2 - _TC", # from item
"income_account": "_Test Account Sales - _TC", # from brand "income_account": "_Test Account Sales - _TC", # from brand
"expense_account": "_Test Account Stock Expenses - _TC", # from item "expense_account": "_Test Account Cost for Goods Sold - _TC", # from item
"cost_center": "_Test Cost Center 2 - _TC", # from item group "cost_center": "_Test Cost Center 2 - _TC", # from item group
} }
sales_item_details = get_item_details( sales_item_details = get_item_details(

View File

@@ -16,10 +16,15 @@
"column_break_8", "column_break_8",
"expense_account", "expense_account",
"default_provisional_account", "default_provisional_account",
"column_break_cpif",
"purchase_expense_account",
"purchase_expense_contra_account",
"selling_defaults", "selling_defaults",
"selling_cost_center", "selling_cost_center",
"column_break_12", "column_break_12",
"income_account", "income_account",
"cost_of_good_sold_section",
"default_cogs_account",
"deferred_accounting_defaults_section", "deferred_accounting_defaults_section",
"deferred_expense_account", "deferred_expense_account",
"column_break_kwad", "column_break_kwad",
@@ -111,7 +116,7 @@
{ {
"fieldname": "default_provisional_account", "fieldname": "default_provisional_account",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Default Provisional Account", "label": "Default Provisional Account (Service)",
"options": "Account" "options": "Account"
}, },
{ {
@@ -136,17 +141,45 @@
{ {
"fieldname": "column_break_kwad", "fieldname": "column_break_kwad",
"fieldtype": "Column Break" "fieldtype": "Column Break"
},
{
"fieldname": "column_break_cpif",
"fieldtype": "Column Break"
},
{
"fieldname": "cost_of_good_sold_section",
"fieldtype": "Section Break",
"label": "Cost of Goods Sold"
},
{
"fieldname": "default_cogs_account",
"fieldtype": "Link",
"label": "Default COGS Account",
"options": "Account"
},
{
"fieldname": "purchase_expense_account",
"fieldtype": "Link",
"label": "Purchase Expense Account",
"options": "Account"
},
{
"fieldname": "purchase_expense_contra_account",
"fieldtype": "Link",
"label": "Purchase Expense Contra Account",
"options": "Account"
} }
], ],
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2025-03-17 13:46:09.719105", "modified": "2025-10-01 19:17:33.687836",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Item Default", "name": "Item Default",
"owner": "Administrator", "owner": "Administrator",
"permissions": [], "permissions": [],
"quick_entry": 1, "quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],

View File

@@ -16,6 +16,7 @@ class ItemDefault(Document):
buying_cost_center: DF.Link | None buying_cost_center: DF.Link | None
company: DF.Link company: DF.Link
default_cogs_account: DF.Link | None
default_discount_account: DF.Link | None default_discount_account: DF.Link | None
default_price_list: DF.Link | None default_price_list: DF.Link | None
default_provisional_account: DF.Link | None default_provisional_account: DF.Link | None
@@ -28,6 +29,8 @@ class ItemDefault(Document):
parent: DF.Data parent: DF.Data
parentfield: DF.Data parentfield: DF.Data
parenttype: DF.Data parenttype: DF.Data
purchase_expense_account: DF.Link | None
purchase_expense_contra_account: DF.Link | None
selling_cost_center: DF.Link | None selling_cost_center: DF.Link | None
# end: auto-generated types # end: auto-generated types

View File

@@ -479,6 +479,7 @@ class PurchaseReceipt(BuyingController):
self.make_item_gl_entries(gl_entries, warehouse_account=warehouse_account) self.make_item_gl_entries(gl_entries, warehouse_account=warehouse_account)
self.make_tax_gl_entries(gl_entries, via_landed_cost_voucher) self.make_tax_gl_entries(gl_entries, via_landed_cost_voucher)
self.set_gl_entry_for_purchase_expense(gl_entries)
update_regional_gl_entries(gl_entries, self) update_regional_gl_entries(gl_entries, self)
return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation) return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation)

View File

@@ -4415,6 +4415,69 @@ class TestPurchaseReceipt(IntegrationTestCase):
self.assertEqual(srbnb_cost, 1000) self.assertEqual(srbnb_cost, 1000)
def test_purchase_expense_account(self):
item = "Test Item with Purchase Expense Account"
make_item(item, {"is_stock_item": 1})
company = "_Test Company with perpetual inventory"
expense_account = "_Test Account Purchase Expense - TCP1"
expense_contra_account = "_Test Account Purchase Contra Expense - TCP1"
if not frappe.db.exists("Account", expense_account):
frappe.get_doc(
{
"doctype": "Account",
"account_name": "_Test Account Purchase Expense",
"parent_account": "Stock Expenses - TCP1",
"company": company,
"is_group": 0,
"root_type": "Expense",
}
).insert()
if not frappe.db.exists("Account", expense_contra_account):
frappe.get_doc(
{
"doctype": "Account",
"account_name": "_Test Account Purchase Contra Expense",
"parent_account": "Stock Expenses - TCP1",
"company": company,
"is_group": 0,
"root_type": "Expense",
}
).insert()
item_doc = frappe.get_doc("Item", item)
item_doc.append(
"item_defaults",
{
"company": company,
"default_warehouse": "Stores - TCP1",
"purchase_expense_account": expense_account,
"purchase_expense_contra_account": expense_contra_account,
},
)
item_doc.save()
pr = make_purchase_receipt(
item_code=item,
qty=10,
rate=100,
company=company,
warehouse="Stores - TCP1",
)
gl_entries = get_gl_entries(pr.doctype, pr.name)
accounts = [d.account for d in gl_entries]
self.assertTrue(expense_account in accounts)
self.assertTrue(expense_contra_account in accounts)
for row in gl_entries:
if row.account == expense_account:
self.assertEqual(row.debit, 1000)
if row.account == expense_contra_account:
self.assertEqual(row.credit, 1000)
def prepare_data_for_internal_transfer(): def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier

View File

@@ -868,6 +868,19 @@ def get_default_income_account(ctx: ItemDetailsCtx, item, item_group, brand):
def get_default_expense_account(ctx: ItemDetailsCtx, item, item_group, brand): def get_default_expense_account(ctx: ItemDetailsCtx, item, item_group, brand):
if ctx.get("doctype") in ["Sales Invoice", "Delivery Note"]:
expense_account = (
item.get("default_cogs_account")
or item_group.get("default_cogs_account")
or brand.get("default_cogs_account")
)
if not expense_account:
expense_account = frappe.get_cached_value("Company", ctx.company, "default_expense_account")
if expense_account:
return expense_account
return ( return (
item.get("expense_account") item.get("expense_account")
or item_group.get("expense_account") or item_group.get("expense_account")