fix: SO to PO flow improvement (#23357)
* fix: SO to PO flow improvement
* fix: Dont map shipping_address
- shipping_address is a text field in SO and link field in PO
- Drop shipping case handles its mapping
- normal case doesnt need to map
* fix: Hide/Add rows depending on Against Default Supplier
* fix: Removed Default Supplier Select field from popup
- removed Default Supplier Select field from popup
- only loop through suppliers of selected items if via default supplier
- only check for items in selected items
* fix: Sales Order Drop Shipping Test
* fix: (translation)Multi line to single line strings
Co-authored-by: Nabin Hait <nabinhait@gmail.com>
diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js
index 7b46fb6..989bd33 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.js
+++ b/erpnext/selling/doctype/sales_order/sales_order.js
@@ -162,7 +162,7 @@
// sales invoice
if(flt(doc.per_billed, 6) < 100) {
- this.frm.add_custom_button(__('Invoice'), () => me.make_sales_invoice(), __('Create'));
+ this.frm.add_custom_button(__('Sales Invoice'), () => me.make_sales_invoice(), __('Create'));
}
// material request
@@ -554,19 +554,32 @@
},
make_purchase_order: function(){
+ let pending_items = this.frm.doc.items.some((item) =>{
+ let pending_qty = flt(item.stock_qty) - flt(item.ordered_qty);
+ return pending_qty > 0;
+ })
+ if(!pending_items){
+ frappe.throw({message: __("Purchase Order already created for all Sales Order items"), title: __("Note")});
+ }
+
var me = this;
var dialog = new frappe.ui.Dialog({
- title: __("For Supplier"),
+ title: __("Select Items"),
fields: [
- {"fieldtype": "Link", "label": __("Supplier"), "fieldname": "supplier", "options":"Supplier",
- "description": __("Leave the field empty to make purchase orders for all suppliers"),
- "get_query": function () {
- return {
- query:"erpnext.selling.doctype.sales_order.sales_order.get_supplier",
- filters: {'parent': me.frm.doc.name}
- }
- }},
- {fieldname: 'items_for_po', fieldtype: 'Table', label: 'Select Items',
+ {
+ "fieldtype": "Check",
+ "label": __("Against Default Supplier"),
+ "fieldname": "against_default_supplier",
+ "default": 0
+ },
+ {
+ "fieldtype": "Section Break",
+ "label": "",
+ "fieldname": "sec_break_dialog",
+ "hide_border": 1
+ },
+ {
+ fieldname: 'items_for_po', fieldtype: 'Table', label: 'Select Items',
fields: [
{
fieldtype:'Data',
@@ -584,8 +597,8 @@
},
{
fieldtype:'Float',
- fieldname:'qty',
- label: __('Quantity'),
+ fieldname:'pending_qty',
+ label: __('Pending Qty'),
read_only: 1,
in_list_view:1
},
@@ -594,60 +607,86 @@
read_only:1,
fieldname:'uom',
label: __('UOM'),
+ in_list_view:1,
+ },
+ {
+ fieldtype:'Data',
+ fieldname:'supplier',
+ label: __('Supplier'),
+ read_only:1,
in_list_view:1
- }
+ },
],
- data: cur_frm.doc.items,
- get_data: function() {
- return cur_frm.doc.items
- }
- },
-
- {"fieldtype": "Button", "label": __('Create Purchase Order'), "fieldname": "make_purchase_order", "cssClass": "btn-primary"},
- ]
- });
-
- dialog.fields_dict.make_purchase_order.$input.click(function() {
- var args = dialog.get_values();
- let selected_items = dialog.fields_dict.items_for_po.grid.get_selected_children()
- if(selected_items.length == 0) {
- frappe.throw({message: 'Please select Item form Table', title: __('Message'), indicator:'blue'})
- }
- let selected_items_list = []
- for(let i in selected_items){
- selected_items_list.push(selected_items[i].item_code)
- }
- dialog.hide();
- return frappe.call({
- type: "GET",
- method: "erpnext.selling.doctype.sales_order.sales_order.make_purchase_order",
- args: {
- "source_name": me.frm.doc.name,
- "for_supplier": args.supplier,
- "selected_items": selected_items_list
- },
- freeze: true,
- callback: function(r) {
- if(!r.exc) {
- // var args = dialog.get_values();
- if (args.supplier){
- var doc = frappe.model.sync(r.message);
- frappe.set_route("Form", r.message.doctype, r.message.name);
- }
- else{
- frappe.route_options = {
- "sales_order": me.frm.doc.name
- }
- frappe.set_route("List", "Purchase Order");
- }
- }
+ data: me.frm.doc.items.map((item) =>{
+ item.pending_qty = (flt(item.stock_qty) - flt(item.ordered_qty)) / flt(item.conversion_factor);
+ return item;
+ }).filter((item) => {return item.pending_qty > 0;})
}
- })
+ ],
+ primary_action_label: 'Create Purchase Order',
+ primary_action (args) {
+ if (!args) return;
+ let selected_items = dialog.fields_dict.items_for_po.grid.get_selected_children();
+ if(selected_items.length == 0) {
+ frappe.throw({message: 'Please select Items from the Table', title: __('Items Required'), indicator:'blue'})
+ }
+
+ dialog.hide();
+
+ var method = args.against_default_supplier ? "make_purchase_order_for_default_supplier" : "make_purchase_order"
+ return frappe.call({
+ type: "GET",
+ method: "erpnext.selling.doctype.sales_order.sales_order." + method,
+ args: {
+ "source_name": me.frm.doc.name,
+ "selected_items": selected_items
+ },
+ freeze: true,
+ callback: function(r) {
+ if(!r.exc) {
+ if (!args.against_default_supplier) {
+ frappe.model.sync(r.message);
+ frappe.set_route("Form", r.message.doctype, r.message.name);
+ }
+ else {
+ frappe.route_options = {
+ "sales_order": me.frm.doc.name
+ }
+ frappe.set_route("List", "Purchase Order");
+ }
+ }
+ }
+ })
+ }
});
- dialog.get_field("items_for_po").grid.only_sortable()
- dialog.get_field("items_for_po").refresh()
+
+ dialog.fields_dict["against_default_supplier"].df.onchange = () => {
+ console.log("yo");
+ var against_default_supplier = dialog.get_value("against_default_supplier");
+ var items_for_po = dialog.get_value("items_for_po");
+
+ if (against_default_supplier) {
+ let items_with_supplier = items_for_po.filter((item) => item.supplier)
+
+ dialog.fields_dict["items_for_po"].df.data = items_with_supplier;
+ dialog.get_field("items_for_po").refresh();
+ } else {
+ let pending_items = me.frm.doc.items.map((item) =>{
+ item.pending_qty = (flt(item.stock_qty) - flt(item.ordered_qty)) / flt(item.conversion_factor);
+ return item;
+ }).filter((item) => {return item.pending_qty > 0;});
+
+ dialog.fields_dict["items_for_po"].df.data = pending_items;
+ dialog.get_field("items_for_po").refresh();
+ }
+ }
+
+ dialog.get_field("items_for_po").grid.only_sortable();
+ dialog.get_field("items_for_po").refresh();
+ dialog.wrapper.find('.grid-heading-row .grid-row-check').click();
dialog.show();
},
+
hold_sales_order: function(){
var me = this;
var d = new frappe.ui.Dialog({
diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index fe3fa82..ae227e0 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -443,25 +443,19 @@
for item in self.items:
if item.ensure_delivery_based_on_produced_serial_no:
if item.item_code in normal_items:
- frappe.throw(_("Cannot ensure delivery by Serial No as \
- Item {0} is added with and without Ensure Delivery by \
- Serial No.").format(item.item_code))
+ frappe.throw(_("Cannot ensure delivery by Serial No as Item {0} is added with and without Ensure Delivery by Serial No.").format(item.item_code))
if item.item_code not in reserved_items:
if not frappe.get_cached_value("Item", item.item_code, "has_serial_no"):
- frappe.throw(_("Item {0} has no Serial No. Only serilialized items \
- can have delivery based on Serial No").format(item.item_code))
+ frappe.throw(_("Item {0} has no Serial No. Only serilialized items can have delivery based on Serial No").format(item.item_code))
if not frappe.db.exists("BOM", {"item": item.item_code, "is_active": 1}):
- frappe.throw(_("No active BOM found for item {0}. Delivery by \
- Serial No cannot be ensured").format(item.item_code))
+ frappe.throw(_("No active BOM found for item {0}. Delivery by Serial No cannot be ensured").format(item.item_code))
reserved_items.append(item.item_code)
else:
normal_items.append(item.item_code)
if not item.ensure_delivery_based_on_produced_serial_no and \
item.item_code in reserved_items:
- frappe.throw(_("Cannot ensure delivery by Serial No as \
- Item {0} is added with and without Ensure Delivery by \
- Serial No.").format(item.item_code))
+ frappe.throw(_("Cannot ensure delivery by Serial No as Item {0} is added with and without Ensure Delivery by Serial No.").format(item.item_code))
def get_list_context(context=None):
from erpnext.controllers.website_list_for_contact import get_list_context
@@ -785,7 +779,7 @@
return data
@frappe.whitelist()
-def make_purchase_order(source_name, for_supplier=None, selected_items=[], target_doc=None):
+def make_purchase_order_for_default_supplier(source_name, selected_items=[], target_doc=None):
if isinstance(selected_items, string_types):
selected_items = json.loads(selected_items)
@@ -822,24 +816,21 @@
def update_item(source, target, source_parent):
target.schedule_date = source.delivery_date
- target.qty = flt(source.qty) - flt(source.ordered_qty)
- target.stock_qty = (flt(source.qty) - flt(source.ordered_qty)) * flt(source.conversion_factor)
+ target.qty = flt(source.qty) - (flt(source.ordered_qty) / flt(source.conversion_factor))
+ target.stock_qty = (flt(source.stock_qty) - flt(source.ordered_qty))
target.project = source_parent.project
- suppliers =[]
- if for_supplier:
- suppliers.append(for_supplier)
- else:
- sales_order = frappe.get_doc("Sales Order", source_name)
- for item in sales_order.items:
- if item.supplier and item.supplier not in suppliers:
- suppliers.append(item.supplier)
+ suppliers = [item.get('supplier') for item in selected_items if item.get('supplier') and item.get('supplier')]
+ suppliers = list(set(suppliers))
+
+ items_to_map = [item.get('item_code') for item in selected_items if item.get('item_code') and item.get('item_code')]
+ items_to_map = list(set(items_to_map))
if not suppliers:
frappe.throw(_("Please set a Supplier against the Items to be considered in the Purchase Order."))
for supplier in suppliers:
- po =frappe.get_list("Purchase Order", filters={"sales_order":source_name, "supplier":supplier, "docstatus": ("<", "2")})
+ po = frappe.get_list("Purchase Order", filters={"sales_order":source_name, "supplier":supplier, "docstatus": ("<", "2")})
if len(po) == 0:
doc = get_mapped_doc("Sales Order", source_name, {
"Sales Order": {
@@ -850,7 +841,8 @@
"contact_mobile",
"contact_email",
"contact_person",
- "taxes_and_charges"
+ "taxes_and_charges",
+ "shipping_address"
],
"validation": {
"docstatus": ["=", 1]
@@ -872,52 +864,82 @@
"item_tax_template"
],
"postprocess": update_item,
- "condition": lambda doc: doc.ordered_qty < doc.qty and doc.supplier == supplier and doc.item_code in selected_items
+ "condition": lambda doc: doc.ordered_qty < doc.stock_qty and doc.supplier == supplier and doc.item_code in items_to_map
}
}, target_doc, set_missing_values)
- if not for_supplier:
- doc.insert()
+
+ doc.insert()
else:
suppliers =[]
if suppliers:
- if not for_supplier:
- frappe.db.commit()
+ frappe.db.commit()
return doc
else:
- frappe.msgprint(_("PO already created for all sales order items"))
-
+ frappe.msgprint(_("Purchase Order already created for all Sales Order items"))
@frappe.whitelist()
-@frappe.validate_and_sanitize_search_inputs
-def get_supplier(doctype, txt, searchfield, start, page_len, filters):
- supp_master_name = frappe.defaults.get_user_default("supp_master_name")
- if supp_master_name == "Supplier Name":
- fields = ["name", "supplier_group"]
- else:
- fields = ["name", "supplier_name", "supplier_group"]
- fields = ", ".join(fields)
+def make_purchase_order(source_name, selected_items=[], target_doc=None):
+ if isinstance(selected_items, string_types):
+ selected_items = json.loads(selected_items)
- return frappe.db.sql("""select {field} from `tabSupplier`
- where docstatus < 2
- and ({key} like %(txt)s
- or supplier_name like %(txt)s)
- and name in (select supplier from `tabSales Order Item` where parent = %(parent)s)
- and name not in (select supplier from `tabPurchase Order` po inner join `tabPurchase Order Item` poi
- on po.name=poi.parent where po.docstatus<2 and poi.sales_order=%(parent)s)
- order by
- if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999),
- if(locate(%(_txt)s, supplier_name), locate(%(_txt)s, supplier_name), 99999),
- name, supplier_name
- limit %(start)s, %(page_len)s """.format(**{
- 'field': fields,
- 'key': frappe.db.escape(searchfield)
- }), {
- 'txt': "%%%s%%" % txt,
- '_txt': txt.replace("%", ""),
- 'start': start,
- 'page_len': page_len,
- 'parent': filters.get('parent')
- })
+ items_to_map = [item.get('item_code') for item in selected_items if item.get('item_code') and item.get('item_code')]
+ items_to_map = list(set(items_to_map))
+
+ def set_missing_values(source, target):
+ target.supplier = ""
+ target.apply_discount_on = ""
+ target.additional_discount_percentage = 0.0
+ target.discount_amount = 0.0
+ target.inter_company_order_reference = ""
+ target.customer = ""
+ target.customer_name = ""
+ target.run_method("set_missing_values")
+ target.run_method("calculate_taxes_and_totals")
+
+ def update_item(source, target, source_parent):
+ target.schedule_date = source.delivery_date
+ target.qty = flt(source.qty) - (flt(source.ordered_qty) / flt(source.conversion_factor))
+ target.stock_qty = (flt(source.stock_qty) - flt(source.ordered_qty))
+ target.project = source_parent.project
+
+ # po = frappe.get_list("Purchase Order", filters={"sales_order":source_name, "supplier":supplier, "docstatus": ("<", "2")})
+ doc = get_mapped_doc("Sales Order", source_name, {
+ "Sales Order": {
+ "doctype": "Purchase Order",
+ "field_no_map": [
+ "address_display",
+ "contact_display",
+ "contact_mobile",
+ "contact_email",
+ "contact_person",
+ "taxes_and_charges",
+ "shipping_address"
+ ],
+ "validation": {
+ "docstatus": ["=", 1]
+ }
+ },
+ "Sales Order Item": {
+ "doctype": "Purchase Order Item",
+ "field_map": [
+ ["name", "sales_order_item"],
+ ["parent", "sales_order"],
+ ["stock_uom", "stock_uom"],
+ ["uom", "uom"],
+ ["conversion_factor", "conversion_factor"],
+ ["delivery_date", "schedule_date"]
+ ],
+ "field_no_map": [
+ "rate",
+ "price_list_rate",
+ "item_tax_template",
+ "supplier"
+ ],
+ "postprocess": update_item,
+ "condition": lambda doc: doc.ordered_qty < doc.stock_qty and doc.item_code in items_to_map
+ }
+ }, target_doc, set_missing_values)
+ return doc
@frappe.whitelist()
def make_work_orders(items, sales_order, company, project=None):
diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py
index 2f5f979..9e25ed0 100644
--- a/erpnext/selling/doctype/sales_order/test_sales_order.py
+++ b/erpnext/selling/doctype/sales_order/test_sales_order.py
@@ -688,12 +688,12 @@
frappe.db.set_value("Stock Settings", None, "auto_insert_price_list_rate_if_missing", 1)
def test_drop_shipping(self):
- from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order
+ from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order_for_default_supplier, \
+ update_status as so_update_status
from erpnext.buying.doctype.purchase_order.purchase_order import update_status
- make_stock_entry(target="_Test Warehouse - _TC", qty=10, rate=100)
+ # make items
po_item = make_item("_Test Item for Drop Shipping", {"is_stock_item": 1, "delivered_by_supplier": 1})
-
dn_item = make_item("_Test Regular Item", {"is_stock_item": 1})
so_items = [
@@ -715,80 +715,61 @@
]
if frappe.db.get_value("Item", "_Test Regular Item", "is_stock_item")==1:
- make_stock_entry(item="_Test Regular Item", target="_Test Warehouse - _TC", qty=10, rate=100)
+ make_stock_entry(item="_Test Regular Item", target="_Test Warehouse - _TC", qty=2, rate=100)
- #setuo existing qty from bin
- bin = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"},
- fields=["ordered_qty", "reserved_qty"])
-
- existing_ordered_qty = bin[0].ordered_qty if bin else 0.0
- existing_reserved_qty = bin[0].reserved_qty if bin else 0.0
-
- bin = frappe.get_all("Bin", filters={"item_code": dn_item.item_code,
- "warehouse": "_Test Warehouse - _TC"}, fields=["reserved_qty"])
-
- existing_reserved_qty_for_dn_item = bin[0].reserved_qty if bin else 0.0
-
- #create so, po and partial dn
+ #create so, po and dn
so = make_sales_order(item_list=so_items, do_not_submit=True)
so.submit()
- po = make_purchase_order(so.name, '_Test Supplier', selected_items=[so_items[0]['item_code']])
+ po = make_purchase_order_for_default_supplier(so.name, selected_items=[so_items[0]])
po.submit()
- dn = create_dn_against_so(so.name, delivered_qty=1)
+ dn = create_dn_against_so(so.name, delivered_qty=2)
self.assertEqual(so.customer, po.customer)
self.assertEqual(po.items[0].sales_order, so.name)
self.assertEqual(po.items[0].item_code, po_item.item_code)
self.assertEqual(dn.items[0].item_code, dn_item.item_code)
-
- #test ordered_qty and reserved_qty
- bin = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"},
- fields=["ordered_qty", "reserved_qty"])
-
- ordered_qty = bin[0].ordered_qty if bin else 0.0
- reserved_qty = bin[0].reserved_qty if bin else 0.0
-
- self.assertEqual(abs(flt(ordered_qty)), existing_ordered_qty)
- self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty)
-
- reserved_qty = frappe.db.get_value("Bin",
- {"item_code": dn_item.item_code, "warehouse": "_Test Warehouse - _TC"}, "reserved_qty")
-
- self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty_for_dn_item + 1)
-
#test po_item length
self.assertEqual(len(po.items), 1)
- #test per_delivered status
+ # test ordered_qty and reserved_qty for drop ship item
+ bin_po_item = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"},
+ fields=["ordered_qty", "reserved_qty"])
+
+ ordered_qty = bin_po_item[0].ordered_qty if bin_po_item else 0.0
+ reserved_qty = bin_po_item[0].reserved_qty if bin_po_item else 0.0
+
+ # drop ship PO should not impact bin, test the same
+ self.assertEqual(abs(flt(ordered_qty)), 0)
+ self.assertEqual(abs(flt(reserved_qty)), 0)
+
+ # test per_delivered status
update_status("Delivered", po.name)
- self.assertEqual(flt(frappe.db.get_value("Sales Order", so.name, "per_delivered"), 2), 75.00)
+ self.assertEqual(flt(frappe.db.get_value("Sales Order", so.name, "per_delivered"), 2), 100.00)
+ po.load_from_db()
- #test reserved qty after complete delivery
- dn = create_dn_against_so(so.name, delivered_qty=1)
- reserved_qty = frappe.db.get_value("Bin",
- {"item_code": dn_item.item_code, "warehouse": "_Test Warehouse - _TC"}, "reserved_qty")
-
- self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty_for_dn_item)
-
- #test after closing so
+ # test after closing so
so.db_set('status', "Closed")
so.update_reserved_qty()
- bin = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"},
+ # test ordered_qty and reserved_qty for drop ship item after closing so
+ bin_po_item = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"},
fields=["ordered_qty", "reserved_qty"])
- ordered_qty = bin[0].ordered_qty if bin else 0.0
- reserved_qty = bin[0].reserved_qty if bin else 0.0
+ ordered_qty = bin_po_item[0].ordered_qty if bin_po_item else 0.0
+ reserved_qty = bin_po_item[0].reserved_qty if bin_po_item else 0.0
- self.assertEqual(abs(flt(ordered_qty)), existing_ordered_qty)
- self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty)
+ self.assertEqual(abs(flt(ordered_qty)), 0)
+ self.assertEqual(abs(flt(reserved_qty)), 0)
- reserved_qty = frappe.db.get_value("Bin",
- {"item_code": dn_item.item_code, "warehouse": "_Test Warehouse - _TC"}, "reserved_qty")
-
- self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty_for_dn_item)
+ # teardown
+ so_update_status("Draft", so.name)
+ dn.load_from_db()
+ dn.cancel()
+ po.cancel()
+ so.load_from_db()
+ so.cancel()
def test_reserved_qty_for_closing_so(self):
bin = frappe.get_all("Bin", filters={"item_code": "_Test Item", "warehouse": "_Test Warehouse - _TC"},