Merge pull request #25221 from Alchez/dev-quality-inspection-accounts

feat: create Quality Inspections from account and stock documents
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index f88e8df..401dfdf 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -225,7 +225,7 @@
 
 	def validate_date_with_fiscal_year(self):
 		if self.meta.get_field("fiscal_year"):
-			date_field = ""
+			date_field = None
 			if self.meta.get_field("posting_date"):
 				date_field = "posting_date"
 			elif self.meta.get_field("transaction_date"):
@@ -1449,6 +1449,7 @@
 	for d in deleted_children:
 		update_bin_on_delete(d, parent.doctype)
 
+
 @frappe.whitelist()
 def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, child_docname="items"):
 	def check_doc_permissions(doc, perm_type='create'):
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index 41ca404..0da723d 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -1,17 +1,21 @@
 # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
 # License: GNU General Public License v3. See license.txt
 
-from __future__ import unicode_literals
-import frappe, erpnext
-from frappe.utils import cint, flt, cstr, get_link_to_form, today, getdate
-from frappe import _
-import frappe.defaults
+import json
 from collections import defaultdict
-from erpnext.accounts.utils import get_fiscal_year, check_if_stock_and_account_balance_synced
+
+import frappe
+import frappe.defaults
+from frappe import _
+from frappe.utils import cint, cstr, flt, get_link_to_form, getdate
+
+import erpnext
 from erpnext.accounts.general_ledger import make_gl_entries, make_reverse_gl_entries, process_gl_map
+from erpnext.accounts.utils import check_if_stock_and_account_balance_synced, get_fiscal_year
 from erpnext.controllers.accounts_controller import AccountsController
-from erpnext.stock.stock_ledger import get_valuation_rate
 from erpnext.stock import get_warehouse_account_map
+from erpnext.stock.stock_ledger import get_valuation_rate
+
 
 class QualityInspectionRequiredError(frappe.ValidationError): pass
 class QualityInspectionRejectedError(frappe.ValidationError): pass
@@ -189,7 +193,6 @@
 		if hasattr(self, "items"):
 			item_doclist = self.get("items")
 		elif self.doctype == "Stock Reconciliation":
-			import json
 			item_doclist = []
 			data = json.loads(self.reconciliation_json)
 			for row in data[data.index(self.head_row)+1:]:
@@ -319,7 +322,7 @@
 		return serialized_items
 
 	def validate_warehouse(self):
-		from erpnext.stock.utils import validate_warehouse_company, validate_disabled_warehouse
+		from erpnext.stock.utils import validate_disabled_warehouse, validate_warehouse_company
 
 		warehouses = list(set([d.warehouse for d in
 			self.get("items") if getattr(d, "warehouse", None)]))
@@ -498,6 +501,39 @@
 			check_if_stock_and_account_balance_synced(self.posting_date,
 				self.company, self.doctype, self.name)
 
+
+@frappe.whitelist()
+def make_quality_inspections(doctype, docname, items):
+	if isinstance(items, str):
+		items = json.loads(items)
+
+	inspections = []
+	for item in items:
+		if flt(item.get("sample_size")) > flt(item.get("qty")):
+			frappe.throw(_("{item_name}'s Sample Size ({sample_size}) cannot be greater than the Accepted Quantity ({accepted_quantity})").format(
+				item_name=item.get("item_name"),
+				sample_size=item.get("sample_size"),
+				accepted_quantity=item.get("qty")
+			))
+
+		quality_inspection = frappe.get_doc({
+			"doctype": "Quality Inspection",
+			"inspection_type": "Incoming",
+			"inspected_by": frappe.session.user,
+			"reference_type": doctype,
+			"reference_name": docname,
+			"item_code": item.get("item_code"),
+			"description": item.get("description"),
+			"sample_size": flt(item.get("sample_size")),
+			"item_serial_no": item.get("serial_no").split("\n")[0] if item.get("serial_no") else None,
+			"batch_no": item.get("batch_no")
+		}).insert()
+		quality_inspection.save()
+		inspections.append(quality_inspection.name)
+
+	return inspections
+
+
 def is_reposting_pending():
 	return frappe.db.exists("Repost Item Valuation",
 		{'docstatus': 1, 'status': ['in', ['Queued','In Progress']]})
diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js
index ad1976d..982b1fe 100644
--- a/erpnext/public/js/controllers/transaction.js
+++ b/erpnext/public/js/controllers/transaction.js
@@ -261,11 +261,19 @@
 		if(!in_list(["Delivery Note", "Sales Invoice", "Purchase Receipt", "Purchase Invoice"], this.frm.doc.doctype)) {
 			return;
 		}
-		var me = this;
-		var inspection_type = in_list(["Purchase Receipt", "Purchase Invoice"], this.frm.doc.doctype)
+
+		const me = this;
+		if (!this.frm.is_new() && this.frm.doc.docstatus === 0) {
+			this.frm.add_custom_button(__("Quality Inspection(s)"), () => {
+				me.make_quality_inspection();
+			}, __("Create"));
+			this.frm.page.set_inner_btn_group_as_primary(__('Create'));
+		}
+
+		const inspection_type = in_list(["Purchase Receipt", "Purchase Invoice"], this.frm.doc.doctype)
 			? "Incoming" : "Outgoing";
 
-		var quality_inspection_field = this.frm.get_docfield("items", "quality_inspection");
+		let quality_inspection_field = this.frm.get_docfield("items", "quality_inspection");
 		quality_inspection_field.get_route_options_for_new_doc = function(row) {
 			if(me.frm.is_new()) return;
 			return {
@@ -280,7 +288,7 @@
 		}
 
 		this.frm.set_query("quality_inspection", "items", function(doc, cdt, cdn) {
-			var d = locals[cdt][cdn];
+			let d = locals[cdt][cdn];
 			return {
 				filters: {
 					docstatus: 1,
@@ -1949,6 +1957,130 @@
 		});
 	},
 
+	make_quality_inspection: function () {
+		let data = [];
+		const fields = [
+			{
+				label: "Items",
+				fieldtype: "Table",
+				fieldname: "items",
+				cannot_add_rows: true,
+				in_place_edit: true,
+				data: data,
+				get_data: () => {
+					return data;
+				},
+				fields: [
+					{
+						fieldtype: "Data",
+						fieldname: "docname",
+						hidden: true
+					},
+					{
+						fieldtype: "Read Only",
+						fieldname: "item_code",
+						label: __("Item Code"),
+						in_list_view: true
+					},
+					{
+						fieldtype: "Read Only",
+						fieldname: "item_name",
+						label: __("Item Name"),
+						in_list_view: true
+					},
+					{
+						fieldtype: "Float",
+						fieldname: "qty",
+						label: __("Accepted Quantity"),
+						in_list_view: true,
+						read_only: true
+					},
+					{
+						fieldtype: "Float",
+						fieldname: "sample_size",
+						label: __("Sample Size"),
+						reqd: true,
+						in_list_view: true
+					},
+					{
+						fieldtype: "Data",
+						fieldname: "description",
+						label: __("Description"),
+						hidden: true
+					},
+					{
+						fieldtype: "Data",
+						fieldname: "serial_no",
+						label: __("Serial No"),
+						hidden: true
+					},
+					{
+						fieldtype: "Data",
+						fieldname: "batch_no",
+						label: __("Batch No"),
+						hidden: true
+					}
+				]
+			}
+		];
+
+		const me = this;
+		const dialog = new frappe.ui.Dialog({
+			title: __("Select Items for Quality Inspection"),
+			fields: fields,
+			primary_action: function () {
+				const data = dialog.get_values();
+				frappe.call({
+					method: "erpnext.controllers.stock_controller.make_quality_inspections",
+					args: {
+						doctype: me.frm.doc.doctype,
+						docname: me.frm.doc.name,
+						items: data.items
+					},
+					freeze: true,
+					callback: function (r) {
+						if (r.message.length > 0) {
+							if (r.message.length === 1) {
+								frappe.set_route("Form", "Quality Inspection", r.message[0]);
+							} else {
+								frappe.route_options = {
+									"reference_type": me.frm.doc.doctype,
+									"reference_name": me.frm.doc.name
+								};
+								frappe.set_route("List", "Quality Inspection");
+							}
+						}
+						dialog.hide();
+					}
+				});
+			},
+			primary_action_label: __("Create")
+		});
+
+		this.frm.doc.items.forEach(item => {
+			if (!item.quality_inspection) {
+				let dialog_items = dialog.fields_dict.items;
+				dialog_items.df.data.push({
+					"docname": item.name,
+					"item_code": item.item_code,
+					"item_name": item.item_name,
+					"qty": item.qty,
+					"description": item.description,
+					"serial_no": item.serial_no,
+					"batch_no": item.batch_no
+				});
+				dialog_items.grid.refresh();
+			}
+		});
+
+		data = dialog.fields_dict.items.df.data;
+		if (!data.length) {
+			frappe.msgprint(__("All items in this document already have a linked Quality Inspection."));
+		} else {
+			dialog.show();
+		}
+	},
+
 	get_method_for_payment: function(){
 		var method = "erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry";
 		if(cur_frm.doc.__onload && cur_frm.doc.__onload.make_payment_via_journal_entry){
diff --git a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py
index 56b046a..7f3d701 100644
--- a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py
+++ b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py
@@ -1,29 +1,45 @@
 # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors
 # See license.txt
 
-from __future__ import unicode_literals
-import frappe
 import unittest
+
+import frappe
 from frappe.utils import nowdate
-from erpnext.stock.doctype.item.test_item import create_item
+
+from erpnext.controllers.stock_controller import (
+	QualityInspectionNotSubmittedError,
+	QualityInspectionRejectedError,
+	QualityInspectionRequiredError,
+	make_quality_inspections,
+)
 from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
+from erpnext.stock.doctype.item.test_item import create_item
 from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
-from erpnext.controllers.stock_controller import QualityInspectionRejectedError, QualityInspectionRequiredError, QualityInspectionNotSubmittedError
 
 # test_records = frappe.get_test_records('Quality Inspection')
 
+
 class TestQualityInspection(unittest.TestCase):
 	def setUp(self):
 		create_item("_Test Item with QA")
-		frappe.db.set_value("Item", "_Test Item with QA", "inspection_required_before_delivery", 1)
+		frappe.db.set_value(
+			"Item", "_Test Item with QA", "inspection_required_before_delivery", 1
+		)
 
 	def test_qa_for_delivery(self):
-		make_stock_entry(item_code="_Test Item with QA", target="_Test Warehouse - _TC", qty=1, basic_rate=100)
+		make_stock_entry(
+			item_code="_Test Item with QA",
+			target="_Test Warehouse - _TC",
+			qty=1,
+			basic_rate=100
+		)
 		dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True)
 
 		self.assertRaises(QualityInspectionRequiredError, dn.submit)
 
-		qa = create_quality_inspection(reference_type="Delivery Note", reference_name=dn.name, status="Rejected")
+		qa = create_quality_inspection(
+			reference_type="Delivery Note", reference_name=dn.name, status="Rejected"
+		)
 		dn.reload()
 		self.assertRaises(QualityInspectionRejectedError, dn.submit)
 
@@ -38,7 +54,9 @@
 
 	def test_qa_not_submit(self):
 		dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True)
-		qa = create_quality_inspection(reference_type="Delivery Note", reference_name=dn.name, do_not_submit=True)
+		qa = create_quality_inspection(
+			reference_type="Delivery Note", reference_name=dn.name, do_not_submit=True
+		)
 		dn.items[0].quality_inspection = qa.name
 		self.assertRaises(QualityInspectionNotSubmittedError, dn.submit)
 
@@ -48,21 +66,28 @@
 	def test_value_based_qi_readings(self):
 		# Test QI based on acceptance values (Non formula)
 		dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True)
-		readings = [{
-			"specification": "Iron Content", # numeric reading
-			"min_value": 0.1,
-			"max_value": 0.9,
-			"reading_1": "0.4"
-		},
-		{
-			"specification": "Particle Inspection Needed", # non-numeric reading
-			"numeric": 0,
-			"value": "Yes",
-			"reading_value": "Yes"
-		}]
+		readings = [
+			{
+				"specification": "Iron Content",  # numeric reading
+				"min_value": 0.1,
+				"max_value": 0.9,
+				"reading_1": "0.4"
+			},
+			{
+				"specification": "Particle Inspection Needed",  # non-numeric reading
+				"numeric": 0,
+				"value": "Yes",
+				"reading_value": "Yes"
+			}
+		]
 
-		qa = create_quality_inspection(reference_type="Delivery Note", reference_name=dn.name,
-			readings=readings, do_not_save=True)
+		qa = create_quality_inspection(
+			reference_type="Delivery Note",
+			reference_name=dn.name,
+			readings=readings,
+			do_not_save=True
+		)
+
 		qa.save()
 
 		# status must be auto set as per formula
@@ -74,36 +99,43 @@
 
 	def test_formula_based_qi_readings(self):
 		dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True)
-		readings = [{
-			"specification": "Iron Content", # numeric reading
-			"formula_based_criteria": 1,
-			"acceptance_formula": "reading_1 > 0.35 and reading_1 < 0.50",
-			"reading_1": "0.4"
-		},
-		{
-			"specification": "Calcium Content", # numeric reading
-			"formula_based_criteria": 1,
-			"acceptance_formula": "reading_1 > 0.20 and reading_1 < 0.50",
-			"reading_1": "0.7"
-		},
-		{
-			"specification": "Mg Content", # numeric reading
-			"formula_based_criteria": 1,
-			"acceptance_formula": "mean < 0.9",
-			"reading_1": "0.5",
-			"reading_2": "0.7",
-			"reading_3": "random text" # check if random string input causes issues
-		},
-		{
-			"specification": "Calcium Content", # non-numeric reading
-			"formula_based_criteria": 1,
-			"numeric": 0,
-			"acceptance_formula": "reading_value in ('Grade A', 'Grade B', 'Grade C')",
-			"reading_value": "Grade B"
-		}]
+		readings = [
+			{
+				"specification": "Iron Content",  # numeric reading
+				"formula_based_criteria": 1,
+				"acceptance_formula": "reading_1 > 0.35 and reading_1 < 0.50",
+				"reading_1": "0.4"
+			},
+			{
+				"specification": "Calcium Content",  # numeric reading
+				"formula_based_criteria": 1,
+				"acceptance_formula": "reading_1 > 0.20 and reading_1 < 0.50",
+				"reading_1": "0.7"
+			},
+			{
+				"specification": "Mg Content",  # numeric reading
+				"formula_based_criteria": 1,
+				"acceptance_formula": "mean < 0.9",
+				"reading_1": "0.5",
+				"reading_2": "0.7",
+				"reading_3": "random text"  # check if random string input causes issues
+			},
+			{
+				"specification": "Calcium Content",  # non-numeric reading
+				"formula_based_criteria": 1,
+				"numeric": 0,
+				"acceptance_formula": "reading_value in ('Grade A', 'Grade B', 'Grade C')",
+				"reading_value": "Grade B"
+			}
+		]
 
-		qa = create_quality_inspection(reference_type="Delivery Note", reference_name=dn.name,
-			readings=readings, do_not_save=True)
+		qa = create_quality_inspection(
+			reference_type="Delivery Note",
+			reference_name=dn.name,
+			readings=readings,
+			do_not_save=True
+		)
+
 		qa.save()
 
 		# status must be auto set as per formula
@@ -115,6 +147,19 @@
 		qa.delete()
 		dn.delete()
 
+	def test_make_quality_inspections_from_linked_document(self):
+		dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True)
+		for item in dn.items:
+			item.sample_size = item.qty
+		quality_inspections = make_quality_inspections(dn.doctype, dn.name, dn.items)
+		self.assertEqual(len(dn.items), len(quality_inspections))
+
+		# cleanup
+		for qi in quality_inspections:
+			frappe.delete_doc("Quality Inspection", qi)
+		dn.delete()
+
+
 def create_quality_inspection(**args):
 	args = frappe._dict(args)
 	qa = frappe.new_doc("Quality Inspection")
@@ -134,7 +179,7 @@
 		readings = args.readings
 
 	if args.status == "Rejected":
-		readings["reading_1"] = "12" # status is auto set in child on save
+		readings["reading_1"] = "12"  # status is auto set in child on save
 
 	if isinstance(readings, list):
 		for entry in readings:
@@ -150,10 +195,11 @@
 
 	return qa
 
+
 def create_quality_inspection_parameter(parameter):
 	if not frappe.db.exists("Quality Inspection Parameter", parameter):
 		frappe.get_doc({
 			"doctype": "Quality Inspection Parameter",
 			"parameter": parameter,
 			"description": parameter
-		}).insert()
\ No newline at end of file
+		}).insert()
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js
index de23e76..93a6fc0 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.js
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.js
@@ -115,6 +115,14 @@
 			return;
 		}
 
+		if (!frm.is_new() && frm.doc.docstatus === 0) {
+			frm.add_custom_button(__("Quality Inspection(s)"), () => {
+				let transaction_controller = new erpnext.TransactionController({ frm: frm });
+				transaction_controller.make_quality_inspection();
+			}, __("Create"));
+			frm.page.set_inner_btn_group_as_primary(__('Create'));
+		}
+
 		let quality_inspection_field = frm.get_docfield("items", "quality_inspection");
 		quality_inspection_field.get_route_options_for_new_doc = function(row) {
 			if (frm.is_new()) return;
@@ -155,7 +163,7 @@
 	refresh: function(frm) {
 		if(!frm.doc.docstatus) {
 			frm.trigger('validate_purpose_consumption');
-			frm.add_custom_button(__('Create Material Request'), function() {
+			frm.add_custom_button(__('Material Request'), function() {
 				frappe.model.with_doctype('Material Request', function() {
 					var mr = frappe.model.get_new_doc('Material Request');
 					var items = frm.get_field('items').grid.get_selected_children();
@@ -178,7 +186,7 @@
 					});
 					frappe.set_route('Form', 'Material Request', mr.name);
 				});
-			});
+			}, __("Create"));
 		}
 
 		if(frm.doc.items) {