diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 536a27712aa..453cf54d33b 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -26,7 +26,9 @@ from erpnext.accounts.utils import update_voucher_outstanding from erpnext.controllers.accounts_controller import validate_account_head from erpnext.controllers.selling_controller import SellingController from erpnext.setup.doctype.company.company import update_company_current_month_sales -from erpnext.stock.doctype.delivery_note.delivery_note import update_billed_amount_based_on_so +from erpnext.stock.doctype.delivery_note.services.billing_status import ( + update_billed_amount_based_on_so, +) from .services.fixed_assets import FixedAssetService from .services.inter_company import ( diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index dacdd56edbe..7bfd275ee2a 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -4,19 +4,13 @@ import frappe from frappe import _ -from frappe.desk.notifications import clear_doctype_notifications -from frappe.model.document import Document -from frappe.query_builder import DocType -from frappe.query_builder.functions import Abs, Sum from frappe.utils import cint, flt from erpnext.controllers.selling_controller import SellingController +from erpnext.stock.doctype.delivery_note.services.billing_status import BillingStatusService +from erpnext.stock.doctype.delivery_note.services.packing import PackingService from erpnext.stock.doctype.packed_item.packed_item import make_packing_list -from .mapper import ( - make_sales_invoice, -) - form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -483,7 +477,7 @@ class DeliveryNote(SellingController): if not self.is_return: self.check_credit_limit() elif self.issue_credit_note: - self.make_return_invoice() + BillingStatusService(self).make_return_invoice() for table_name in ["items", "packed_items"]: if not self.get(table_name): @@ -516,7 +510,7 @@ class DeliveryNote(SellingController): # because updating reserved qty in bin depends upon updated delivered qty in SO self.update_stock_ledger() - self.cancel_packing_slips() + PackingService(self).cancel_packing_slips() self.update_pick_list_status() self.make_gl_entries_on_cancel() @@ -607,20 +601,7 @@ class DeliveryNote(SellingController): def validate_packed_qty(self): """Validate that if packed qty exists, it should be equal to qty""" - - if frappe.db.exists("Packing Slip", {"docstatus": 1, "delivery_note": self.name}): - product_bundle_list = self.get_product_bundle_list() - for item in self.items + self.packed_items: - if ( - item.item_code not in product_bundle_list - and flt(item.packed_qty) - and flt(item.packed_qty) != flt(item.qty) - ): - frappe.throw( - _("Row {0}: Packed Qty must be equal to {1} Qty.").format( - item.idx, frappe.bold(item.doctype) - ) - ) + PackingService(self).validate_packed_qty() def check_next_docstatus(self): submit_rv = frappe.db.sql( @@ -641,153 +622,14 @@ class DeliveryNote(SellingController): if submit_in: frappe.throw(_("Installation Note {0} has already been submitted").format(submit_in[0][0])) - def cancel_packing_slips(self): - """ - Cancel submitted packing slips related to this delivery note - """ - res = frappe.db.sql( - """SELECT name FROM `tabPacking Slip` WHERE delivery_note = %s - AND docstatus = 1""", - self.name, - ) - - if res: - for r in res: - ps = frappe.get_doc("Packing Slip", r[0]) - ps.cancel() - frappe.msgprint(_("Packing Slip(s) cancelled")) - def update_status(self, status): - self.set_status(update=True, status=status) - self.notify_update() - clear_doctype_notifications(self) + BillingStatusService(self).update_status(status) def update_billing_status(self, update_modified=True): - updated_delivery_notes = [self.name] - for d in self.get("items"): - if d.si_detail and not d.so_detail: - d.db_set("billed_amt", d.amount, update_modified=update_modified) - elif d.so_detail: - updated_delivery_notes += update_billed_amount_based_on_so(d.so_detail, update_modified) - - for dn in set(updated_delivery_notes): - dn_doc = self if (dn == self.name) else frappe.get_lazy_doc("Delivery Note", dn) - dn_doc.update_billing_percentage(update_modified=update_modified) - - self.load_from_db() - - def make_return_invoice(self): - try: - return_invoice = make_sales_invoice(self.name) - return_invoice.is_return = True - return_invoice.save() - return_invoice.submit() - - credit_note_link = frappe.utils.get_link_to_form("Sales Invoice", return_invoice.name) - - frappe.msgprint(_("Credit Note {0} has been created automatically").format(credit_note_link)) - except Exception: - frappe.throw( - _( - "Could not create Credit Note automatically, please uncheck 'Issue Credit Note' and submit again" - ) - ) + BillingStatusService(self).update_billing_status(update_modified) def has_unpacked_items(self): - product_bundle_list = self.get_product_bundle_list() - - for item in self.items + self.packed_items: - if item.item_code not in product_bundle_list and flt(item.packed_qty) < flt(item.qty): - return True - - return False - - def get_product_bundle_list(self): - items_list = [item.item_code for item in self.items] - return frappe.db.get_all( - "Product Bundle", - filters={"new_item_code": ["in", items_list], "disabled": 0}, - pluck="name", - ) - - -def update_billed_amount_based_on_so(so_detail, update_modified=True): - # Billed against Sales Order directly - si = frappe.qb.DocType("Sales Invoice").as_("si") - si_item = frappe.qb.DocType("Sales Invoice Item").as_("si_item") - sum_amount = Sum(si_item.amount).as_("amount") - - billed_against_so = ( - frappe.qb.from_(si_item) - .join(si) - .on(si.name == si_item.parent) - .select(sum_amount) - .where( - (si_item.so_detail == so_detail) - & ((si_item.dn_detail.isnull()) | (si_item.dn_detail == "")) - & (si_item.docstatus == 1) - & (si.update_stock == 0) - ) - .run() - ) - billed_against_so = billed_against_so and billed_against_so[0][0] or 0 - - # Get all Delivery Note Item rows against the Sales Order Item row - dn = frappe.qb.DocType("Delivery Note").as_("dn") - dn_item = frappe.qb.DocType("Delivery Note Item").as_("dn_item") - - dn_details = ( - frappe.qb.from_(dn) - .from_(dn_item) - .select(dn_item.name, dn_item.amount, dn_item.si_detail, dn_item.parent) - .where( - (dn.name == dn_item.parent) - & (dn_item.so_detail == so_detail) - & (dn.docstatus == 1) - & (dn.is_return == 0) - ) - .orderby(dn.posting_date, dn.posting_time, dn.name) - .run(as_dict=True) - ) - - updated_dn = [] - for dnd in dn_details: - billed_amt_against_dn = 0 - - # If delivered against Sales Invoice - if dnd.si_detail: - billed_amt_against_dn = flt(dnd.amount) - billed_against_so -= billed_amt_against_dn - else: - # Get billed amount directly against Delivery Note - billed_amt_against_dn = frappe.db.sql( - """select sum(amount) from `tabSales Invoice Item` - where dn_detail=%s and docstatus=1""", - dnd.name, - ) - billed_amt_against_dn = billed_amt_against_dn and billed_amt_against_dn[0][0] or 0 - - # Distribute billed amount directly against SO between DNs based on FIFO - if billed_against_so and billed_amt_against_dn < dnd.amount: - pending_to_bill = flt(dnd.amount) - billed_amt_against_dn - if pending_to_bill <= billed_against_so: - billed_amt_against_dn += pending_to_bill - billed_against_so -= pending_to_bill - else: - billed_amt_against_dn += billed_against_so - billed_against_so = 0 - - frappe.db.set_value( - "Delivery Note Item", - dnd.name, - "billed_amt", - billed_amt_against_dn, - update_modified=update_modified, - ) - - updated_dn.append(dnd.parent) - - return updated_dn + return PackingService(self).has_unpacked_items() def get_list_context(context=None): diff --git a/erpnext/stock/doctype/delivery_note/services/__init__.py b/erpnext/stock/doctype/delivery_note/services/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/doctype/delivery_note/services/billing_status.py b/erpnext/stock/doctype/delivery_note/services/billing_status.py new file mode 100644 index 00000000000..98d360389d4 --- /dev/null +++ b/erpnext/stock/doctype/delivery_note/services/billing_status.py @@ -0,0 +1,134 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Billing status tracking and return invoicing for Delivery Note.""" + +import frappe +from frappe import _ +from frappe.desk.notifications import clear_doctype_notifications +from frappe.query_builder.functions import Sum +from frappe.utils import flt + + +class BillingStatusService: + def __init__(self, doc): + self.doc = doc + + def update_status(self, status: str) -> None: + doc = self.doc + doc.set_status(update=True, status=status) + doc.notify_update() + clear_doctype_notifications(doc) + + def update_billing_status(self, update_modified: bool = True) -> None: + doc = self.doc + updated_delivery_notes = [doc.name] + for d in doc.get("items"): + if d.si_detail and not d.so_detail: + d.db_set("billed_amt", d.amount, update_modified=update_modified) + elif d.so_detail: + updated_delivery_notes += update_billed_amount_based_on_so(d.so_detail, update_modified) + + for dn in set(updated_delivery_notes): + dn_doc = doc if (dn == doc.name) else frappe.get_lazy_doc("Delivery Note", dn) + dn_doc.update_billing_percentage(update_modified=update_modified) + + doc.load_from_db() + + def make_return_invoice(self) -> None: + from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice + + try: + return_invoice = make_sales_invoice(self.doc.name) + return_invoice.is_return = True + return_invoice.save() + return_invoice.submit() + + credit_note_link = frappe.utils.get_link_to_form("Sales Invoice", return_invoice.name) + + frappe.msgprint(_("Credit Note {0} has been created automatically").format(credit_note_link)) + except Exception: + frappe.throw( + _( + "Could not create Credit Note automatically, please uncheck 'Issue Credit Note' and submit again" + ) + ) + + +def update_billed_amount_based_on_so(so_detail: str, update_modified: bool = True) -> list[str]: + # Billed against Sales Order directly + si = frappe.qb.DocType("Sales Invoice").as_("si") + si_item = frappe.qb.DocType("Sales Invoice Item").as_("si_item") + sum_amount = Sum(si_item.amount).as_("amount") + + billed_against_so = ( + frappe.qb.from_(si_item) + .join(si) + .on(si.name == si_item.parent) + .select(sum_amount) + .where( + (si_item.so_detail == so_detail) + & ((si_item.dn_detail.isnull()) | (si_item.dn_detail == "")) + & (si_item.docstatus == 1) + & (si.update_stock == 0) + ) + .run() + ) + billed_against_so = billed_against_so and billed_against_so[0][0] or 0 + + # Get all Delivery Note Item rows against the Sales Order Item row + dn = frappe.qb.DocType("Delivery Note").as_("dn") + dn_item = frappe.qb.DocType("Delivery Note Item").as_("dn_item") + + dn_details = ( + frappe.qb.from_(dn) + .from_(dn_item) + .select(dn_item.name, dn_item.amount, dn_item.si_detail, dn_item.parent) + .where( + (dn.name == dn_item.parent) + & (dn_item.so_detail == so_detail) + & (dn.docstatus == 1) + & (dn.is_return == 0) + ) + .orderby(dn.posting_date, dn.posting_time, dn.name) + .run(as_dict=True) + ) + + updated_dn = [] + for dnd in dn_details: + billed_amt_against_dn = 0 + + # If delivered against Sales Invoice + if dnd.si_detail: + billed_amt_against_dn = flt(dnd.amount) + billed_against_so -= billed_amt_against_dn + else: + # Get billed amount directly against Delivery Note + billed_amt_against_dn = frappe.db.sql( + """select sum(amount) from `tabSales Invoice Item` + where dn_detail=%s and docstatus=1""", + dnd.name, + ) + billed_amt_against_dn = billed_amt_against_dn and billed_amt_against_dn[0][0] or 0 + + # Distribute billed amount directly against SO between DNs based on FIFO + if billed_against_so and billed_amt_against_dn < dnd.amount: + pending_to_bill = flt(dnd.amount) - billed_amt_against_dn + if pending_to_bill <= billed_against_so: + billed_amt_against_dn += pending_to_bill + billed_against_so -= pending_to_bill + else: + billed_amt_against_dn += billed_against_so + billed_against_so = 0 + + frappe.db.set_value( + "Delivery Note Item", + dnd.name, + "billed_amt", + billed_amt_against_dn, + update_modified=update_modified, + ) + + updated_dn.append(dnd.parent) + + return updated_dn diff --git a/erpnext/stock/doctype/delivery_note/services/packing.py b/erpnext/stock/doctype/delivery_note/services/packing.py new file mode 100644 index 00000000000..f26ba2897e9 --- /dev/null +++ b/erpnext/stock/doctype/delivery_note/services/packing.py @@ -0,0 +1,63 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Packing slip and product bundle handling for Delivery Note.""" + +import frappe +from frappe import _ +from frappe.utils import flt + + +class PackingService: + def __init__(self, doc): + self.doc = doc + + def validate_packed_qty(self) -> None: + """Validate that if packed qty exists, it should be equal to qty""" + doc = self.doc + + if frappe.db.exists("Packing Slip", {"docstatus": 1, "delivery_note": doc.name}): + product_bundle_list = self.get_product_bundle_list() + for item in doc.items + doc.packed_items: + if ( + item.item_code not in product_bundle_list + and flt(item.packed_qty) + and flt(item.packed_qty) != flt(item.qty) + ): + frappe.throw( + _("Row {0}: Packed Qty must be equal to {1} Qty.").format( + item.idx, frappe.bold(item.doctype) + ) + ) + + def has_unpacked_items(self) -> bool: + doc = self.doc + product_bundle_list = self.get_product_bundle_list() + + for item in doc.items + doc.packed_items: + if item.item_code not in product_bundle_list and flt(item.packed_qty) < flt(item.qty): + return True + + return False + + def get_product_bundle_list(self) -> list[str]: + items_list = [item.item_code for item in self.doc.items] + return frappe.db.get_all( + "Product Bundle", + filters={"new_item_code": ["in", items_list], "disabled": 0}, + pluck="name", + ) + + def cancel_packing_slips(self) -> None: + """Cancel submitted packing slips related to this delivery note""" + res = frappe.db.sql( + """SELECT name FROM `tabPacking Slip` WHERE delivery_note = %s + AND docstatus = 1""", + self.doc.name, + ) + + if res: + for r in res: + ps = frappe.get_doc("Packing Slip", r[0]) + ps.cancel() + frappe.msgprint(_("Packing Slip(s) cancelled"))