Merge branch 'develop' into manufacturing-work-order-stop
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js
index 35be388..e3eed92 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.js
+++ b/erpnext/manufacturing/doctype/job_card/job_card.js
@@ -28,6 +28,11 @@
 		frappe.flags.resume_job = 0;
 		let has_items = frm.doc.items && frm.doc.items.length;
 
+		if (frm.doc.__onload.work_order_stopped) {
+			frm.disable_save();
+			return;
+		}
+
 		if (!frm.doc.__islocal && has_items && frm.doc.docstatus < 2) {
 			let to_request = frm.doc.for_quantity > frm.doc.transferred_qty;
 			let excess_transfer_allowed = frm.doc.__onload.job_card_excess_transfer;
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py
index ff4feaa..f0f18cf 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/job_card.py
@@ -36,6 +36,7 @@
 	def onload(self):
 		excess_transfer = frappe.db.get_single_value("Manufacturing Settings", "job_card_excess_transfer")
 		self.set_onload("job_card_excess_transfer", excess_transfer)
+		self.set_onload("work_order_stopped", self.is_work_order_stopped())
 
 	def validate(self):
 		self.validate_time_logs()
@@ -44,6 +45,7 @@
 		self.validate_sequence_id()
 		self.set_sub_operations()
 		self.update_sub_operation_status()
+		self.validate_work_order()
 
 	def set_sub_operations(self):
 		if self.operation:
@@ -548,6 +550,18 @@
 				frappe.throw(_("{0}, complete the operation {1} before the operation {2}.")
 					.format(message, bold(row.operation), bold(self.operation)), OperationSequenceError)
 
+	def validate_work_order(self):
+		if self.is_work_order_stopped():
+			frappe.throw(_("You can't make any changes to Job Card since Work Order is stopped."))
+
+	def is_work_order_stopped(self):
+		if self.work_order:
+			status = frappe.get_value('Work Order', self.work_order)
+
+			if status == "Closed":
+				return True
+
+		return False
 
 @frappe.whitelist()
 def make_time_log(args):
diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py
index 85b5bfb..f4a88dc 100644
--- a/erpnext/manufacturing/doctype/work_order/test_work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py
@@ -12,6 +12,7 @@
 	ItemHasVariantError,
 	OverProductionError,
 	StockOverProductionError,
+	close_work_order,
 	make_stock_entry,
 	stop_unstop,
 )
@@ -800,6 +801,46 @@
 			if row.is_scrap_item:
 				self.assertEqual(row.qty, 1)
 
+	def test_close_work_order(self):
+		items = ['Test FG Item for Closed WO', 'Test RM Item 1 for Closed WO',
+			'Test RM Item 2 for Closed WO']
+
+		company = '_Test Company with perpetual inventory'
+		for item_code in items:
+			create_item(item_code = item_code, is_stock_item = 1,
+				is_purchase_item=1, opening_stock=100, valuation_rate=10, company=company, warehouse='Stores - TCP1')
+
+		item = 'Test FG Item for Closed WO'
+		raw_materials = ['Test RM Item 1 for Closed WO', 'Test RM Item 2 for Closed WO']
+		if not frappe.db.get_value('BOM', {'item': item}):
+			bom = make_bom(item=item, source_warehouse='Stores - TCP1', raw_materials=raw_materials, do_not_save=True)
+			bom.with_operations = 1
+			bom.append('operations', {
+				'operation': '_Test Operation 1',
+				'workstation': '_Test Workstation 1',
+				'hour_rate': 20,
+				'time_in_mins': 60
+			})
+
+			bom.submit()
+
+		wo_order = make_wo_order_test_record(item=item, company=company, planned_start_date=now(), qty=20, skip_transfer=1)
+		job_cards = frappe.db.get_value('Job Card', {'work_order': wo_order.name}, 'name')
+
+		if len(job_cards) == len(bom.operations):
+			for jc in job_cards:
+				job_card_doc = frappe.get_doc('Job Card', jc)
+				job_card_doc.append('time_logs', {
+					'from_time': now(),
+					'time_in_mins': 60,
+					'completed_qty': job_card_doc.for_quantity
+				})
+
+				job_card_doc.submit()
+
+			close_work_order(wo_order, "Closed")
+			self.assertEqual(wo_order.get('status'), "Closed")
+
 def update_job_card(job_card):
 	job_card_doc = frappe.get_doc('Job Card', job_card)
 	job_card_doc.set('scrap_items', [
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js
index 51c46f6..bfce1b8 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.js
+++ b/erpnext/manufacturing/doctype/work_order/work_order.js
@@ -135,24 +135,26 @@
 			frm.set_intro(__("Submit this Work Order for further processing."));
 		}
 
-		if (frm.doc.docstatus===1) {
-			frm.trigger('show_progress_for_items');
-			frm.trigger('show_progress_for_operations');
-		}
+		if (frm.doc.status != "Closed") {
+			if (frm.doc.docstatus===1) {
+				frm.trigger('show_progress_for_items');
+				frm.trigger('show_progress_for_operations');
+			}
 
-		if (frm.doc.docstatus === 1
-			&& frm.doc.operations && frm.doc.operations.length) {
+			if (frm.doc.docstatus === 1
+				&& frm.doc.operations && frm.doc.operations.length) {
 
-			const not_completed = frm.doc.operations.filter(d => {
-				if(d.status != 'Completed') {
-					return true;
+				const not_completed = frm.doc.operations.filter(d => {
+					if (d.status != 'Completed') {
+						return true;
+					}
+				});
+
+				if (not_completed && not_completed.length) {
+					frm.add_custom_button(__('Create Job Card'), () => {
+						frm.trigger("make_job_card");
+					}).addClass('btn-primary');
 				}
-			});
-
-			if(not_completed && not_completed.length) {
-				frm.add_custom_button(__('Create Job Card'), () => {
-					frm.trigger("make_job_card");
-				}).addClass('btn-primary');
 			}
 		}
 
@@ -517,14 +519,22 @@
 erpnext.work_order = {
 	set_custom_buttons: function(frm) {
 		var doc = frm.doc;
-		if (doc.docstatus === 1) {
+		if (doc.docstatus === 1 && doc.status != "Closed") {
+			frm.add_custom_button(__('Close'), function() {
+				frappe.confirm(__("Once the Work Order is Closed. It can't be resumed."),
+					() => {
+						erpnext.work_order.change_work_order_status(frm, "Closed");
+					}
+				);
+			}, __("Status"));
+
 			if (doc.status != 'Stopped' && doc.status != 'Completed') {
 				frm.add_custom_button(__('Stop'), function() {
-					erpnext.work_order.stop_work_order(frm, "Stopped");
+					erpnext.work_order.change_work_order_status(frm, "Stopped");
 				}, __("Status"));
 			} else if (doc.status == 'Stopped') {
 				frm.add_custom_button(__('Re-open'), function() {
-					erpnext.work_order.stop_work_order(frm, "Resumed");
+					erpnext.work_order.change_work_order_status(frm, "Resumed");
 				}, __("Status"));
 			}
 
@@ -713,9 +723,10 @@
 		});
 	},
 
-	stop_work_order: function(frm, status) {
+	change_work_order_status: function(frm, status) {
+		let method_name = status=="Closed" ? "close_work_order" : "stop_unstop";
 		frappe.call({
-			method: "erpnext.manufacturing.doctype.work_order.work_order.stop_unstop",
+			method: `erpnext.manufacturing.doctype.work_order.work_order.${method_name}`,
 			freeze: true,
 			freeze_message: __("Updating Work Order status"),
 			args: {
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json
index 7f8e816..df7ee53 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.json
+++ b/erpnext/manufacturing/doctype/work_order/work_order.json
@@ -99,7 +99,7 @@
    "no_copy": 1,
    "oldfieldname": "status",
    "oldfieldtype": "Select",
-   "options": "\nDraft\nSubmitted\nNot Started\nIn Process\nCompleted\nStopped\nCancelled",
+   "options": "\nDraft\nSubmitted\nNot Started\nIn Process\nCompleted\nStopped\nClosed\nCancelled",
    "read_only": 1,
    "reqd": 1,
    "search_index": 1
@@ -573,7 +573,8 @@
  "image_field": "image",
  "is_submittable": 1,
  "links": [],
- "modified": "2021-10-27 19:21:35.139888",
+ "migration_hash": "a18118963f4fcdb7f9d326de5f4063ba",
+ "modified": "2021-10-29 15:12:32.203605",
  "modified_by": "Administrator",
  "module": "Manufacturing",
  "name": "Work Order",
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index f881e1b..0090f4d 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -175,7 +175,7 @@
 
 	def update_status(self, status=None):
 		'''Update status of work order if unknown'''
-		if status != "Stopped":
+		if status != "Stopped" and status != "Closed":
 			status = self.get_status(status)
 
 		if status != self.status:
@@ -624,7 +624,6 @@
 	def validate_operation_time(self):
 		for d in self.operations:
 			if not d.time_in_mins > 0:
-				print(self.bom_no, self.production_item)
 				frappe.throw(_("Operation Time must be greater than 0 for Operation {0}").format(d.operation))
 
 	def update_required_items(self):
@@ -967,6 +966,10 @@
 		frappe.throw(_("Not permitted"), frappe.PermissionError)
 
 	pro_order = frappe.get_doc("Work Order", work_order)
+
+	if pro_order.status == "Closed":
+		frappe.throw(_("Closed Work Order can not be stopped or Re-opened"))
+
 	pro_order.update_status(status)
 	pro_order.update_planned_qty()
 	frappe.msgprint(_("Work Order has been {0}").format(status))
@@ -1001,6 +1004,29 @@
 			if row.job_card_qty > 0:
 				create_job_card(work_order, row, auto_create=True)
 
+@frappe.whitelist()
+def close_work_order(work_order, status):
+	if not frappe.has_permission("Work Order", "write"):
+		frappe.throw(_("Not permitted"), frappe.PermissionError)
+
+	work_order = frappe.get_doc("Work Order", work_order)
+	if work_order.get("operations"):
+		job_cards = frappe.get_list("Job Card",
+			filters={
+				"work_order": work_order.name,
+				"status": "Work In Progress"
+			}, pluck='name')
+
+		if job_cards:
+			job_cards = ", ".join(job_cards)
+			frappe.throw(_("Can not close Work Order. Since {0} Job Cards are in Work In Progress state.").format(job_cards))
+
+	work_order.update_status(status)
+	work_order.update_planned_qty()
+	frappe.msgprint(_("Work Order has been {0}").format(status))
+	work_order.notify_update()
+	return work_order.status
+
 def split_qty_based_on_batch_size(wo_doc, row, qty):
 	if not cint(frappe.db.get_value("Operation",
 		row.operation, "create_job_card_based_on_batch_size")):
diff --git a/erpnext/stock/stock_balance.py b/erpnext/stock/stock_balance.py
index 6ac8da1..6663458 100644
--- a/erpnext/stock/stock_balance.py
+++ b/erpnext/stock/stock_balance.py
@@ -160,7 +160,7 @@
 def get_planned_qty(item_code, warehouse):
 	planned_qty = frappe.db.sql("""
 		select sum(qty - produced_qty) from `tabWork Order`
-		where production_item = %s and fg_warehouse = %s and status not in ("Stopped", "Completed")
+		where production_item = %s and fg_warehouse = %s and status not in ("Stopped", "Completed", "Closed")
 		and docstatus=1 and qty > produced_qty""", (item_code, warehouse))
 
 	return flt(planned_qty[0][0]) if planned_qty else 0