diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 7bfd275ee2a..7f524c82912 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -261,12 +261,10 @@ class DeliveryNote(SellingController): def set_actual_qty(self): for d in self.get("items"): if d.item_code and d.warehouse: - actual_qty = frappe.db.sql( - """select actual_qty from `tabBin` - where item_code = %s and warehouse = %s""", - (d.item_code, d.warehouse), + actual_qty = frappe.db.get_value( + "Bin", {"item_code": d.item_code, "warehouse": d.warehouse}, "actual_qty" ) - d.actual_qty = actual_qty and flt(actual_qty[0][0]) or 0 + d.actual_qty = flt(actual_qty) or 0 def so_required(self): """check in manage account if sales order required or not""" @@ -385,11 +383,10 @@ class DeliveryNote(SellingController): def validate_proj_cust(self): """check for does customer belong to same project as entered..""" if self.project and self.customer: - res = frappe.db.sql( - """select name from `tabProject` - where name = %s and (customer = %s or - ifnull(customer,'')='')""", - (self.project, self.customer), + res = frappe.get_all( + "Project", + filters={"name": self.project}, + or_filters=[["customer", "=", self.customer], ["customer", "is", "not set"]], ) if not res: frappe.throw( @@ -604,20 +601,20 @@ class DeliveryNote(SellingController): PackingService(self).validate_packed_qty() def check_next_docstatus(self): - submit_rv = frappe.db.sql( - """select t1.name - from `tabSales Invoice` t1,`tabSales Invoice Item` t2 - where t1.name = t2.parent and t2.delivery_note = %s and t1.docstatus = 1""", - (self.name), + submit_rv = frappe.get_all( + "Sales Invoice Item", + filters={"delivery_note": self.name, "docstatus": 1}, + fields=["parent"], + as_list=True, ) if submit_rv: frappe.throw(_("Sales Invoice {0} has already been submitted").format(submit_rv[0][0])) - submit_in = frappe.db.sql( - """select t1.name - from `tabInstallation Note` t1, `tabInstallation Note Item` t2 - where t1.name = t2.parent and t2.prevdoc_docname = %s and t1.docstatus = 1""", - (self.name), + submit_in = frappe.get_all( + "Installation Note Item", + filters={"prevdoc_docname": self.name, "docstatus": 1}, + fields=["parent"], + as_list=True, ) if submit_in: frappe.throw(_("Installation Note {0} has already been submitted").format(submit_in[0][0])) diff --git a/erpnext/stock/doctype/delivery_note/services/billing_status.py b/erpnext/stock/doctype/delivery_note/services/billing_status.py index 98d360389d4..70364c89fa7 100644 --- a/erpnext/stock/doctype/delivery_note/services/billing_status.py +++ b/erpnext/stock/doctype/delivery_note/services/billing_status.py @@ -104,12 +104,12 @@ def update_billed_amount_based_on_so(so_detail: str, update_modified: bool = Tru 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 = frappe.get_all( + "Sales Invoice Item", + filters={"dn_detail": dnd.name, "docstatus": 1}, + fields=[{"SUM": "amount", "as": "amount"}], ) - billed_amt_against_dn = billed_amt_against_dn and billed_amt_against_dn[0][0] or 0 + billed_amt_against_dn = billed_amt_against_dn[0].amount or 0 if billed_amt_against_dn else 0 # Distribute billed amount directly against SO between DNs based on FIFO if billed_against_so and billed_amt_against_dn < dnd.amount: diff --git a/erpnext/stock/doctype/delivery_note/services/packing.py b/erpnext/stock/doctype/delivery_note/services/packing.py index 999174cd876..da4da55e1eb 100644 --- a/erpnext/stock/doctype/delivery_note/services/packing.py +++ b/erpnext/stock/doctype/delivery_note/services/packing.py @@ -50,14 +50,12 @@ class PackingService: 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, + res = frappe.get_all( + "Packing Slip", filters={"delivery_note": self.doc.name, "docstatus": 1}, pluck="name" ) if res: for r in res: - ps = frappe.get_doc("Packing Slip", r[0]) + ps = frappe.get_doc("Packing Slip", r) ps.cancel() frappe.msgprint(_("Packing Slip(s) cancelled")) diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index aca8491946d..68cb91a979c 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -3376,6 +3376,68 @@ class TestDeliveryNote(ERPNextTestSuite): dn.items[0].stock_qty = 2 dn.save() + def test_validate_proj_cust_matches_project_customer(self): + """validate_proj_cust must reject a DN whose customer differs from the project's customer, + and accept one when the project has no customer (the ifnull(customer,'')='' / `is not set` + branch of the converted or_filters).""" + mismatch_project = frappe.get_doc( + { + "doctype": "Project", + "project_name": "_Test DN Project Mismatch", + "company": "_Test Company", + "customer": "_Test Customer 1", + } + ).insert() + dn = create_delivery_note(customer="_Test Customer", do_not_save=True) + dn.project = mismatch_project.name + with self.assertRaises(frappe.ValidationError) as cm: + dn.insert() + self.assertIn("does not belong to project", str(cm.exception)) + + # A project with no customer must pass via the empty-string/NULL or_filters branch. + open_project = frappe.get_doc( + { + "doctype": "Project", + "project_name": "_Test DN Project No Customer", + "company": "_Test Company", + } + ).insert() + self.assertFalse(open_project.customer) + dn2 = create_delivery_note(customer="_Test Customer", do_not_save=True) + dn2.project = open_project.name + dn2.insert() # must not raise + self.assertTrue(dn2.name) + + def test_check_next_docstatus_blocks_cancel_with_submitted_invoice(self): + """check_next_docstatus must block cancelling a DN once a submitted Sales Invoice draws from + it — covers the converted child-table get_all (Sales Invoice Item, docstatus=1).""" + dn = create_delivery_note() # submitted, simple _Test Item + si = make_sales_invoice(dn.name) + si.insert() + si.submit() + + dn.load_from_db() + with self.assertRaises(frappe.ValidationError) as cm: + dn.cancel() + self.assertIn("has already been submitted", str(cm.exception)) + + def test_cancel_packing_slips_cancels_submitted_slips(self): + """cancel_packing_slips must cancel the DN's submitted Packing Slips — covers the converted + get_all(pluck=name) lookup and the pluck-aware iteration.""" + from erpnext.stock.doctype.delivery_note.mapper import make_packing_slip + from erpnext.stock.doctype.delivery_note.services.packing import PackingService + + dn = create_delivery_note(do_not_submit=True) # draft, so a Packing Slip can be mapped + ps = make_packing_slip(dn.name) + ps.save() + ps.submit() + dn.submit() + self.assertEqual(frappe.db.get_value("Packing Slip", ps.name, "docstatus"), 1) + + PackingService(dn).cancel_packing_slips() + + self.assertEqual(frappe.db.get_value("Packing Slip", ps.name, "docstatus"), 2) + def create_delivery_note(**args): dn = frappe.new_doc("Delivery Note") diff --git a/erpnext/stock/doctype/delivery_trip/delivery_trip.py b/erpnext/stock/doctype/delivery_trip/delivery_trip.py index 2e8832771fd..857ba0618e4 100644 --- a/erpnext/stock/doctype/delivery_trip/delivery_trip.py +++ b/erpnext/stock/doctype/delivery_trip/delivery_trip.py @@ -315,19 +315,15 @@ def get_contact_and_address(name: str): def get_default_contact(out, name): - contact_persons = frappe.db.sql( - """ - SELECT parent, - (SELECT is_primary_contact FROM tabContact c WHERE c.name = dl.parent) AS is_primary_contact - FROM - `tabDynamic Link` dl - WHERE - dl.link_doctype='Customer' - AND dl.link_name=%s - AND dl.parenttype = 'Contact' - """, - (name), - as_dict=1, + dl = frappe.qb.DocType("Dynamic Link") + contact = frappe.qb.DocType("Contact") + contact_persons = ( + frappe.qb.from_(dl) + .left_join(contact) + .on(contact.name == dl.parent) + .select(dl.parent, contact.is_primary_contact) + .where((dl.link_doctype == "Customer") & (dl.link_name == name) & (dl.parenttype == "Contact")) + .run(as_dict=1) ) if contact_persons: @@ -341,19 +337,15 @@ def get_default_contact(out, name): def get_default_address(out, name): - shipping_addresses = frappe.db.sql( - """ - SELECT parent, - (SELECT is_shipping_address FROM tabAddress a WHERE a.name=dl.parent) AS is_shipping_address - FROM - `tabDynamic Link` dl - WHERE - dl.link_doctype='Customer' - AND dl.link_name=%s - AND dl.parenttype = 'Address' - """, - (name), - as_dict=1, + dl = frappe.qb.DocType("Dynamic Link") + address = frappe.qb.DocType("Address") + shipping_addresses = ( + frappe.qb.from_(dl) + .left_join(address) + .on(address.name == dl.parent) + .select(dl.parent, address.is_shipping_address) + .where((dl.link_doctype == "Customer") & (dl.link_name == name) & (dl.parenttype == "Address")) + .run(as_dict=1) ) if shipping_addresses: diff --git a/erpnext/stock/doctype/delivery_trip/test_delivery_trip.py b/erpnext/stock/doctype/delivery_trip/test_delivery_trip.py index 83b7395f342..e838fbbc743 100644 --- a/erpnext/stock/doctype/delivery_trip/test_delivery_trip.py +++ b/erpnext/stock/doctype/delivery_trip/test_delivery_trip.py @@ -8,6 +8,7 @@ from frappe.utils import add_days, flt, now_datetime, nowdate import erpnext from erpnext.stock.doctype.delivery_trip.delivery_trip import ( get_contact_and_address, + get_default_contact, notify_customers, ) from erpnext.tests.utils import ERPNextTestSuite @@ -108,6 +109,71 @@ class TestDeliveryTrip(ERPNextTestSuite): self.delivery_trip.save() self.assertEqual(self.delivery_trip.status, "Completed") + def test_get_contact_and_address_returns_linked_contact_and_address(self): + """get_contact_and_address (the converted Dynamic Link queries) must return a real Contact + and Address that are actually linked to the customer — pins the converted query's output.""" + out = get_contact_and_address("_Test Customer") + + self.assertTrue(out.contact_person and out.contact_person.parent) + self.assertTrue(frappe.db.exists("Contact", out.contact_person.parent)) + self.assertTrue( + frappe.db.exists( + "Dynamic Link", + { + "parenttype": "Contact", + "parent": out.contact_person.parent, + "link_doctype": "Customer", + "link_name": "_Test Customer", + }, + ) + ) + + self.assertTrue(out.shipping_address and out.shipping_address.parent) + self.assertTrue(frappe.db.exists("Address", out.shipping_address.parent)) + self.assertTrue( + frappe.db.exists( + "Dynamic Link", + { + "parenttype": "Address", + "parent": out.shipping_address.parent, + "link_doctype": "Customer", + "link_name": "_Test Customer", + }, + ) + ) + + def test_get_default_contact_keeps_orphaned_dynamic_link(self): + """The converted get_default_contact uses a LEFT join, matching the original correlated + subquery: a Dynamic Link whose parent Contact no longer exists must STILL be returned + (is_primary_contact NULL). An inner join would silently drop it and return None.""" + customer = "_Test Customer 2" + # A Contact linked to the customer, then orphan its Dynamic Link by deleting the Contact row. + contact = frappe.get_doc( + { + "doctype": "Contact", + "first_name": "_Test Orphan Link Contact", + "links": [{"link_doctype": "Customer", "link_name": customer}], + } + ).insert() + orphan_parent = contact.name + frappe.db.delete("Contact", {"name": orphan_parent}) + + self.assertFalse(frappe.db.exists("Contact", orphan_parent)) + self.assertTrue( + frappe.db.exists( + "Dynamic Link", + {"parenttype": "Contact", "parent": orphan_parent, "link_name": customer}, + ) + ) + + out = frappe._dict() + result = get_default_contact(out, customer) + + # LEFT join keeps the orphaned-link row; an inner join would have returned None. + self.assertIsNotNone(result) + self.assertEqual(result.parent, orphan_parent) + self.assertIsNone(result.is_primary_contact) + def create_address(driver): if not frappe.db.exists("Address", {"address_title": "_Test Address for Driver"}):