diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index a34ff1485f5..df4c5d65d04 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -3,13 +3,14 @@ import frappe -from frappe import _, qb, throw +from frappe import _, throw from frappe.model.document import Document -from frappe.query_builder.functions import Sum from frappe.utils import cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate import erpnext from erpnext.accounts.deferred_revenue import validate_service_stop_date +from erpnext.accounts.doctype.purchase_invoice.services.billing_status import BillingStatusService +from erpnext.accounts.doctype.purchase_invoice.services.expense_account import ExpenseAccountService from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import ( validate_docs_for_deferred_accounting, validate_docs_for_voucher_types, @@ -32,12 +33,7 @@ from erpnext.accounts.general_ledger import ( from erpnext.accounts.party import get_due_date, get_party_account from erpnext.accounts.utils import get_account_currency, get_fiscal_year, update_voucher_outstanding from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled -from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account -from erpnext.controllers.accounts_controller import validate_account_head from erpnext.controllers.buying_controller import BuyingController -from erpnext.stock.doctype.purchase_receipt.services.billing_status import ( - update_billed_amount_based_on_po, -) class WarehouseMissingError(frappe.ValidationError): @@ -284,9 +280,10 @@ class PurchaseInvoice(BuyingController): self.validate_with_previous_doc() self.validate_uom_is_integer("uom", "qty") self.validate_uom_is_integer("stock_uom", "stock_qty") - self.set_expense_account(for_validate=True) - self.validate_expense_account() - self.set_against_expense_account() + expense_account_service = ExpenseAccountService(self) + expense_account_service.set_expense_account(for_validate=True) + expense_account_service.validate_expense_account() + expense_account_service.set_against_expense_account() self.validate_write_off_account() self.validate_write_off_cost_center() @@ -446,166 +443,10 @@ class PurchaseInvoice(BuyingController): frappe.msgprint(_("Item Code required at Row No {0}").format(d.idx), raise_exception=True) def set_expense_account(self, for_validate=False): - auto_accounting_for_stock = erpnext.is_perpetual_inventory_enabled(self.company) - - if auto_accounting_for_stock: - stock_not_billed_account = self.get_company_default("stock_received_but_not_billed") - stock_items = self.get_stock_items() - - self.asset_received_but_not_billed = None - - inventory_account_map = {} - if self.update_stock: - self.validate_item_code() - self.validate_warehouse(for_validate) - if auto_accounting_for_stock: - inventory_account_map = self.get_inventory_account_map() - - for item in self.get("items"): - # in case of auto inventory accounting, - # expense account is always "Stock Received But Not Billed" for a stock item - # except opening entry, drop-ship entry and fixed asset items - if ( - auto_accounting_for_stock - and item.item_code in stock_items - and self.is_opening == "No" - and not item.is_fixed_asset - and ( - not item.po_detail - or not frappe.db.get_value("Purchase Order Item", item.po_detail, "delivered_by_supplier") - ) - ): - if self.update_stock and item.warehouse and (not item.from_warehouse): - _inv_dict = self.get_inventory_account_dict(item, inventory_account_map) - - if for_validate and item.expense_account and item.expense_account != _inv_dict["account"]: - msg = _( - "Row {0}: Expense Head changed to {1} because account {2} is not linked to warehouse {3} or it is not the default inventory account" - ).format( - item.idx, - frappe.bold(_inv_dict["account"]), - frappe.bold(item.expense_account), - frappe.bold(item.warehouse), - ) - frappe.msgprint(msg, title=_("Expense Head Changed")) - item.expense_account = _inv_dict["account"] - else: - # check if 'Stock Received But Not Billed' account is credited in Purchase receipt or not - if item.purchase_receipt: - negative_expense_booked_in_pr = frappe.db.sql( - """select name from `tabGL Entry` - where voucher_type='Purchase Receipt' and voucher_no=%s and account = %s""", - (item.purchase_receipt, stock_not_billed_account), - ) - - if negative_expense_booked_in_pr: - if ( - for_validate - and item.expense_account - and item.expense_account != stock_not_billed_account - ): - msg = _( - "Row {0}: Expense Head changed to {1} because expense is booked against this account in Purchase Receipt {2}" - ).format( - item.idx, - frappe.bold(stock_not_billed_account), - frappe.bold(item.purchase_receipt), - ) - frappe.msgprint(msg, title=_("Expense Head Changed")) - - item.expense_account = stock_not_billed_account - else: - # If no purchase receipt present then book expense in 'Stock Received But Not Billed' - # This is done in cases when Purchase Invoice is created before Purchase Receipt - if ( - for_validate - and item.expense_account - and item.expense_account != stock_not_billed_account - ): - msg = _( - "Row {0}: Expense Head changed to {1} as no Purchase Receipt is created against Item {2}." - ).format( - item.idx, frappe.bold(stock_not_billed_account), frappe.bold(item.item_code) - ) - msg += "
" - msg += _( - "This is done to handle accounting for cases when Purchase Receipt is created after Purchase Invoice" - ) - frappe.msgprint(msg, title=_("Expense Head Changed")) - - item.expense_account = stock_not_billed_account - elif item.is_fixed_asset: - account = None - if not item.pr_detail and item.po_detail: - receipt_item = frappe.get_cached_value( - "Purchase Receipt Item", - { - "purchase_order": item.purchase_order, - "purchase_order_item": item.po_detail, - "docstatus": 1, - }, - ["name", "parent"], - as_dict=1, - ) - if receipt_item: - item.pr_detail = receipt_item.name - item.purchase_receipt = receipt_item.parent - - if item.pr_detail: - if not self.asset_received_but_not_billed: - self.asset_received_but_not_billed = self.get_company_default( - "asset_received_but_not_billed" - ) - - # check if 'Asset Received But Not Billed' account is credited in Purchase receipt or not - arbnb_booked_in_pr = frappe.db.get_value( - "GL Entry", - { - "voucher_type": "Purchase Receipt", - "voucher_no": item.purchase_receipt, - "account": self.asset_received_but_not_billed, - }, - "name", - ) - if arbnb_booked_in_pr: - account = self.asset_received_but_not_billed - - if not account: - account_type = ( - "capital_work_in_progress_account" - if is_cwip_accounting_enabled(item.asset_category) - else "fixed_asset_account" - ) - account = get_asset_category_account( - account_type, item=item.item_code, company=self.company - ) - if not account: - form_link = get_link_to_form("Asset Category", item.asset_category) - throw( - _("Please set Fixed Asset Account in {} against {}.").format( - form_link, self.company - ), - title=_("Missing Account"), - ) - item.expense_account = account - elif not item.expense_account and for_validate: - throw(_("Expense account is mandatory for item {0}").format(item.item_code or item.item_name)) - - def validate_expense_account(self): - for item in self.get("items"): - validate_account_head(item.idx, item.expense_account, self.company, _("Expense")) - - def set_against_expense_account(self, force=False): - against_accounts = [] - for item in self.get("items"): - if item.expense_account and (item.expense_account not in against_accounts): - against_accounts.append(item.expense_account) - - self.against_expense_account = ",".join(against_accounts) + ExpenseAccountService(self).set_expense_account(for_validate) def force_set_against_expense_account(self): - self.set_against_expense_account() - frappe.db.set_value(self.doctype, self.name, "against_expense_account", self.against_expense_account) + ExpenseAccountService(self).force_set_against_expense_account() def po_required(self): if ( @@ -756,7 +597,7 @@ class PurchaseInvoice(BuyingController): def validate_for_repost(self): self.validate_write_off_account() self.validate_write_off_cost_center() - self.validate_expense_account() + ExpenseAccountService(self).validate_expense_account() validate_docs_for_voucher_types(["Purchase Invoice"]) validate_docs_for_deferred_accounting([], [self.name]) @@ -785,7 +626,7 @@ class PurchaseInvoice(BuyingController): self.update_billing_status_for_zero_amount_refdoc("Purchase Receipt") self.update_billing_status_for_zero_amount_refdoc("Purchase Order") - self.update_billing_status_in_pr() + BillingStatusService(self).update_billing_status_in_pr() # Updating stock ledger should always be called after updating prevdoc status, # because updating ordered qty in bin depends upon updated ordered qty in PO @@ -836,32 +677,10 @@ class PurchaseInvoice(BuyingController): self.make_exchange_gain_loss_journal() elif self.docstatus == 2: make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) - self.cancel_provisional_entries() + BillingStatusService(self).cancel_provisional_entries() self.update_supplier_outstanding(update_outstanding) - def cancel_provisional_entries(self): - rows = set() - purchase_receipts = set() - for d in self.items: - if d.purchase_receipt: - purchase_receipts.add(d.purchase_receipt) - rows.add(d.name) - - if rows: - # cancel gl entries - gle = qb.DocType("GL Entry") - gle_update_query = ( - qb.update(gle) - .set(gle.is_cancelled, 1) - .where( - (gle.voucher_type == "Purchase Receipt") - & (gle.voucher_no.isin(purchase_receipts)) - & (gle.voucher_detail_no.isin(rows)) - ) - ) - gle_update_query.run() - def update_supplier_outstanding(self, update_outstanding): if update_outstanding == "No": update_voucher_outstanding( @@ -911,7 +730,7 @@ class PurchaseInvoice(BuyingController): self.update_billing_status_for_zero_amount_refdoc("Purchase Receipt") self.update_billing_status_for_zero_amount_refdoc("Purchase Order") - self.update_billing_status_in_pr() + BillingStatusService(self).update_billing_status_in_pr() # Updating stock ledger should always be called after updating prevdoc status, # because updating ordered qty in bin depends upon updated ordered qty in PO @@ -994,63 +813,6 @@ class PurchaseInvoice(BuyingController): ) ) - def update_billing_status_in_pr(self, update_modified=True): - if self.is_return and not self.update_billed_amount_in_purchase_receipt: - return - - updated_pr = [] - po_details = [] - - pr_details_billed_amt = self.get_pr_details_billed_amt() - - for d in self.get("items"): - if d.pr_detail: - frappe.db.set_value( - "Purchase Receipt Item", - d.pr_detail, - "billed_amt", - flt(pr_details_billed_amt.get(d.pr_detail)), - update_modified=update_modified, - ) - updated_pr.append(d.purchase_receipt) - elif d.po_detail: - po_details.append(d.po_detail) - - if po_details: - updated_pr += update_billed_amount_based_on_po(po_details, update_modified) - - adjust_incoming_rate = frappe.db.get_single_value( - "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate" - ) - - for pr in set(updated_pr): - from erpnext.stock.doctype.purchase_receipt.services.billing_status import ( - update_billing_percentage, - ) - - pr_doc = frappe.get_lazy_doc("Purchase Receipt", pr) - update_billing_percentage( - pr_doc, update_modified=update_modified, adjust_incoming_rate=adjust_incoming_rate - ) - - def get_pr_details_billed_amt(self): - # Get billed amount based on purchase receipt item reference (pr_detail) in purchase invoice - - pr_details_billed_amt = {} - pr_details = [d.get("pr_detail") for d in self.get("items") if d.get("pr_detail")] - if pr_details: - doctype = frappe.qb.DocType("Purchase Invoice Item") - query = ( - frappe.qb.from_(doctype) - .select(doctype.pr_detail, Sum(doctype.amount)) - .where(doctype.pr_detail.isin(pr_details) & doctype.docstatus == 1) - .groupby(doctype.pr_detail) - ) - - pr_details_billed_amt = frappe._dict(query.run(as_list=1)) - - return pr_details_billed_amt - def on_recurring(self, reference_doc, auto_repeat_doc): self.due_date = None diff --git a/erpnext/accounts/doctype/purchase_invoice/services/billing_status.py b/erpnext/accounts/doctype/purchase_invoice/services/billing_status.py new file mode 100644 index 00000000000..24ecece8887 --- /dev/null +++ b/erpnext/accounts/doctype/purchase_invoice/services/billing_status.py @@ -0,0 +1,95 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Purchase Receipt billing sync and provisional-entry cancellation for Purchase Invoice.""" + +import frappe +from frappe import qb +from frappe.query_builder.functions import Sum +from frappe.utils import flt + +from erpnext.stock.doctype.purchase_receipt.services.billing_status import ( + update_billed_amount_based_on_po, + update_billing_percentage, +) + + +class BillingStatusService: + def __init__(self, doc): + self.doc = doc + + def update_billing_status_in_pr(self, update_modified: bool = True) -> None: + doc = self.doc + if doc.is_return and not doc.update_billed_amount_in_purchase_receipt: + return + + updated_pr = [] + po_details = [] + + pr_details_billed_amt = self.get_pr_details_billed_amt() + + for d in doc.get("items"): + if d.pr_detail: + frappe.db.set_value( + "Purchase Receipt Item", + d.pr_detail, + "billed_amt", + flt(pr_details_billed_amt.get(d.pr_detail)), + update_modified=update_modified, + ) + updated_pr.append(d.purchase_receipt) + elif d.po_detail: + po_details.append(d.po_detail) + + if po_details: + updated_pr += update_billed_amount_based_on_po(po_details, update_modified) + + adjust_incoming_rate = frappe.db.get_single_value( + "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate" + ) + + for pr in set(updated_pr): + pr_doc = frappe.get_lazy_doc("Purchase Receipt", pr) + update_billing_percentage( + pr_doc, update_modified=update_modified, adjust_incoming_rate=adjust_incoming_rate + ) + + def get_pr_details_billed_amt(self) -> dict: + # Get billed amount based on purchase receipt item reference (pr_detail) in purchase invoice + + pr_details_billed_amt = {} + pr_details = [d.get("pr_detail") for d in self.doc.get("items") if d.get("pr_detail")] + if pr_details: + doctype = frappe.qb.DocType("Purchase Invoice Item") + query = ( + frappe.qb.from_(doctype) + .select(doctype.pr_detail, Sum(doctype.amount)) + .where(doctype.pr_detail.isin(pr_details) & doctype.docstatus == 1) + .groupby(doctype.pr_detail) + ) + + pr_details_billed_amt = frappe._dict(query.run(as_list=1)) + + return pr_details_billed_amt + + def cancel_provisional_entries(self) -> None: + rows = set() + purchase_receipts = set() + for d in self.doc.items: + if d.purchase_receipt: + purchase_receipts.add(d.purchase_receipt) + rows.add(d.name) + + if rows: + # cancel gl entries + gle = qb.DocType("GL Entry") + gle_update_query = ( + qb.update(gle) + .set(gle.is_cancelled, 1) + .where( + (gle.voucher_type == "Purchase Receipt") + & (gle.voucher_no.isin(purchase_receipts)) + & (gle.voucher_detail_no.isin(rows)) + ) + ) + gle_update_query.run() diff --git a/erpnext/accounts/doctype/purchase_invoice/services/expense_account.py b/erpnext/accounts/doctype/purchase_invoice/services/expense_account.py new file mode 100644 index 00000000000..9f2d949d9a7 --- /dev/null +++ b/erpnext/accounts/doctype/purchase_invoice/services/expense_account.py @@ -0,0 +1,183 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Expense account resolution for Purchase Invoice items.""" + +import frappe +from frappe import _, throw +from frappe.utils import get_link_to_form + +import erpnext +from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled +from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account +from erpnext.controllers.accounts_controller import validate_account_head + + +class ExpenseAccountService: + def __init__(self, doc): + self.doc = doc + + def set_expense_account(self, for_validate: bool = False) -> None: + doc = self.doc + auto_accounting_for_stock = erpnext.is_perpetual_inventory_enabled(doc.company) + + if auto_accounting_for_stock: + stock_not_billed_account = doc.get_company_default("stock_received_but_not_billed") + stock_items = doc.get_stock_items() + + doc.asset_received_but_not_billed = None + + inventory_account_map = {} + if doc.update_stock: + doc.validate_item_code() + doc.validate_warehouse(for_validate) + if auto_accounting_for_stock: + inventory_account_map = doc.get_inventory_account_map() + + for item in doc.get("items"): + # in case of auto inventory accounting, + # expense account is always "Stock Received But Not Billed" for a stock item + # except opening entry, drop-ship entry and fixed asset items + if ( + auto_accounting_for_stock + and item.item_code in stock_items + and doc.is_opening == "No" + and not item.is_fixed_asset + and ( + not item.po_detail + or not frappe.db.get_value("Purchase Order Item", item.po_detail, "delivered_by_supplier") + ) + ): + if doc.update_stock and item.warehouse and (not item.from_warehouse): + _inv_dict = doc.get_inventory_account_dict(item, inventory_account_map) + + if for_validate and item.expense_account and item.expense_account != _inv_dict["account"]: + msg = _( + "Row {0}: Expense Head changed to {1} because account {2} is not linked to warehouse {3} or it is not the default inventory account" + ).format( + item.idx, + frappe.bold(_inv_dict["account"]), + frappe.bold(item.expense_account), + frappe.bold(item.warehouse), + ) + frappe.msgprint(msg, title=_("Expense Head Changed")) + item.expense_account = _inv_dict["account"] + else: + # check if 'Stock Received But Not Billed' account is credited in Purchase receipt or not + if item.purchase_receipt: + negative_expense_booked_in_pr = frappe.db.sql( + """select name from `tabGL Entry` + where voucher_type='Purchase Receipt' and voucher_no=%s and account = %s""", + (item.purchase_receipt, stock_not_billed_account), + ) + + if negative_expense_booked_in_pr: + if ( + for_validate + and item.expense_account + and item.expense_account != stock_not_billed_account + ): + msg = _( + "Row {0}: Expense Head changed to {1} because expense is booked against this account in Purchase Receipt {2}" + ).format( + item.idx, + frappe.bold(stock_not_billed_account), + frappe.bold(item.purchase_receipt), + ) + frappe.msgprint(msg, title=_("Expense Head Changed")) + + item.expense_account = stock_not_billed_account + else: + # If no purchase receipt present then book expense in 'Stock Received But Not Billed' + # This is done in cases when Purchase Invoice is created before Purchase Receipt + if ( + for_validate + and item.expense_account + and item.expense_account != stock_not_billed_account + ): + msg = _( + "Row {0}: Expense Head changed to {1} as no Purchase Receipt is created against Item {2}." + ).format( + item.idx, frappe.bold(stock_not_billed_account), frappe.bold(item.item_code) + ) + msg += "
" + msg += _( + "This is done to handle accounting for cases when Purchase Receipt is created after Purchase Invoice" + ) + frappe.msgprint(msg, title=_("Expense Head Changed")) + + item.expense_account = stock_not_billed_account + elif item.is_fixed_asset: + account = None + if not item.pr_detail and item.po_detail: + receipt_item = frappe.get_cached_value( + "Purchase Receipt Item", + { + "purchase_order": item.purchase_order, + "purchase_order_item": item.po_detail, + "docstatus": 1, + }, + ["name", "parent"], + as_dict=1, + ) + if receipt_item: + item.pr_detail = receipt_item.name + item.purchase_receipt = receipt_item.parent + + if item.pr_detail: + if not doc.asset_received_but_not_billed: + doc.asset_received_but_not_billed = doc.get_company_default( + "asset_received_but_not_billed" + ) + + # check if 'Asset Received But Not Billed' account is credited in Purchase receipt or not + arbnb_booked_in_pr = frappe.db.get_value( + "GL Entry", + { + "voucher_type": "Purchase Receipt", + "voucher_no": item.purchase_receipt, + "account": doc.asset_received_but_not_billed, + }, + "name", + ) + if arbnb_booked_in_pr: + account = doc.asset_received_but_not_billed + + if not account: + account_type = ( + "capital_work_in_progress_account" + if is_cwip_accounting_enabled(item.asset_category) + else "fixed_asset_account" + ) + account = get_asset_category_account( + account_type, item=item.item_code, company=doc.company + ) + if not account: + form_link = get_link_to_form("Asset Category", item.asset_category) + throw( + _("Please set Fixed Asset Account in {} against {}.").format( + form_link, doc.company + ), + title=_("Missing Account"), + ) + item.expense_account = account + elif not item.expense_account and for_validate: + throw(_("Expense account is mandatory for item {0}").format(item.item_code or item.item_name)) + + def validate_expense_account(self) -> None: + for item in self.doc.get("items"): + validate_account_head(item.idx, item.expense_account, self.doc.company, _("Expense")) + + def set_against_expense_account(self) -> None: + doc = self.doc + against_accounts = [] + for item in doc.get("items"): + if item.expense_account and (item.expense_account not in against_accounts): + against_accounts.append(item.expense_account) + + doc.against_expense_account = ",".join(against_accounts) + + def force_set_against_expense_account(self) -> None: + doc = self.doc + self.set_against_expense_account() + frappe.db.set_value(doc.doctype, doc.name, "against_expense_account", doc.against_expense_account)