From 4e5bc82a1703b5df922f82370664d1f8f0e387cd Mon Sep 17 00:00:00 2001 From: Nihantra Patel Date: Fri, 5 Apr 2024 12:27:34 +0530 Subject: [PATCH 01/74] fix: update the task status from timesheet --- erpnext/projects/doctype/task/task.py | 2 -- erpnext/projects/doctype/timesheet/timesheet.py | 6 ++++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/erpnext/projects/doctype/task/task.py b/erpnext/projects/doctype/task/task.py index c03c99b25d5..d6e3b8c77f7 100755 --- a/erpnext/projects/doctype/task/task.py +++ b/erpnext/projects/doctype/task/task.py @@ -199,8 +199,6 @@ class Task(NestedSet): self.name, as_dict=1, )[0] - if self.status == "Open": - self.status = "Working" self.total_costing_amount = tl.total_costing_amount self.total_billing_amount = tl.total_billing_amount self.actual_time = tl.time diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index 90f436831f9..d2048593eb6 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -126,6 +126,12 @@ class Timesheet(Document): if data.task and data.task not in tasks: task = frappe.get_doc("Task", data.task) task.update_time_and_costing() + time_logs_completed = all(tl.completed for tl in self.time_logs if tl.task == task.name) + + if time_logs_completed: + task.status = "Completed" + else: + task.status = "Working" task.save() tasks.append(data.task) From 72057adc94c77e391699ab286c7acc8ef301e97d Mon Sep 17 00:00:00 2001 From: Florian HENRY Date: Mon, 29 Apr 2024 10:18:02 +0200 Subject: [PATCH 02/74] fix: French chart of account the 4191 code must be of type Income Account --- .../verified/fr_plan_comptable_general_avec_code.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/fr_plan_comptable_general_avec_code.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/fr_plan_comptable_general_avec_code.json index b6673795bea..d599ea65ea6 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/fr_plan_comptable_general_avec_code.json +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/fr_plan_comptable_general_avec_code.json @@ -1525,7 +1525,8 @@ "41-Clients et comptes rattach\u00e9s (PASSIF)": { "Clients cr\u00e9diteurs": { "Clients - Avances et acomptes re\u00e7us sur commandes": { - "account_number": "4191" + "account_number": "4191", + "account_type": "Income Account" }, "Clients - Dettes pour emballages et mat\u00e9riels consign\u00e9s": { "account_number": "4196" @@ -3141,4 +3142,4 @@ "account_number": "7" } } -} \ No newline at end of file +} From db5ed972e55825206137b9c7018905fe7cfbbd91 Mon Sep 17 00:00:00 2001 From: "Nihantra C. Patel" <141945075+Nihantra-Patel@users.noreply.github.com> Date: Wed, 8 May 2024 10:17:49 +0530 Subject: [PATCH 03/74] fix: not allow template item in product bundle item --- .../doctype/product_bundle_item/product_bundle_item.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/selling/doctype/product_bundle_item/product_bundle_item.json b/erpnext/selling/doctype/product_bundle_item/product_bundle_item.json index 77c23f5084f..3727b4e18cc 100644 --- a/erpnext/selling/doctype/product_bundle_item/product_bundle_item.json +++ b/erpnext/selling/doctype/product_bundle_item/product_bundle_item.json @@ -18,6 +18,7 @@ "in_global_search": 1, "in_list_view": 1, "label": "Item", + "link_filters": "[[\"Item\",\"has_variants\",\"=\",0]]", "oldfieldname": "item_code", "oldfieldtype": "Link", "options": "Item", @@ -68,7 +69,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:10:19.782002", + "modified": "2024-05-08 10:08:01.818998", "modified_by": "Administrator", "module": "Selling", "name": "Product Bundle Item", @@ -78,4 +79,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} From c90185f5331e2271afb5ddc22340d3704f5bbcd3 Mon Sep 17 00:00:00 2001 From: Poorvi Date: Fri, 10 May 2024 11:47:57 +0530 Subject: [PATCH 04/74] fix: fixing Item-wise sales register --- .../accounts/report/purchase_register/purchase_register.py | 6 ++++-- erpnext/accounts/report/sales_register/sales_register.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/report/purchase_register/purchase_register.py b/erpnext/accounts/report/purchase_register/purchase_register.py index 504c74babcb..7631506f506 100644 --- a/erpnext/accounts/report/purchase_register/purchase_register.py +++ b/erpnext/accounts/report/purchase_register/purchase_register.py @@ -376,8 +376,10 @@ def get_account_columns(invoice_list, include_payments): def get_invoices(filters, additional_query_columns): pi = frappe.qb.DocType("Purchase Invoice") + pii = frappe.qb.DocType("Purchase Invoice Item") query = ( frappe.qb.from_(pi) + .left_join(pii).on(pi.name == pii.parent) .select( ConstantColumn("Purchase Invoice").as_("doctype"), pi.name, @@ -386,7 +388,7 @@ def get_invoices(filters, additional_query_columns): pi.supplier, pi.supplier_name, pi.tax_id, - pi.bill_no, + pi.bill_no, pi.bill_date, pi.remarks, pi.base_net_total, @@ -395,7 +397,7 @@ def get_invoices(filters, additional_query_columns): pi.outstanding_amount, pi.mode_of_payment, ) - .where(pi.docstatus == 1) + .where((pi.docstatus == 1) & pii.item_code.isnotnull()) .orderby(pi.posting_date, pi.name, order=Order.desc) ) diff --git a/erpnext/accounts/report/sales_register/sales_register.py b/erpnext/accounts/report/sales_register/sales_register.py index f27569531b1..832845ffe2e 100644 --- a/erpnext/accounts/report/sales_register/sales_register.py +++ b/erpnext/accounts/report/sales_register/sales_register.py @@ -414,9 +414,11 @@ def get_account_columns(invoice_list, include_payments): def get_invoices(filters, additional_query_columns): si = frappe.qb.DocType("Sales Invoice") + sii = frappe.qb.DocType("Sales Invoice Item") query = ( frappe.qb.from_(si) - .select( + .left_join(sii).on(si.name == sii.parent) + .select( ConstantColumn("Sales Invoice").as_("doctype"), si.name, si.posting_date, @@ -437,7 +439,7 @@ def get_invoices(filters, additional_query_columns): si.represents_company, si.company, ) - .where(si.docstatus == 1) + .where((si.docstatus == 1) & sii.item_code.isnotnull()) .orderby(si.posting_date, si.name, order=Order.desc) ) From 1b45ecfcae5ed9587c5d03e14afaf6d39d3fa84f Mon Sep 17 00:00:00 2001 From: Poorvi-R-Bhat Date: Mon, 13 May 2024 09:35:10 +0530 Subject: [PATCH 05/74] fix: Item-wise Sales and Purchase register with no item codes #41373 --- .../item_wise_purchase_register.py | 59 ++++++++------- .../item_wise_sales_register.py | 73 ++++++++++--------- .../purchase_register/purchase_register.py | 4 +- .../report/sales_register/sales_register.py | 6 +- 4 files changed, 75 insertions(+), 67 deletions(-) diff --git a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py index 4e7ab1bde6e..5c4752a47df 100644 --- a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py +++ b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py @@ -311,34 +311,39 @@ def get_conditions(filters): return conditions - def get_items(filters, additional_query_columns): - conditions = get_conditions(filters) - if additional_query_columns: - additional_query_columns = "," + ",".join(additional_query_columns) - return frappe.db.sql( - f""" - select - `tabPurchase Invoice Item`.`name`, `tabPurchase Invoice Item`.`parent`, - `tabPurchase Invoice`.posting_date, `tabPurchase Invoice`.credit_to, `tabPurchase Invoice`.company, - `tabPurchase Invoice`.supplier, `tabPurchase Invoice`.remarks, `tabPurchase Invoice`.base_net_total, - `tabPurchase Invoice`.unrealized_profit_loss_account, - `tabPurchase Invoice Item`.`item_code`, `tabPurchase Invoice Item`.description, `tabPurchase Invoice Item`.`item_group`, - `tabPurchase Invoice Item`.`item_name` as pi_item_name, `tabPurchase Invoice Item`.`item_group` as pi_item_group, - `tabItem`.`item_name` as i_item_name, `tabItem`.`item_group` as i_item_group, - `tabPurchase Invoice Item`.`project`, `tabPurchase Invoice Item`.`purchase_order`, - `tabPurchase Invoice Item`.`purchase_receipt`, `tabPurchase Invoice Item`.`po_detail`, - `tabPurchase Invoice Item`.`expense_account`, `tabPurchase Invoice Item`.`stock_qty`, - `tabPurchase Invoice Item`.`stock_uom`, `tabPurchase Invoice Item`.`base_net_amount`, - `tabPurchase Invoice`.`supplier_name`, `tabPurchase Invoice`.`mode_of_payment` {additional_query_columns} - from `tabPurchase Invoice`, `tabPurchase Invoice Item`, `tabItem` - where `tabPurchase Invoice`.name = `tabPurchase Invoice Item`.`parent` and - `tabItem`.name = `tabPurchase Invoice Item`.`item_code` and - `tabPurchase Invoice`.docstatus = 1 {conditions} - """, - filters, - as_dict=1, - ) + pi = frappe.qb.DocType('Purchase Invoice') + pii = frappe.qb.DocType('Purchase Invoice Item') + Item = frappe.qb.DocType('Item') + query = (frappe.qb.from_(pi) + .join(pii).on(pi.name == pii.parent) + .left_join(Item).on(pii.item_code == Item.name) + .select( + pii.name.as_('pii_name'), pii.parent, + pi.posting_date, pi.credit_to, pi.company, + pi.supplier, pi.remarks, pi.base_net_total, + pi.unrealized_profit_loss_account, + pii.item_code, pii.description, pii.item_group, + pii.item_name.as_('pi_item_name'), pii.item_group.as_('pi_item_group'), + Item.item_name.as_('i_item_name'), Item.item_group.as_('i_item_group'), + pii.project, pii.purchase_order, + pii.purchase_receipt, pii.po_detail, + pii.expense_account, pii.stock_qty, + pii.stock_uom, pii.base_net_amount, + pi.supplier_name, pi.mode_of_payment + ) + .where(pi.docstatus == 1)) + + if additional_query_columns: + query = query.select(*additional_query_columns) + + if filters.get("supplier"): + query = query.where(pi.supplier == filters['supplier']) + if filters.get("company"): + query = query.where(pi.company == filters['company']) + + return query.run(as_dict=True) + def get_aii_accounts(): diff --git a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py index ce04af1e79d..4a416a96d99 100644 --- a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py +++ b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py @@ -402,39 +402,46 @@ def get_group_by_conditions(filters, doctype): return "ORDER BY `tab{}`.{}".format(doctype, frappe.scrub(filters.get("group_by"))) -def get_items(filters, additional_query_columns, additional_conditions=None): - conditions = get_conditions(filters, additional_conditions) - if additional_query_columns: - additional_query_columns = "," + ",".join(additional_query_columns) - return frappe.db.sql( - """ - select - `tabSales Invoice Item`.name, `tabSales Invoice Item`.parent, - `tabSales Invoice`.posting_date, `tabSales Invoice`.debit_to, - `tabSales Invoice`.unrealized_profit_loss_account, - `tabSales Invoice`.is_internal_customer, - `tabSales Invoice`.customer, `tabSales Invoice`.remarks, - `tabSales Invoice`.territory, `tabSales Invoice`.company, `tabSales Invoice`.base_net_total, - `tabSales Invoice Item`.project, - `tabSales Invoice Item`.item_code, `tabSales Invoice Item`.description, - `tabSales Invoice Item`.`item_name`, `tabSales Invoice Item`.`item_group`, - `tabSales Invoice Item`.`item_name` as si_item_name, `tabSales Invoice Item`.`item_group` as si_item_group, - `tabItem`.`item_name` as i_item_name, `tabItem`.`item_group` as i_item_group, - `tabSales Invoice Item`.sales_order, `tabSales Invoice Item`.delivery_note, - `tabSales Invoice Item`.income_account, `tabSales Invoice Item`.cost_center, - `tabSales Invoice Item`.enable_deferred_revenue, `tabSales Invoice Item`.deferred_revenue_account, - `tabSales Invoice Item`.stock_qty, `tabSales Invoice Item`.stock_uom, - `tabSales Invoice Item`.base_net_rate, `tabSales Invoice Item`.base_net_amount, - `tabSales Invoice`.customer_name, `tabSales Invoice`.customer_group, `tabSales Invoice Item`.so_detail, - `tabSales Invoice`.update_stock, `tabSales Invoice Item`.uom, `tabSales Invoice Item`.qty {} - from `tabSales Invoice`, `tabSales Invoice Item`, `tabItem` - where `tabSales Invoice`.name = `tabSales Invoice Item`.parent and - `tabItem`.name = `tabSales Invoice Item`.`item_code` and - `tabSales Invoice`.docstatus = 1 {} - """.format(additional_query_columns, conditions), - filters, - as_dict=1, - ) # nosec +def get_items(filters, additional_query_columns,additional_conditions=None): + si = frappe.qb.DocType('Sales Invoice') + sii = frappe.qb.DocType('Sales Invoice Item') + Item = frappe.qb.DocType('Item') + + query = ( + frappe.qb.from_(si) + .join(sii).on(si.name == sii.parent) + .left_join(Item).on(sii.item_code == Item.name) + .select( + sii.name, sii.parent, + si.posting_date, si.debit_to, + si.unrealized_profit_loss_account, + si.is_internal_customer, + si.customer, si.remarks, + si.territory, si.company, si.base_net_total, + sii.project, + sii.item_code, sii.description, + sii.item_name, sii.item_group, + sii.item_name.as_('si_item_name'), sii.item_group.as_('si_item_group'), + Item.item_name.as_('i_item_name'), Item.item_group.as_('i_item_group'), + sii.sales_order, sii.delivery_note, + sii.income_account, sii.cost_center, + sii.enable_deferred_revenue, sii.deferred_revenue_account, + sii.stock_qty, sii.stock_uom, + sii.base_net_rate, sii.base_net_amount, + si.customer_name, si.customer_group, sii.so_detail, + si.update_stock, sii.uom, sii.qty + ) + .where(si.docstatus == 1) + ) + if filters.get("customer"): + query = query.where(si.customer == filters['customer']) + + if filters.get("customer_group"): + query = query.where(si.customer_group == filters['customer_group']) + + return query.run(as_dict=True) + + def get_delivery_notes_against_sales_order(item_list): diff --git a/erpnext/accounts/report/purchase_register/purchase_register.py b/erpnext/accounts/report/purchase_register/purchase_register.py index 7631506f506..25caa9e03fb 100644 --- a/erpnext/accounts/report/purchase_register/purchase_register.py +++ b/erpnext/accounts/report/purchase_register/purchase_register.py @@ -376,10 +376,8 @@ def get_account_columns(invoice_list, include_payments): def get_invoices(filters, additional_query_columns): pi = frappe.qb.DocType("Purchase Invoice") - pii = frappe.qb.DocType("Purchase Invoice Item") query = ( frappe.qb.from_(pi) - .left_join(pii).on(pi.name == pii.parent) .select( ConstantColumn("Purchase Invoice").as_("doctype"), pi.name, @@ -397,7 +395,7 @@ def get_invoices(filters, additional_query_columns): pi.outstanding_amount, pi.mode_of_payment, ) - .where((pi.docstatus == 1) & pii.item_code.isnotnull()) + .where((pi.docstatus == 1)) .orderby(pi.posting_date, pi.name, order=Order.desc) ) diff --git a/erpnext/accounts/report/sales_register/sales_register.py b/erpnext/accounts/report/sales_register/sales_register.py index 832845ffe2e..dc557433f7d 100644 --- a/erpnext/accounts/report/sales_register/sales_register.py +++ b/erpnext/accounts/report/sales_register/sales_register.py @@ -414,11 +414,9 @@ def get_account_columns(invoice_list, include_payments): def get_invoices(filters, additional_query_columns): si = frappe.qb.DocType("Sales Invoice") - sii = frappe.qb.DocType("Sales Invoice Item") query = ( frappe.qb.from_(si) - .left_join(sii).on(si.name == sii.parent) - .select( + .select( ConstantColumn("Sales Invoice").as_("doctype"), si.name, si.posting_date, @@ -439,7 +437,7 @@ def get_invoices(filters, additional_query_columns): si.represents_company, si.company, ) - .where((si.docstatus == 1) & sii.item_code.isnotnull()) + .where((si.docstatus == 1)) .orderby(si.posting_date, si.name, order=Order.desc) ) From eb4f94ddf23a6cecf2612ad974cc54c02b5c59b6 Mon Sep 17 00:00:00 2001 From: Nihantra Patel Date: Mon, 20 May 2024 15:51:27 +0530 Subject: [PATCH 06/74] fix: add include closed orders option in so/po trends report --- .../report/purchase_order_trends/purchase_order_trends.js | 7 +++++++ erpnext/controllers/trends.py | 6 ++++-- .../report/sales_order_trends/sales_order_trends.js | 7 +++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.js b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.js index 56684a8659b..9bb67d0ffb6 100644 --- a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.js +++ b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.js @@ -2,3 +2,10 @@ // License: GNU General Public License v3. See license.txt frappe.query_reports["Purchase Order Trends"] = $.extend({}, erpnext.purchase_trends_filters); + +frappe.query_reports["Purchase Order Trends"]["filters"].push({ + "fieldname": "include_closed_orders", + "label": __("Include Closed Orders"), + "fieldtype": "Check", + "default": 0 +}); \ No newline at end of file diff --git a/erpnext/controllers/trends.py b/erpnext/controllers/trends.py index 18fe7767c5d..7f07466b3bc 100644 --- a/erpnext/controllers/trends.py +++ b/erpnext/controllers/trends.py @@ -74,8 +74,10 @@ def get_data(filters, conditions): if conditions["based_on_select"] in ["t1.project,", "t2.project,"]: cond = " and " + conditions["based_on_select"][:-1] + " IS Not NULL" - if conditions.get("trans") in ["Sales Order", "Purchase Order"]: - cond += " and t1.status != 'Closed'" + + if not filters.get("include_closed_orders"): + if conditions.get("trans") in ["Sales Order", "Purchase Order"]: + cond += " and t1.status != 'Closed'" if conditions.get("trans") == "Quotation" and filters.get("group_by") == "Customer": cond += " and t1.quotation_to = 'Customer'" diff --git a/erpnext/selling/report/sales_order_trends/sales_order_trends.js b/erpnext/selling/report/sales_order_trends/sales_order_trends.js index 28bd5504930..4bce8be615e 100644 --- a/erpnext/selling/report/sales_order_trends/sales_order_trends.js +++ b/erpnext/selling/report/sales_order_trends/sales_order_trends.js @@ -2,3 +2,10 @@ // License: GNU General Public License v3. See license.txt frappe.query_reports["Sales Order Trends"] = $.extend({}, erpnext.sales_trends_filters); + +frappe.query_reports["Sales Order Trends"]["filters"].push({ + "fieldname": "include_closed_orders", + "label": __("Include Closed Orders"), + "fieldtype": "Check", + "default": 0 +}); \ No newline at end of file From 6e3782a061fd9a570359dc5607d69267fb5cce97 Mon Sep 17 00:00:00 2001 From: Nihantra Patel Date: Mon, 20 May 2024 15:59:45 +0530 Subject: [PATCH 07/74] fix: add include closed orders option in so/po trends report --- .../report/purchase_order_trends/purchase_order_trends.js | 8 ++++---- .../report/sales_order_trends/sales_order_trends.js | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.js b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.js index 9bb67d0ffb6..47431e6367f 100644 --- a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.js +++ b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.js @@ -4,8 +4,8 @@ frappe.query_reports["Purchase Order Trends"] = $.extend({}, erpnext.purchase_trends_filters); frappe.query_reports["Purchase Order Trends"]["filters"].push({ - "fieldname": "include_closed_orders", - "label": __("Include Closed Orders"), - "fieldtype": "Check", - "default": 0 + fieldname: "include_closed_orders", + label: __("Include Closed Orders"), + fieldtype: "Check", + default: 0, }); \ No newline at end of file diff --git a/erpnext/selling/report/sales_order_trends/sales_order_trends.js b/erpnext/selling/report/sales_order_trends/sales_order_trends.js index 4bce8be615e..6c14991c1b2 100644 --- a/erpnext/selling/report/sales_order_trends/sales_order_trends.js +++ b/erpnext/selling/report/sales_order_trends/sales_order_trends.js @@ -4,8 +4,8 @@ frappe.query_reports["Sales Order Trends"] = $.extend({}, erpnext.sales_trends_filters); frappe.query_reports["Sales Order Trends"]["filters"].push({ - "fieldname": "include_closed_orders", - "label": __("Include Closed Orders"), - "fieldtype": "Check", - "default": 0 + fieldname: "include_closed_orders", + label: __("Include Closed Orders"), + fieldtype: "Check", + default: 0, }); \ No newline at end of file From 91c9d964f9f89f9f01631967823ea650de1844da Mon Sep 17 00:00:00 2001 From: Nihantra Patel Date: Mon, 20 May 2024 16:02:10 +0530 Subject: [PATCH 08/74] fix: add include closed orders option in so/po trends report --- .../report/purchase_order_trends/purchase_order_trends.js | 2 +- erpnext/selling/report/sales_order_trends/sales_order_trends.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.js b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.js index 47431e6367f..9b193a34d83 100644 --- a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.js +++ b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.js @@ -8,4 +8,4 @@ frappe.query_reports["Purchase Order Trends"]["filters"].push({ label: __("Include Closed Orders"), fieldtype: "Check", default: 0, -}); \ No newline at end of file +}); diff --git a/erpnext/selling/report/sales_order_trends/sales_order_trends.js b/erpnext/selling/report/sales_order_trends/sales_order_trends.js index 6c14991c1b2..a44353cf54b 100644 --- a/erpnext/selling/report/sales_order_trends/sales_order_trends.js +++ b/erpnext/selling/report/sales_order_trends/sales_order_trends.js @@ -8,4 +8,4 @@ frappe.query_reports["Sales Order Trends"]["filters"].push({ label: __("Include Closed Orders"), fieldtype: "Check", default: 0, -}); \ No newline at end of file +}); From 76073ae228d9f3ebe67eae562e2e48bd54ace252 Mon Sep 17 00:00:00 2001 From: Poorvi-R-Bhat Date: Wed, 22 May 2024 12:26:02 +0530 Subject: [PATCH 09/74] fix: fixing Item-wise sales register and purchase register #41373 --- .../item_wise_purchase_register.py | 77 ++++++++++++------- 1 file changed, 48 insertions(+), 29 deletions(-) diff --git a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py index 5c4752a47df..48881112b68 100644 --- a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py +++ b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py @@ -311,39 +311,58 @@ def get_conditions(filters): return conditions + def get_items(filters, additional_query_columns): - pi = frappe.qb.DocType('Purchase Invoice') - pii = frappe.qb.DocType('Purchase Invoice Item') - Item = frappe.qb.DocType('Item') - query = (frappe.qb.from_(pi) - .join(pii).on(pi.name == pii.parent) - .left_join(Item).on(pii.item_code == Item.name) - .select( - pii.name.as_('pii_name'), pii.parent, - pi.posting_date, pi.credit_to, pi.company, - pi.supplier, pi.remarks, pi.base_net_total, - pi.unrealized_profit_loss_account, - pii.item_code, pii.description, pii.item_group, - pii.item_name.as_('pi_item_name'), pii.item_group.as_('pi_item_group'), - Item.item_name.as_('i_item_name'), Item.item_group.as_('i_item_group'), - pii.project, pii.purchase_order, - pii.purchase_receipt, pii.po_detail, - pii.expense_account, pii.stock_qty, - pii.stock_uom, pii.base_net_amount, - pi.supplier_name, pi.mode_of_payment - ) - .where(pi.docstatus == 1)) + pi = frappe.qb.DocType("Purchase Invoice") + pii = frappe.qb.DocType("Purchase Invoice Item") + Item = frappe.qb.DocType("Item") + query = ( + frappe.qb.from_(pi) + .join(pii) + .on(pi.name == pii.parent) + # added left join + .left_join(Item) + .on(pii.item_code == Item.name) + .select( + pii.name.as_("pii_name"), + pii.parent, + pi.posting_date, + pi.credit_to, + pi.company, + pi.supplier, + pi.remarks, + pi.base_net_total, + pi.unrealized_profit_loss_account, + pii.item_code, + pii.description, + pii.item_group, + pii.item_name.as_("pi_item_name"), + pii.item_group.as_("pi_item_group"), + Item.item_name.as_("i_item_name"), + Item.item_group.as_("i_item_group"), + pii.project, + pii.purchase_order, + pii.purchase_receipt, + pii.po_detail, + pii.expense_account, + pii.stock_qty, + pii.stock_uom, + pii.base_net_amount, + pi.supplier_name, + pi.mode_of_payment, + ) + .where(pi.docstatus == 1) + ) - if additional_query_columns: - query = query.select(*additional_query_columns) + if additional_query_columns: + query = query.select(*additional_query_columns) - if filters.get("supplier"): - query = query.where(pi.supplier == filters['supplier']) - if filters.get("company"): - query = query.where(pi.company == filters['company']) - - return query.run(as_dict=True) + if filters.get("supplier"): + query = query.where(pi.supplier == filters["supplier"]) + if filters.get("company"): + query = query.where(pi.company == filters["company"]) + return query.run(as_dict=True) def get_aii_accounts(): From eafa88b8e9da824d89e5673504c0de3422b54b6b Mon Sep 17 00:00:00 2001 From: Poorvi-R-Bhat Date: Wed, 22 May 2024 12:39:22 +0530 Subject: [PATCH 10/74] fix: fixing Item-wise sales register #41373 --- .../item_wise_purchase_register.py | 1 - .../item_wise_sales_register.py | 93 +++++++++++-------- .../purchase_register/purchase_register.py | 6 +- .../report/sales_register/sales_register.py | 4 +- 4 files changed, 61 insertions(+), 43 deletions(-) diff --git a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py index 48881112b68..ee517e7459d 100644 --- a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py +++ b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py @@ -320,7 +320,6 @@ def get_items(filters, additional_query_columns): frappe.qb.from_(pi) .join(pii) .on(pi.name == pii.parent) - # added left join .left_join(Item) .on(pii.item_code == Item.name) .select( diff --git a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py index 4a416a96d99..6f323ff77ce 100644 --- a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py +++ b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py @@ -402,46 +402,65 @@ def get_group_by_conditions(filters, doctype): return "ORDER BY `tab{}`.{}".format(doctype, frappe.scrub(filters.get("group_by"))) -def get_items(filters, additional_query_columns,additional_conditions=None): - si = frappe.qb.DocType('Sales Invoice') - sii = frappe.qb.DocType('Sales Invoice Item') - Item = frappe.qb.DocType('Item') - - query = ( - frappe.qb.from_(si) - .join(sii).on(si.name == sii.parent) - .left_join(Item).on(sii.item_code == Item.name) - .select( - sii.name, sii.parent, - si.posting_date, si.debit_to, - si.unrealized_profit_loss_account, - si.is_internal_customer, - si.customer, si.remarks, - si.territory, si.company, si.base_net_total, - sii.project, - sii.item_code, sii.description, - sii.item_name, sii.item_group, - sii.item_name.as_('si_item_name'), sii.item_group.as_('si_item_group'), - Item.item_name.as_('i_item_name'), Item.item_group.as_('i_item_group'), - sii.sales_order, sii.delivery_note, - sii.income_account, sii.cost_center, - sii.enable_deferred_revenue, sii.deferred_revenue_account, - sii.stock_qty, sii.stock_uom, - sii.base_net_rate, sii.base_net_amount, - si.customer_name, si.customer_group, sii.so_detail, - si.update_stock, sii.uom, sii.qty - ) - .where(si.docstatus == 1) - ) - if filters.get("customer"): - query = query.where(si.customer == filters['customer']) +def get_items(filters, additional_query_columns, additional_conditions=None): + si = frappe.qb.DocType("Sales Invoice") + sii = frappe.qb.DocType("Sales Invoice Item") + Item = frappe.qb.DocType("Item") - if filters.get("customer_group"): - query = query.where(si.customer_group == filters['customer_group']) - - return query.run(as_dict=True) + query = ( + frappe.qb.from_(si) + .join(sii) + .on(si.name == sii.parent) + # added left join + .left_join(Item) + .on(sii.item_code == Item.name) + .select( + sii.name, + sii.parent, + si.posting_date, + si.debit_to, + si.unrealized_profit_loss_account, + si.is_internal_customer, + si.customer, + si.remarks, + si.territory, + si.company, + si.base_net_total, + sii.project, + sii.item_code, + sii.description, + sii.item_name, + sii.item_group, + sii.item_name.as_("si_item_name"), + sii.item_group.as_("si_item_group"), + Item.item_name.as_("i_item_name"), + Item.item_group.as_("i_item_group"), + sii.sales_order, + sii.delivery_note, + sii.income_account, + sii.cost_center, + sii.enable_deferred_revenue, + sii.deferred_revenue_account, + sii.stock_qty, + sii.stock_uom, + sii.base_net_rate, + sii.base_net_amount, + si.customer_name, + si.customer_group, + sii.so_detail, + si.update_stock, + sii.uom, + sii.qty, + ) + .where(si.docstatus == 1) + ) + if filters.get("customer"): + query = query.where(si.customer == filters["customer"]) + if filters.get("customer_group"): + query = query.where(si.customer_group == filters["customer_group"]) + return query.run(as_dict=True) def get_delivery_notes_against_sales_order(item_list): diff --git a/erpnext/accounts/report/purchase_register/purchase_register.py b/erpnext/accounts/report/purchase_register/purchase_register.py index 25caa9e03fb..1e24d96115d 100644 --- a/erpnext/accounts/report/purchase_register/purchase_register.py +++ b/erpnext/accounts/report/purchase_register/purchase_register.py @@ -377,7 +377,7 @@ def get_account_columns(invoice_list, include_payments): def get_invoices(filters, additional_query_columns): pi = frappe.qb.DocType("Purchase Invoice") query = ( - frappe.qb.from_(pi) + frappe.qb.from_(pi) # qb .select( ConstantColumn("Purchase Invoice").as_("doctype"), pi.name, @@ -386,7 +386,7 @@ def get_invoices(filters, additional_query_columns): pi.supplier, pi.supplier_name, pi.tax_id, - pi.bill_no, + pi.bill_no, pi.bill_date, pi.remarks, pi.base_net_total, @@ -395,7 +395,7 @@ def get_invoices(filters, additional_query_columns): pi.outstanding_amount, pi.mode_of_payment, ) - .where((pi.docstatus == 1)) + .where(pi.docstatus == 1) .orderby(pi.posting_date, pi.name, order=Order.desc) ) diff --git a/erpnext/accounts/report/sales_register/sales_register.py b/erpnext/accounts/report/sales_register/sales_register.py index dc557433f7d..f8b94d353f9 100644 --- a/erpnext/accounts/report/sales_register/sales_register.py +++ b/erpnext/accounts/report/sales_register/sales_register.py @@ -415,7 +415,7 @@ def get_account_columns(invoice_list, include_payments): def get_invoices(filters, additional_query_columns): si = frappe.qb.DocType("Sales Invoice") query = ( - frappe.qb.from_(si) + frappe.qb.from_(si) # qb .select( ConstantColumn("Sales Invoice").as_("doctype"), si.name, @@ -437,7 +437,7 @@ def get_invoices(filters, additional_query_columns): si.represents_company, si.company, ) - .where((si.docstatus == 1)) + .where(si.docstatus == 1) .orderby(si.posting_date, si.name, order=Order.desc) ) From 25931c4db24400c71623ac354f27e83566eafc9b Mon Sep 17 00:00:00 2001 From: David Date: Wed, 3 Apr 2024 12:21:39 +0200 Subject: [PATCH 11/74] test: add payment channel tests --- .../payment_request/test_payment_request.py | 116 +++++++++++++++++- 1 file changed, 113 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index 932060895b0..3709bc6257d 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -2,6 +2,7 @@ # See license.txt import unittest +from unittest.mock import patch import frappe @@ -13,7 +14,12 @@ from erpnext.setup.utils import get_exchange_rate test_dependencies = ["Currency Exchange", "Journal Entry", "Contact", "Address"] -payment_gateway = {"doctype": "Payment Gateway", "gateway": "_Test Gateway"} +PAYMENT_URL = "https://example.com/payment" + +payment_gateways = [ + {"doctype": "Payment Gateway", "gateway": "_Test Gateway"}, + {"doctype": "Payment Gateway", "gateway": "_Test Gateway Phone"}, +] payment_method = [ { @@ -29,13 +35,21 @@ payment_method = [ "payment_account": "_Test Bank USD - _TC", "currency": "USD", }, + { + "doctype": "Payment Gateway Account", + "payment_gateway": "_Test Gateway Phone", + "payment_account": "_Test Bank USD - _TC", + "payment_channel": "Phone", + "currency": "USD", + }, ] class TestPaymentRequest(unittest.TestCase): def setUp(self): - if not frappe.db.get_value("Payment Gateway", payment_gateway["gateway"], "name"): - frappe.get_doc(payment_gateway).insert(ignore_permissions=True) + for payment_gateway in payment_gateways: + if not frappe.db.get_value("Payment Gateway", payment_gateway["gateway"], "name"): + frappe.get_doc(payment_gateway).insert(ignore_permissions=True) for method in payment_method: if not frappe.db.get_value( @@ -45,6 +59,25 @@ class TestPaymentRequest(unittest.TestCase): ): frappe.get_doc(method).insert(ignore_permissions=True) + send_email = patch( + "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.send_email", + return_value=None, + ) + self.send_email = send_email.start() + self.addCleanup(send_email.stop) + get_payment_url = patch( + # this also shadows one (1) call to _get_payment_gateway_controller + "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.get_payment_url", + return_value=PAYMENT_URL, + ) + self.get_payment_url = get_payment_url.start() + self.addCleanup(get_payment_url.stop) + _get_payment_gateway_controller = patch( + "erpnext.accounts.doctype.payment_request.payment_request._get_payment_gateway_controller", + ) + self._get_payment_gateway_controller = _get_payment_gateway_controller.start() + self.addCleanup(_get_payment_gateway_controller.stop) + def test_payment_request_linkings(self): so_inr = make_sales_order(currency="INR", do_not_save=True) so_inr.disable_rounded_total = 1 @@ -75,6 +108,83 @@ class TestPaymentRequest(unittest.TestCase): self.assertEqual(pr.reference_name, si_usd.name) self.assertEqual(pr.currency, "USD") + def test_payment_channels(self): + so = make_sales_order(currency="USD") + + pr = make_payment_request( + dt="Sales Order", + dn=so.name, + payment_gateway_account="_Test Gateway - USD", # email channel + submit_doc=False, + return_doc=True, + ) + pr.flags.mute_email = True # but temporarily prohibit sending + pr.submit() + pr.reload() + self.assertEqual(pr.payment_channel, "Email") + self.assertEqual(pr.mute_email, False) + + self.assertIsNone(pr.payment_url) + self.assertEqual(self.send_email.call_count, 0) # hence: no increment + self.assertEqual(self._get_payment_gateway_controller.call_count, 1) + pr.cancel() + + pr = make_payment_request( + dt="Sales Order", + dn=so.name, + payment_gateway_account="_Test Gateway Phone - USD", + submit_doc=True, + return_doc=True, + ) + pr.reload() + + self.assertEqual(pr.payment_channel, "Phone") + self.assertEqual(pr.mute_email, False) + + self.assertIsNone(pr.payment_url) + self.assertEqual(self.send_email.call_count, 0) # no increment on phone channel + self.assertEqual(self._get_payment_gateway_controller.call_count, 3) + pr.cancel() + + pr = make_payment_request( + dt="Sales Order", + dn=so.name, + payment_gateway_account="_Test Gateway - USD", # email channel + submit_doc=True, + return_doc=True, + ) + pr.reload() + + self.assertEqual(pr.payment_channel, "Email") + self.assertEqual(pr.mute_email, False) + + self.assertEqual(pr.payment_url, PAYMENT_URL) + self.assertEqual(self.send_email.call_count, 1) # increment on normal email channel + self.assertEqual(self._get_payment_gateway_controller.call_count, 4) + pr.cancel() + + so = make_sales_order(currency="USD", do_not_save=True) + # no-op; for optical consistency with how a webshop SO would look like + so.order_type = "Shopping Cart" + so.save() + pr = make_payment_request( + dt="Sales Order", + dn=so.name, + payment_gateway_account="_Test Gateway - USD", # email channel + order_type="Shopping Cart", + submit_doc=True, + return_doc=True, + ) + pr.reload() + + self.assertEqual(pr.payment_channel, "Email") + self.assertEqual(pr.mute_email, False) + + self.assertIsNone(pr.payment_url) + self.assertEqual(self.send_email.call_count, 1) # no increment on shopping cart + self.assertEqual(self._get_payment_gateway_controller.call_count, 5) + pr.cancel() + def test_payment_entry_against_purchase_invoice(self): si_usd = make_purchase_invoice( customer="_Test Supplier USD", From e3fd82766f65ae5da2caf4193683048fa3482929 Mon Sep 17 00:00:00 2001 From: Dany Robert Date: Mon, 27 May 2024 14:50:28 +0000 Subject: [PATCH 12/74] fix: condition filter for transaction pricing rule --- erpnext/accounts/doctype/pricing_rule/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/accounts/doctype/pricing_rule/utils.py b/erpnext/accounts/doctype/pricing_rule/utils.py index 32221b6d9a8..9bd417fa5b3 100644 --- a/erpnext/accounts/doctype/pricing_rule/utils.py +++ b/erpnext/accounts/doctype/pricing_rule/utils.py @@ -564,6 +564,7 @@ def apply_pricing_rule_on_transaction(doc): if pricing_rules: pricing_rules = filter_pricing_rules_for_qty_amount(doc.total_qty, doc.total, pricing_rules) + pricing_rules = filter_pricing_rule_based_on_condition(pricing_rules, doc) if not pricing_rules: remove_free_item(doc) From e3cf53a8b766b0315f460e73d2c5d797d9b19334 Mon Sep 17 00:00:00 2001 From: Marica Date: Tue, 28 May 2024 15:28:02 +0530 Subject: [PATCH 13/74] feat: Add Party details to Serial No Ledger Report (#41656) - Since no party details are present in Serial No anymore, it is hard to get a view of which SN was sold to/purchased from which party - Add Party fields to report --- .../serial_no_ledger/serial_no_ledger.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py b/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py index f229f73e683..6ef02724f65 100644 --- a/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py +++ b/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py @@ -9,6 +9,9 @@ from frappe import _ from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos as get_serial_nos_from_sle from erpnext.stock.stock_ledger import get_stock_ledger_entries +BUYING_VOUCHER_TYPES = ["Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"] +SELLING_VOUCHER_TYPES = ["Sales Invoice", "Delivery Note"] + def execute(filters=None): columns = get_columns(filters) @@ -72,6 +75,20 @@ def get_columns(filters): "fieldname": "qty", "width": 150, }, + { + "label": _("Party Type"), + "fieldtype": "Link", + "fieldname": "party_type", + "options": "DocType", + "width": 90, + }, + { + "label": _("Party"), + "fieldtype": "Dynamic Link", + "fieldname": "party", + "options": "party_type", + "width": 120, + }, ] return columns @@ -102,6 +119,17 @@ def get_data(filters): } ) + # get party details depending on the voucher type + party_field = ( + "supplier" + if row.voucher_type in BUYING_VOUCHER_TYPES + else ("customer" if row.voucher_type in SELLING_VOUCHER_TYPES else None) + ) + args.party_type = party_field.title() if party_field else None + args.party = ( + frappe.db.get_value(row.voucher_type, row.voucher_no, party_field) if party_field else None + ) + serial_nos = [] if row.serial_no: parsed_serial_nos = get_serial_nos_from_sle(row.serial_no) From ce834f5dba1066b59705eb171e1bca331405b704 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Tue, 28 May 2024 18:48:09 +0530 Subject: [PATCH 14/74] fix: multiple issues related to serial and batch bundle (#41662) --- .../doctype/pos_invoice/pos_invoice.py | 1 + .../doctype/pos_invoice/test_pos_invoice.py | 11 +- .../purchase_invoice/purchase_invoice.py | 1 + .../doctype/sales_invoice/sales_invoice.py | 1 + .../controllers/sales_and_purchase_return.py | 248 +++++++++++++++++- erpnext/controllers/stock_controller.py | 198 ++++++++++++-- erpnext/stock/deprecated_serial_batch.py | 13 +- erpnext/stock/doctype/batch/batch.py | 3 +- .../doctype/delivery_note/delivery_note.py | 4 + .../delivery_note/test_delivery_note.py | 21 +- .../purchase_receipt/purchase_receipt.py | 1 + .../purchase_receipt/test_purchase_receipt.py | 2 +- .../purchase_receipt_item.json | 10 +- .../purchase_receipt_item.py | 1 + .../doctype/putaway_rule/test_putaway_rule.py | 16 +- .../serial_and_batch_bundle.py | 2 + .../stock/doctype/stock_entry/stock_entry.js | 9 + .../stock/doctype/stock_entry/stock_entry.py | 15 +- .../batch_wise_balance_history.py | 6 +- erpnext/stock/serial_batch_bundle.py | 46 +++- erpnext/stock/stock_ledger.py | 6 +- .../subcontracting_receipt.js | 15 ++ .../subcontracting_receipt.json | 22 +- .../subcontracting_receipt.py | 5 + 24 files changed, 569 insertions(+), 88 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 0ce2c1a4f25..9e6c518dfc0 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -228,6 +228,7 @@ class POSInvoice(SalesInvoice): self.apply_loyalty_points() self.check_phone_payments() self.set_status(update=True) + self.make_bundle_for_sales_purchase_return() self.submit_serial_batch_bundle() if self.coupon_code: diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index 773ef0103b6..9159f837269 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -318,29 +318,28 @@ class TestPOSInvoice(unittest.TestCase): pos.insert() pos.submit() + pos.reload() pos_return1 = make_sales_return(pos.name) # partial return 1 pos_return1.get("items")[0].qty = -1 + pos_return1.submit() + pos_return1.reload() bundle_id = frappe.get_doc( "Serial and Batch Bundle", pos_return1.get("items")[0].serial_and_batch_bundle ) - bundle_id.remove(bundle_id.entries[1]) - bundle_id.save() - bundle_id.load_from_db() serial_no = bundle_id.entries[0].serial_no self.assertEqual(serial_no, serial_nos[0]) - pos_return1.insert() - pos_return1.submit() - # partial return 2 pos_return2 = make_sales_return(pos.name) + pos_return2.submit() + self.assertEqual(pos_return2.get("items")[0].qty, -1) serial_no = get_serial_nos_from_bundle(pos_return2.get("items")[0].serial_and_batch_bundle)[0] self.assertEqual(serial_no, serial_nos[1]) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index cebd730e489..bb158bedcd0 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -708,6 +708,7 @@ class PurchaseInvoice(BuyingController): # Updating stock ledger should always be called after updating prevdoc status, # because updating ordered qty in bin depends upon updated ordered qty in PO if self.update_stock == 1: + self.make_bundle_for_sales_purchase_return() self.make_bundle_using_old_serial_batch_fields() self.update_stock_ledger() diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index ba79cfee663..f588d14b43a 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -455,6 +455,7 @@ class SalesInvoice(SellingController): if not self.get(table_name): continue + self.make_bundle_for_sales_purchase_return(table_name) self.make_bundle_using_old_serial_batch_fields(table_name) self.update_stock_ledger() diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index 83f3410d8fb..f60759e9ee8 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -1,11 +1,12 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +from collections import defaultdict import frappe from frappe import _ from frappe.model.meta import get_field_precision -from frappe.utils import flt, format_datetime, get_datetime +from frappe.utils import cint, flt, format_datetime, get_datetime import erpnext from erpnext.stock.serial_batch_bundle import get_batches_from_bundle @@ -513,6 +514,7 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai target_doc.rejected_warehouse = "" target_doc.warehouse = source_doc.rejected_warehouse target_doc.received_qty = target_doc.qty + target_doc.return_qty_from_rejected_warehouse = 1 elif doctype == "Purchase Invoice": returned_qty_map = get_returned_qty_map_for_row( @@ -570,7 +572,14 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai if default_warehouse_for_sales_return: target_doc.warehouse = default_warehouse_for_sales_return - if source_doc.item_code: + if ( + (source_doc.serial_no or source_doc.batch_no) + and not source_doc.serial_and_batch_bundle + and not source_doc.use_serial_batch_fields + ): + target_doc.set("use_serial_batch_fields", 1) + + if source_doc.item_code and target_doc.get("use_serial_batch_fields"): item_details = frappe.get_cached_value( "Item", source_doc.item_code, ["has_batch_no", "has_serial_no"], as_dict=1 ) @@ -578,14 +587,7 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai if not item_details.has_batch_no and not item_details.has_serial_no: return - if not target_doc.get("use_serial_batch_fields"): - for qty_field in ["stock_qty", "rejected_qty"]: - if not target_doc.get(qty_field): - continue - - update_serial_batch_no(source_doc, target_doc, source_parent, item_details, qty_field) - elif target_doc.get("use_serial_batch_fields"): - update_non_bundled_serial_nos(source_doc, target_doc, source_parent) + update_non_bundled_serial_nos(source_doc, target_doc, source_parent) def update_non_bundled_serial_nos(source_doc, target_doc, source_parent): from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -839,3 +841,229 @@ def get_returned_batches(child_doc, parent_doc, batch_no_field=None, ignore_vouc batches.update(get_batches_from_bundle(ids)) return batches + + +def available_serial_batch_for_return(field, doctype, reference_ids, is_rejected=False): + available_dict = get_available_serial_batches(field, doctype, reference_ids, is_rejected=is_rejected) + if not available_dict: + frappe.throw(_("No Serial / Batches are available for return")) + + return available_dict + + +def get_available_serial_batches(field, doctype, reference_ids, is_rejected=False): + _bundle_ids = get_serial_and_batch_bundle(field, doctype, reference_ids, is_rejected=is_rejected) + if not _bundle_ids: + return frappe._dict({}) + + return get_serial_batches_based_on_bundle(field, _bundle_ids) + + +def get_serial_batches_based_on_bundle(field, _bundle_ids): + available_dict = frappe._dict({}) + batch_serial_nos = frappe.get_all( + "Serial and Batch Bundle", + fields=[ + "`tabSerial and Batch Entry`.`serial_no`", + "`tabSerial and Batch Entry`.`batch_no`", + "`tabSerial and Batch Entry`.`qty`", + "`tabSerial and Batch Bundle`.`voucher_detail_no`", + "`tabSerial and Batch Bundle`.`voucher_type`", + "`tabSerial and Batch Bundle`.`voucher_no`", + ], + filters=[ + ["Serial and Batch Bundle", "name", "in", _bundle_ids], + ["Serial and Batch Entry", "docstatus", "=", 1], + ], + order_by="`tabSerial and Batch Bundle`.`creation`, `tabSerial and Batch Entry`.`idx`", + ) + + for row in batch_serial_nos: + key = row.voucher_detail_no + if frappe.get_cached_value(row.voucher_type, row.voucher_no, "is_return"): + key = frappe.get_cached_value(row.voucher_type + " Item", row.voucher_detail_no, field) + + if key not in available_dict: + available_dict[key] = frappe._dict( + {"qty": 0.0, "serial_nos": defaultdict(float), "batches": defaultdict(float)} + ) + + available_dict[key]["qty"] += row.qty + + if row.serial_no: + available_dict[key]["serial_nos"][row.serial_no] += row.qty + elif row.batch_no: + available_dict[key]["batches"][row.batch_no] += row.qty + + return available_dict + + +def get_serial_and_batch_bundle(field, doctype, reference_ids, is_rejected=False): + filters = {"docstatus": 1, "name": ("in", reference_ids), "serial_and_batch_bundle": ("is", "set")} + + pluck_field = "serial_and_batch_bundle" + if is_rejected: + del filters["serial_and_batch_bundle"] + filters["rejected_serial_and_batch_bundle"] = ("is", "set") + pluck_field = "rejected_serial_and_batch_bundle" + + _bundle_ids = frappe.get_all( + doctype, + filters=filters, + pluck=pluck_field, + ) + + if not _bundle_ids: + return {} + + del filters["name"] + + filters[field] = ("in", reference_ids) + + if not is_rejected: + _bundle_ids.extend( + frappe.get_all( + doctype, + filters=filters, + pluck="serial_and_batch_bundle", + ) + ) + else: + fields = [ + "serial_and_batch_bundle", + ] + + if is_rejected: + fields.extend(["rejected_serial_and_batch_bundle", "return_qty_from_rejected_warehouse"]) + + data = frappe.get_all( + doctype, + fields=fields, + filters=filters, + ) + + for d in data: + if is_rejected: + if d.get("return_qty_from_rejected_warehouse"): + _bundle_ids.append(d.get("serial_and_batch_bundle")) + else: + _bundle_ids.append(d.get("rejected_serial_and_batch_bundle")) + else: + _bundle_ids.append(d.get("serial_and_batch_bundle")) + + return _bundle_ids + + +def filter_serial_batches(parent_doc, data, row, warehouse_field=None, qty_field=None): + if not qty_field: + qty_field = "qty" + + if not warehouse_field: + warehouse_field = "warehouse" + + warehouse = row.get(warehouse_field) + qty = abs(row.get(qty_field)) + + filterd_serial_batch = frappe._dict({"serial_nos": [], "batches": defaultdict(float)}) + + if data.serial_nos: + available_serial_nos = [] + for serial_no, sn_qty in data.serial_nos.items(): + if sn_qty != 0: + available_serial_nos.append(serial_no) + + if available_serial_nos: + if parent_doc.doctype in ["Purchase Invoice", "Purchase Reecipt"]: + available_serial_nos = get_available_serial_nos(available_serial_nos) + + if len(available_serial_nos) > qty: + filterd_serial_batch["serial_nos"] = sorted(available_serial_nos[0 : cint(qty)]) + else: + filterd_serial_batch["serial_nos"] = available_serial_nos + + elif data.batches: + for batch_no, batch_qty in data.batches.items(): + if parent_doc.get("is_internal_customer"): + batch_qty = batch_qty * -1 + + if batch_qty <= 0: + continue + + if parent_doc.doctype in ["Purchase Invoice", "Purchase Reecipt"]: + batch_qty = get_available_batch_qty( + parent_doc, + batch_no, + warehouse, + ) + + if batch_qty <= 0: + frappe.throw( + _("Batch {0} is not available in warehouse {1}").format(batch_no, warehouse), + title=_("Batch Not Available for Return"), + ) + + if qty <= 0: + break + + if batch_qty > qty: + filterd_serial_batch["batches"][batch_no] = qty + qty = 0 + else: + filterd_serial_batch["batches"][batch_no] += batch_qty + qty -= batch_qty + + return filterd_serial_batch + + +def get_available_batch_qty(parent_doc, batch_no, warehouse): + from erpnext.stock.doctype.batch.batch import get_batch_qty + + return get_batch_qty( + batch_no, + warehouse, + posting_date=parent_doc.posting_date, + posting_time=parent_doc.posting_time, + for_stock_levels=True, + ) + + +def make_serial_batch_bundle_for_return(data, child_doc, parent_doc, warehouse_field=None): + from erpnext.stock.serial_batch_bundle import SerialBatchCreation + + type_of_transaction = "Outward" + if parent_doc.doctype in ["Sales Invoice", "Delivery Note", "POS Invoice"]: + type_of_transaction = "Inward" + + if not warehouse_field: + warehouse_field = "warehouse" + + warehouse = child_doc.get(warehouse_field) + if parent_doc.get("is_internal_customer"): + warehouse = child_doc.get("target_warehouse") + type_of_transaction = "Outward" + + cls_obj = SerialBatchCreation( + { + "type_of_transaction": type_of_transaction, + "item_code": child_doc.item_code, + "warehouse": warehouse, + "serial_nos": data.get("serial_nos"), + "batches": data.get("batches"), + "posting_date": parent_doc.posting_date, + "posting_time": parent_doc.posting_time, + "voucher_type": parent_doc.doctype, + "voucher_no": parent_doc.name, + "voucher_detail_no": child_doc.name, + "qty": child_doc.qty, + "company": parent_doc.company, + "do_not_submit": True, + } + ).make_serial_and_batch_bundle() + + return cls_obj.name + + +def get_available_serial_nos(serial_nos, warehouse): + return frappe.get_all( + "Serial No", filters={"warehouse": warehouse, "name": ("in", serial_nos)}, pluck="name" + ) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 524bbfcc4b1..7ced1eb1c54 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -16,6 +16,11 @@ from erpnext.accounts.general_ledger import ( ) from erpnext.accounts.utils import cancel_exchange_gain_loss_journal, get_fiscal_year from erpnext.controllers.accounts_controller import AccountsController +from erpnext.controllers.sales_and_purchase_return import ( + available_serial_batch_for_return, + filter_serial_batches, + make_serial_batch_bundle_for_return, +) from erpnext.stock import get_warehouse_account_map from erpnext.stock.doctype.inventory_dimension.inventory_dimension import ( get_evaluated_inventory_dimension, @@ -217,6 +222,125 @@ class StockController(AccountsController): self.update_bundle_details(bundle_details, table_name, row, is_rejected=True) self.create_serial_batch_bundle(bundle_details, row) + def make_bundle_for_sales_purchase_return(self, table_name=None): + if not self.get("is_return"): + return + + if not table_name: + table_name = "items" + + self.make_bundle_for_non_rejected_qty(table_name) + + if self.doctype in ["Purchase Invoice", "Purchase Receipt"]: + self.make_bundle_for_rejected_qty(table_name) + + def make_bundle_for_rejected_qty(self, table_name=None): + field, reference_ids = self.get_reference_ids( + table_name, "rejected_qty", "rejected_serial_and_batch_bundle" + ) + + if not reference_ids: + return + + child_doctype = self.doctype + " Item" + available_dict = available_serial_batch_for_return( + field, child_doctype, reference_ids, is_rejected=True + ) + + for row in self.get(table_name): + if data := available_dict.get(row.get(field)): + qty_field = "rejected_qty" + warehouse_field = "rejected_warehouse" + if row.get("return_qty_from_rejected_warehouse"): + qty_field = "qty" + warehouse_field = "warehouse" + + data = filter_serial_batches( + self, data, row, warehouse_field=warehouse_field, qty_field=qty_field + ) + bundle = make_serial_batch_bundle_for_return(data, row, self, warehouse_field) + if row.get("return_qty_from_rejected_warehouse"): + row.db_set( + { + "serial_and_batch_bundle": bundle, + "batch_no": "", + "serial_no": "", + } + ) + else: + row.db_set( + { + "rejected_serial_and_batch_bundle": bundle, + "batch_no": "", + "rejected_serial_no": "", + } + ) + + def make_bundle_for_non_rejected_qty(self, table_name): + field, reference_ids = self.get_reference_ids(table_name) + if not reference_ids: + return + + child_doctype = self.doctype + " Item" + available_dict = available_serial_batch_for_return(field, child_doctype, reference_ids) + + for row in self.get(table_name): + if data := available_dict.get(row.get(field)): + data = filter_serial_batches(self, data, row) + bundle = make_serial_batch_bundle_for_return(data, row, self) + row.db_set( + { + "serial_and_batch_bundle": bundle, + "batch_no": "", + "serial_no": "", + } + ) + + def get_reference_ids(self, table_name, qty_field=None, bundle_field=None) -> tuple[str, list[str]]: + field = { + "Sales Invoice": "sales_invoice_item", + "Delivery Note": "dn_detail", + "Purchase Receipt": "purchase_receipt_item", + "Purchase Invoice": "purchase_invoice_item", + "POS Invoice": "pos_invoice_item", + }.get(self.doctype) + + if not bundle_field: + bundle_field = "serial_and_batch_bundle" + + if not qty_field: + qty_field = "qty" + + reference_ids = [] + + for row in self.get(table_name): + if not self.is_serial_batch_item(row.item_code): + continue + + if ( + row.get(field) + and ( + qty_field == "qty" + and not row.get("return_qty_from_rejected_warehouse") + or qty_field == "rejected_qty" + and (row.get("return_qty_from_rejected_warehouse") or row.get("rejected_warehouse")) + ) + and not row.get("use_serial_batch_fields") + and not row.get(bundle_field) + ): + reference_ids.append(row.get(field)) + + return field, reference_ids + + @frappe.request_cache + def is_serial_batch_item(self, item_code) -> bool: + item_details = frappe.db.get_value("Item", item_code, ["has_serial_no", "has_batch_no"], as_dict=1) + + if item_details.has_serial_no or item_details.has_batch_no: + return True + + return False + def update_bundle_details(self, bundle_details, table_name, row, is_rejected=False): from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -611,35 +735,16 @@ class StockController(AccountsController): def make_package_for_transfer( self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None ): - bundle_doc = frappe.get_doc("Serial and Batch Bundle", serial_and_batch_bundle) - - if not type_of_transaction: - type_of_transaction = "Inward" - - bundle_doc = frappe.copy_doc(bundle_doc) - bundle_doc.warehouse = warehouse - bundle_doc.type_of_transaction = type_of_transaction - bundle_doc.voucher_type = self.doctype - bundle_doc.voucher_no = "" if self.is_new() or self.docstatus == 2 else self.name - bundle_doc.is_cancelled = 0 - - for row in bundle_doc.entries: - row.is_outward = 0 - row.qty = abs(row.qty) - row.stock_value_difference = abs(row.stock_value_difference) - if type_of_transaction == "Outward": - row.qty *= -1 - row.stock_value_difference *= row.stock_value_difference - row.is_outward = 1 - - row.warehouse = warehouse - - bundle_doc.calculate_qty_and_amount() - bundle_doc.flags.ignore_permissions = True - bundle_doc.flags.ignore_validate = True - bundle_doc.save(ignore_permissions=True) - - return bundle_doc.name + return make_bundle_for_material_transfer( + is_new=self.is_new(), + docstatus=self.docstatus, + voucher_type=self.doctype, + voucher_no=self.name, + serial_and_batch_bundle=serial_and_batch_bundle, + warehouse=warehouse, + type_of_transaction=type_of_transaction, + do_not_submit=do_not_submit, + ) def get_sl_entries(self, d, args): sl_dict = frappe._dict( @@ -1557,3 +1662,38 @@ def create_item_wise_repost_entries( repost_entries.append(repost_entry) return repost_entries + + +def make_bundle_for_material_transfer(**kwargs): + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + bundle_doc = frappe.get_doc("Serial and Batch Bundle", kwargs.serial_and_batch_bundle) + + if not kwargs.type_of_transaction: + kwargs.type_of_transaction = "Inward" + + bundle_doc = frappe.copy_doc(bundle_doc) + bundle_doc.warehouse = kwargs.warehouse + bundle_doc.type_of_transaction = kwargs.type_of_transaction + bundle_doc.voucher_type = kwargs.voucher_type + bundle_doc.voucher_no = "" if kwargs.is_new or kwargs.docstatus == 2 else kwargs.voucher_no + bundle_doc.is_cancelled = 0 + + for row in bundle_doc.entries: + row.is_outward = 0 + row.qty = abs(row.qty) + row.stock_value_difference = abs(row.stock_value_difference) + if kwargs.type_of_transaction == "Outward": + row.qty *= -1 + row.stock_value_difference *= row.stock_value_difference + row.is_outward = 1 + + row.warehouse = kwargs.warehouse + + bundle_doc.calculate_qty_and_amount() + bundle_doc.flags.ignore_permissions = True + bundle_doc.flags.ignore_validate = True + bundle_doc.save(ignore_permissions=True) + + return bundle_doc.name diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index f2a53858a9a..79ace06d25a 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -8,8 +8,11 @@ from pypika import Order class DeprecatedSerialNoValuation: @deprecated def calculate_stock_value_from_deprecarated_ledgers(self): - if not frappe.db.get_value( - "Stock Ledger Entry", {"serial_no": ("is", "set"), "is_cancelled": 0}, "name" + if not frappe.db.get_all( + "Stock Ledger Entry", + fields=["name"], + filters={"serial_no": ("is", "set"), "is_cancelled": 0, "item_code": self.sle.item_code}, + limit=1, ): return @@ -41,6 +44,12 @@ class DeprecatedSerialNoValuation: # get rate from serial nos within same company incoming_values = 0.0 for serial_no in serial_nos: + sn_details = frappe.db.get_value("Serial No", serial_no, ["purchase_rate", "company"], as_dict=1) + if sn_details and sn_details.purchase_rate and sn_details.company == self.sle.company: + self.serial_no_incoming_rate[serial_no] += flt(sn_details.purchase_rate) + incoming_values += self.serial_no_incoming_rate[serial_no] + continue + table = frappe.qb.DocType("Stock Ledger Entry") stock_ledgers = ( frappe.qb.from_(table) diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 77b87aa995c..0be85e46015 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -208,7 +208,8 @@ def get_batch_qty( :param batch_no: Optional - give qty for this batch no :param warehouse: Optional - give qty for this warehouse - :param item_code: Optional - give qty for this item""" + :param item_code: Optional - give qty for this item + :param for_stock_levels: True consider expired batches""" from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( get_auto_batch_nos, diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 013415e1cc7..df6536624fa 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -468,6 +468,7 @@ class DeliveryNote(SellingController): if not self.get(table_name): continue + self.make_bundle_for_sales_purchase_return(table_name) self.make_bundle_using_old_serial_batch_fields(table_name) # Updating stock ledger should always be called after updating prevdoc status, @@ -1365,6 +1366,9 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None): if source_parent.doctype == "Delivery Note" and source.received_qty: target.qty = flt(source.qty) + flt(source.returned_qty) - flt(source.received_qty) + if source.get("use_serial_batch_fields"): + target.set("use_serial_batch_fields", 1) + doclist = get_mapped_doc( doctype, source_name, diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index e01a3397b64..58ea0be5d77 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -261,18 +261,15 @@ class TestDeliveryNote(FrappeTestCase): self.assertTrue(dn.items[0].serial_no) frappe.flags.ignore_serial_batch_bundle_validation = False + frappe.flags.use_serial_and_batch_fields = False # return entry dn1 = make_sales_return(dn.name) dn1.items[0].qty = -2 - - bundle_doc = frappe.get_doc("Serial and Batch Bundle", dn1.items[0].serial_and_batch_bundle) - bundle_doc.set("entries", bundle_doc.entries[:2]) - bundle_doc.save() - - dn1.save() + dn1.items[0].serial_no = "\n".join(get_serial_nos(serial_nos)[0:2]) dn1.submit() + dn1.reload() returned_serial_nos1 = get_serial_nos_from_bundle(dn1.items[0].serial_and_batch_bundle) for serial_no in returned_serial_nos1: @@ -281,21 +278,15 @@ class TestDeliveryNote(FrappeTestCase): dn2 = make_sales_return(dn.name) dn2.items[0].qty = -2 - - bundle_doc = frappe.get_doc("Serial and Batch Bundle", dn2.items[0].serial_and_batch_bundle) - bundle_doc.set("entries", bundle_doc.entries[:2]) - bundle_doc.save() - - dn2.save() + dn2.items[0].serial_no = "\n".join(get_serial_nos(serial_nos)[2:4]) dn2.submit() + dn2.reload() returned_serial_nos2 = get_serial_nos_from_bundle(dn2.items[0].serial_and_batch_bundle) for serial_no in returned_serial_nos2: self.assertTrue(serial_no in serial_nos) self.assertFalse(serial_no in returned_serial_nos1) - frappe.flags.use_serial_and_batch_fields = False - def test_sales_return_for_non_bundled_items_partial(self): company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company") @@ -440,7 +431,7 @@ class TestDeliveryNote(FrappeTestCase): self.assertEqual(dn.per_returned, 100) self.assertEqual(dn.status, "Return Issued") - def test_delivery_note_return_valuation_on_different_warehuose(self): + def test_delivery_note_return_valuation_on_different_warehouse(self): from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company") diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 94ad468cbc9..1146e0aab4f 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -371,6 +371,7 @@ class PurchaseReceipt(BuyingController): else: self.db_set("status", "Completed") + self.make_bundle_for_sales_purchase_return() self.make_bundle_using_old_serial_batch_fields() # Updating stock ledger should always be called after updating prevdoc status, # because updating ordered qty, reserved_qty_for_subcontract in bin diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 830a5e99567..4d1e8e841e9 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -2685,7 +2685,7 @@ class TestPurchaseReceipt(FrappeTestCase): for row in inter_transfer_dn_return.items: self.assertTrue(row.serial_and_batch_bundle) - def test_internal_transfer_with_serial_batch_items_without_user_serial_batch_fields(self): + def test_internal_transfer_with_serial_batch_items_without_use_serial_batch_fields(self): from erpnext.controllers.sales_and_purchase_return import make_return_doc from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json index 6ba1469a46a..9604c55450e 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -81,6 +81,7 @@ "purchase_invoice", "column_break_40", "allow_zero_valuation_rate", + "return_qty_from_rejected_warehouse", "is_fixed_asset", "asset_location", "asset_category", @@ -1116,12 +1117,19 @@ "hidden": 1, "label": "Apply TDS", "read_only": 1 + }, + { + "default": "0", + "fieldname": "return_qty_from_rejected_warehouse", + "fieldtype": "Check", + "label": "Return Qty from Rejected Warehouse", + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2024-04-08 20:00:16.277292", + "modified": "2024-05-28 09:48:24.448815", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt Item", diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py index 908c0a7a0f4..393b6a25691 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py @@ -85,6 +85,7 @@ class PurchaseReceiptItem(Document): rejected_serial_no: DF.Text | None rejected_warehouse: DF.Link | None retain_sample: DF.Check + return_qty_from_rejected_warehouse: DF.Check returned_qty: DF.Float rm_supp_cost: DF.Currency sales_order: DF.Link | None diff --git a/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py b/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py index 9f7fdeccd05..9178229c018 100644 --- a/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py +++ b/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py @@ -377,7 +377,7 @@ class TestPutawayRule(FrappeTestCase): apply_putaway_rule=1, do_not_save=1, ) - stock_entry.save() + stock_entry.submit() stock_entry.load_from_db() self.assertEqual(stock_entry.items[0].t_warehouse, self.warehouse_1) @@ -398,11 +398,17 @@ class TestPutawayRule(FrappeTestCase): self.assertUnchangedItemsOnResave(stock_entry) - for row in stock_entry.items: - if row.serial_and_batch_bundle: - frappe.delete_doc("Serial and Batch Bundle", row.serial_and_batch_bundle) - stock_entry.load_from_db() + stock_entry.cancel() + + rivs = frappe.get_all("Repost Item Valuation", filters={"voucher_no": stock_entry.name}) + for row in rivs: + riv_doc = frappe.get_doc("Repost Item Valuation", row.name) + riv_doc.cancel() + riv_doc.delete() + + frappe.db.set_single_value("Accounts Settings", "delete_linked_ledger_entries", 1) + stock_entry.delete() pr.cancel() rule_1.delete() diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 5e16115db01..ad757351a9b 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -156,6 +156,8 @@ class SerialandBatchBundle(Document): def validate_serial_nos_duplicate(self): # Don't inward same serial number multiple times + if self.voucher_type in ["POS Invoice", "Pick List"]: + return if not self.warehouse: return diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index fb63f1c23c6..4e00de0d7ce 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -111,6 +111,8 @@ frappe.ui.form.on("Stock Entry", { // or a pre-existing batch if (frm.doc.purpose != "Material Receipt") { filters["warehouse"] = item.s_warehouse || item.t_warehouse; + } else { + filters["is_inward"] = 1; } return { @@ -1110,6 +1112,13 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle on_submit() { this.clean_up(); + this.refresh_serial_batch_bundle_field(); + } + + refresh_serial_batch_bundle_field() { + frappe.route_hooks.after_submit = (frm_obj) => { + frm_obj.reload_doc(); + }; } after_cancel() { diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 9b66fb2ba95..61be2849326 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -226,6 +226,7 @@ class StockEntry(StockController): if not self.from_bom: self.fg_completed_qty = 0.0 + self.make_serial_and_batch_bundle_for_outward() self.validate_serialized_batch() self.calculate_rate_and_amount() self.validate_putaway_capacity() @@ -289,9 +290,6 @@ class StockEntry(StockController): if self.purpose == "Material Transfer" and self.outgoing_stock_entry: self.set_material_request_transfer_status("In Transit") - def before_save(self): - self.make_serial_and_batch_bundle_for_outward() - def on_update(self): self.set_serial_and_batch_bundle() @@ -997,7 +995,7 @@ class StockEntry(StockController): self.purpose = frappe.get_cached_value("Stock Entry Type", self.stock_entry_type, "purpose") def make_serial_and_batch_bundle_for_outward(self): - if self.docstatus == 1: + if self.docstatus == 0: return serial_or_batch_items = get_serial_or_batch_items(self.items) @@ -1050,12 +1048,11 @@ class StockEntry(StockController): if not bundle_doc: continue - if self.docstatus == 0: - for entry in bundle_doc.entries: - if not entry.serial_no: - continue + for entry in bundle_doc.entries: + if not entry.serial_no: + continue - already_picked_serial_nos.append(entry.serial_no) + already_picked_serial_nos.append(entry.serial_no) row.serial_and_batch_bundle = bundle_doc.name diff --git a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py index 16a0de57a5d..822da13cc72 100644 --- a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py +++ b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py @@ -4,7 +4,7 @@ import frappe from frappe import _ -from frappe.utils import cint, flt, get_table_name, getdate +from frappe.utils import add_to_date, cint, flt, get_datetime, get_table_name, getdate from frappe.utils.deprecations import deprecated from pypika import functions as fn @@ -107,6 +107,8 @@ def get_stock_ledger_entries_for_batch_no(filters): if not filters.get("to_date"): frappe.throw(_("'To Date' is required")) + posting_datetime = get_datetime(add_to_date(filters["to_date"], days=1)) + sle = frappe.qb.DocType("Stock Ledger Entry") query = ( frappe.qb.from_(sle) @@ -121,7 +123,7 @@ def get_stock_ledger_entries_for_batch_no(filters): (sle.docstatus < 2) & (sle.is_cancelled == 0) & (sle.batch_no != "") - & (sle.posting_date <= filters["to_date"]) + & (sle.posting_datetime < posting_datetime) ) .groupby(sle.voucher_no, sle.batch_no, sle.item_code, sle.warehouse) .orderby(sle.item_code, sle.warehouse) diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 50a7707d4a9..fcebf0491ac 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -55,8 +55,45 @@ class SerialBatchBundle: elif not self.sle.is_cancelled: self.validate_item_and_warehouse() + def is_material_transfer(self): + allowed_types = [ + "Material Transfer", + "Send to Subcontractor", + "Material Transfer for Manufacture", + ] + + if ( + self.sle.voucher_type == "Stock Entry" + and not self.sle.is_cancelled + and frappe.get_cached_value("Stock Entry", self.sle.voucher_no, "purpose") in allowed_types + ): + return True + + def make_serial_batch_no_bundle_for_material_transfer(self): + from erpnext.controllers.stock_controller import make_bundle_for_material_transfer + + bundle = frappe.db.get_value( + "Stock Entry Detail", self.sle.voucher_detail_no, "serial_and_batch_bundle" + ) + + if bundle: + new_bundle_id = make_bundle_for_material_transfer( + is_new=False, + docstatus=1, + voucher_type=self.sle.voucher_type, + voucher_no=self.sle.voucher_no, + serial_and_batch_bundle=bundle, + warehouse=self.sle.warehouse, + type_of_transaction="Inward" if self.sle.actual_qty > 0 else "Outward", + do_not_submit=0, + ) + self.sle.db_set({"serial_and_batch_bundle": new_bundle_id}) + def make_serial_batch_no_bundle(self): self.validate_item() + if self.sle.actual_qty > 0 and self.is_material_transfer(): + self.make_serial_batch_no_bundle_for_material_transfer() + return sn_doc = SerialBatchCreation( { @@ -143,6 +180,9 @@ class SerialBatchBundle: "serial_and_batch_bundle": sn_doc.name, } + if self.sle.actual_qty < 0 and self.is_material_transfer(): + values_to_update["valuation_rate"] = sn_doc.avg_rate + if not frappe.db.get_single_value( "Stock Settings", "do_not_update_serial_batch_on_creation_of_auto_bundle" ): @@ -341,11 +381,9 @@ def get_serial_nos(serial_and_batch_bundle, serial_nos=None): if serial_nos: filters["serial_no"] = ("in", serial_nos) - entries = frappe.get_all("Serial and Batch Entry", fields=["serial_no"], filters=filters, order_by="idx") - if not entries: - return [] + serial_nos = frappe.get_all("Serial and Batch Entry", filters=filters, order_by="idx", pluck="serial_no") - return [d.serial_no for d in entries if d.serial_no] + return serial_nos def get_batches_from_bundle(serial_and_batch_bundle, batches=None): diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index ac513f2d380..7bce0dacfa5 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -312,7 +312,11 @@ def get_reposting_data(file_path) -> dict: if isinstance(content, str): content = content.encode("utf-8") - data = gzip.decompress(content) + try: + data = gzip.decompress(content) + except Exception: + return frappe._dict() + if data := json.loads(data.decode("utf-8")): data = data diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js index 0dff297e45d..8dfd9bd486d 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js @@ -302,6 +302,21 @@ frappe.ui.form.on("Subcontracting Receipt", { }; } }, + + reset_raw_materials_table: (frm) => { + frm.clear_table("supplied_items"); + + frm.call({ + method: "reset_raw_materials", + doc: frm.doc, + freeze: true, + callback: (r) => { + if (!r.exc) { + frm.save(); + } + }, + }); + }, }); frappe.ui.form.on("Landed Cost Taxes and Charges", { diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.json b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.json index 2024f6e5952..0013fe63219 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.json +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.json @@ -47,8 +47,11 @@ "total_qty", "column_break_27", "total", - "raw_material_details", + "raw_materials_consumed_section", + "reset_raw_materials_table", + "column_break_uinr", "get_current_stock", + "raw_material_details", "supplied_items", "additional_costs_section", "distribute_additional_costs_based_on", @@ -300,6 +303,7 @@ "depends_on": "supplied_items", "fieldname": "raw_material_details", "fieldtype": "Section Break", + "hide_border": 1, "label": "Raw Materials Consumed", "options": "fa fa-table", "print_hide": 1, @@ -640,12 +644,26 @@ "fieldname": "supplier_delivery_note", "fieldtype": "Data", "label": "Supplier Delivery Note" + }, + { + "fieldname": "raw_materials_consumed_section", + "fieldtype": "Section Break", + "label": "Raw Materials Actions" + }, + { + "fieldname": "reset_raw_materials_table", + "fieldtype": "Button", + "label": "Reset Raw Materials Table" + }, + { + "fieldname": "column_break_uinr", + "fieldtype": "Column Break" } ], "in_create": 1, "is_submittable": 1, "links": [], - "modified": "2024-03-27 13:10:46.856728", + "modified": "2024-05-27 15:02:13.517969", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Receipt", diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index 52193c56afc..cb0eca1b75e 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -179,6 +179,11 @@ class SubcontractingReceipt(SubcontractingController): self.update_status() self.delete_auto_created_batches() + @frappe.whitelist() + def reset_raw_materials(self): + self.supplied_items = [] + self.create_raw_materials_supplied() + def validate_closed_subcontracting_order(self): for item in self.items: if item.subcontracting_order: From 6b0ce33030b4944f994a6d62dbfa23e11aceff8d Mon Sep 17 00:00:00 2001 From: Khushi Rawat <142375893+khushi8112@users.noreply.github.com> Date: Tue, 28 May 2024 20:37:52 +0530 Subject: [PATCH 15/74] =?UTF-8?q?refactor:=20renamed=20number=20of=20depre?= =?UTF-8?q?ciations=20booked=20to=20opening=20booked=20de=E2=80=A6=20(#415?= =?UTF-8?q?15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: renamed number of depreciations booked to opening booked depreciations * feat: introduced new field for showing total number of booked depreciations --- .../doctype/journal_entry/journal_entry.py | 24 ++++++++++++ erpnext/assets/doctype/asset/asset.json | 16 ++++---- erpnext/assets/doctype/asset/asset.py | 27 ++++++++++---- erpnext/assets/doctype/asset/depreciation.py | 1 + erpnext/assets/doctype/asset/test_asset.py | 26 ++++++------- .../asset_depreciation_schedule.json | 6 +-- .../asset_depreciation_schedule.py | 30 ++++++++------- .../test_asset_depreciation_schedule.py | 37 ++++++++++++++++++- .../asset_finance_book.json | 10 ++++- .../asset_finance_book/asset_finance_book.py | 1 + .../doctype/asset_repair/asset_repair.py | 4 +- erpnext/patches.txt | 1 + ...sset_depreciation_schedules_from_assets.py | 2 +- ..._booked_to_opening_booked_depreciations.py | 7 ++++ .../update_gpa_and_ndb_for_assdeprsch.py | 4 +- 15 files changed, 144 insertions(+), 52 deletions(-) create mode 100644 erpnext/patches/v15_0/rename_number_of_depreciations_booked_to_opening_booked_depreciations.py diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 84d05465bfc..e757f13108f 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -194,6 +194,7 @@ class JournalEntry(AccountsController): self.update_asset_value() self.update_inter_company_jv() self.update_invoice_discounting() + self.update_booked_depreciation() def on_update_after_submit(self): if hasattr(self, "repost_required"): @@ -225,6 +226,7 @@ class JournalEntry(AccountsController): self.unlink_inter_company_jv() self.unlink_asset_adjustment_entry() self.update_invoice_discounting() + self.update_booked_depreciation() def get_title(self): return self.pay_to_recd_from or self.accounts[0].account @@ -442,6 +444,28 @@ class JournalEntry(AccountsController): if status: inv_disc_doc.set_status(status=status) + def update_booked_depreciation(self): + for d in self.get("accounts"): + if ( + self.voucher_type == "Depreciation Entry" + and d.reference_type == "Asset" + and d.reference_name + and frappe.get_cached_value("Account", d.account, "root_type") == "Expense" + and d.debit + ): + asset = frappe.get_doc("Asset", d.reference_name) + for fb_row in asset.get("finance_books"): + if fb_row.finance_book == self.finance_book: + depr_schedule = get_depr_schedule(asset.name, "Active", fb_row.finance_book) + total_number_of_booked_depreciations = asset.opening_number_of_booked_depreciations + for je in depr_schedule: + if je.journal_entry: + total_number_of_booked_depreciations += 1 + fb_row.db_set( + "total_number_of_booked_depreciations", total_number_of_booked_depreciations + ) + break + def unlink_advance_entry_reference(self): for d in self.get("accounts"): if d.is_advance == "Yes" and d.reference_type in ("Sales Invoice", "Purchase Invoice"): diff --git a/erpnext/assets/doctype/asset/asset.json b/erpnext/assets/doctype/asset/asset.json index 5980ca13e02..587faaed14e 100644 --- a/erpnext/assets/doctype/asset/asset.json +++ b/erpnext/assets/doctype/asset/asset.json @@ -45,7 +45,7 @@ "calculate_depreciation", "column_break_33", "opening_accumulated_depreciation", - "number_of_depreciations_booked", + "opening_number_of_booked_depreciations", "is_fully_depreciated", "section_break_36", "finance_books", @@ -257,12 +257,6 @@ "label": "Opening Accumulated Depreciation", "options": "Company:company:default_currency" }, - { - "depends_on": "eval:(doc.is_existing_asset)", - "fieldname": "number_of_depreciations_booked", - "fieldtype": "Int", - "label": "Number of Depreciations Booked" - }, { "collapsible": 1, "collapsible_depends_on": "eval:doc.calculate_depreciation || doc.is_existing_asset", @@ -546,6 +540,12 @@ "no_copy": 1, "print_hide": 1, "read_only": 1 + }, + { + "depends_on": "eval:(doc.is_existing_asset)", + "fieldname": "opening_number_of_booked_depreciations", + "fieldtype": "Int", + "label": "Opening Number of Booked Depreciations" } ], "idx": 72, @@ -589,7 +589,7 @@ "link_fieldname": "target_asset" } ], - "modified": "2024-04-18 16:45:47.306032", + "modified": "2024-05-21 13:46:21.066483", "modified_by": "Administrator", "module": "Assets", "name": "Asset", diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 15ad6c4fb42..c509909951c 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -89,8 +89,8 @@ class Asset(AccountsController): maintenance_required: DF.Check naming_series: DF.Literal["ACC-ASS-.YYYY.-"] next_depreciation_date: DF.Date | None - number_of_depreciations_booked: DF.Int opening_accumulated_depreciation: DF.Currency + opening_number_of_booked_depreciations: DF.Int policy_number: DF.Data | None purchase_amount: DF.Currency purchase_date: DF.Date | None @@ -145,7 +145,7 @@ class Asset(AccountsController): "Asset Depreciation Schedules created:
{0}

Please check, edit if needed, and submit the Asset." ).format(asset_depr_schedules_links) ) - + self.set_total_booked_depreciations() self.total_asset_cost = self.gross_purchase_amount self.status = self.get_status() @@ -419,7 +419,7 @@ class Asset(AccountsController): if not self.is_existing_asset: self.opening_accumulated_depreciation = 0 - self.number_of_depreciations_booked = 0 + self.opening_number_of_booked_depreciations = 0 else: depreciable_amount = flt(self.gross_purchase_amount) - flt(row.expected_value_after_useful_life) if flt(self.opening_accumulated_depreciation) > depreciable_amount: @@ -430,15 +430,15 @@ class Asset(AccountsController): ) if self.opening_accumulated_depreciation: - if not self.number_of_depreciations_booked: - frappe.throw(_("Please set Number of Depreciations Booked")) + if not self.opening_number_of_booked_depreciations: + frappe.throw(_("Please set Opening Number of Booked Depreciations")) else: - self.number_of_depreciations_booked = 0 + self.opening_number_of_booked_depreciations = 0 - if flt(row.total_number_of_depreciations) <= cint(self.number_of_depreciations_booked): + if flt(row.total_number_of_depreciations) <= cint(self.opening_number_of_booked_depreciations): frappe.throw( _( - "Row {0}: Total Number of Depreciations cannot be less than or equal to Number of Depreciations Booked" + "Row {0}: Total Number of Depreciations cannot be less than or equal to Opening Number of Booked Depreciations" ).format(row.idx), title=_("Invalid Schedule"), ) @@ -459,6 +459,17 @@ class Asset(AccountsController): ).format(row.idx) ) + def set_total_booked_depreciations(self): + # set value of total number of booked depreciations field + for fb_row in self.get("finance_books"): + total_number_of_booked_depreciations = self.opening_number_of_booked_depreciations + depr_schedule = get_depr_schedule(self.name, "Active", fb_row.finance_book) + if depr_schedule: + for je in depr_schedule: + if je.journal_entry: + total_number_of_booked_depreciations += 1 + fb_row.db_set("total_number_of_booked_depreciations", total_number_of_booked_depreciations) + def validate_expected_value_after_useful_life(self): for row in self.get("finance_books"): depr_schedule = get_depr_schedule(self.name, "Draft", row.finance_book) diff --git a/erpnext/assets/doctype/asset/depreciation.py b/erpnext/assets/doctype/asset/depreciation.py index b6a4d912d63..f06abaa1202 100644 --- a/erpnext/assets/doctype/asset/depreciation.py +++ b/erpnext/assets/doctype/asset/depreciation.py @@ -323,6 +323,7 @@ def _make_journal_entry_for_depreciation( if not je.meta.get_workflow(): je.submit() + asset.reload() idx = cint(asset_depr_schedule_doc.finance_book_id) row = asset.get("finance_books")[idx - 1] row.value_after_depreciation -= depr_schedule.depreciation_amount diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 5c49c0a8451..f3ec6122d53 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -355,7 +355,7 @@ class TestAsset(AssetSetup): purchase_date="2020-04-01", expected_value_after_useful_life=0, total_number_of_depreciations=5, - number_of_depreciations_booked=2, + opening_number_of_booked_depreciations=2, frequency_of_depreciation=12, depreciation_start_date="2023-03-31", opening_accumulated_depreciation=24000, @@ -453,7 +453,7 @@ class TestAsset(AssetSetup): purchase_date="2020-01-01", expected_value_after_useful_life=0, total_number_of_depreciations=6, - number_of_depreciations_booked=1, + opening_number_of_booked_depreciations=1, frequency_of_depreciation=10, depreciation_start_date="2021-01-01", opening_accumulated_depreciation=20000, @@ -739,7 +739,7 @@ class TestDepreciationMethods(AssetSetup): calculate_depreciation=1, available_for_use_date="2030-06-06", is_existing_asset=1, - number_of_depreciations_booked=2, + opening_number_of_booked_depreciations=2, opening_accumulated_depreciation=47095.89, expected_value_after_useful_life=10000, depreciation_start_date="2032-12-31", @@ -789,7 +789,7 @@ class TestDepreciationMethods(AssetSetup): available_for_use_date="2030-01-01", is_existing_asset=1, depreciation_method="Double Declining Balance", - number_of_depreciations_booked=1, + opening_number_of_booked_depreciations=1, opening_accumulated_depreciation=50000, expected_value_after_useful_life=10000, depreciation_start_date="2031-12-31", @@ -1123,8 +1123,8 @@ class TestDepreciationBasics(AssetSetup): self.assertRaises(frappe.ValidationError, asset.save) - def test_number_of_depreciations_booked(self): - """Tests if an error is raised when number_of_depreciations_booked is not specified when opening_accumulated_depreciation is.""" + def test_opening_booked_depreciations(self): + """Tests if an error is raised when opening_number_of_booked_depreciations is not specified when opening_accumulated_depreciation is.""" asset = create_asset( item_code="Macbook Pro", @@ -1140,9 +1140,9 @@ class TestDepreciationBasics(AssetSetup): self.assertRaises(frappe.ValidationError, asset.save) def test_number_of_depreciations(self): - """Tests if an error is raised when number_of_depreciations_booked >= total_number_of_depreciations.""" + """Tests if an error is raised when opening_number_of_booked_depreciations >= total_number_of_depreciations.""" - # number_of_depreciations_booked > total_number_of_depreciations + # opening_number_of_booked_depreciations > total_number_of_depreciations asset = create_asset( item_code="Macbook Pro", calculate_depreciation=1, @@ -1151,13 +1151,13 @@ class TestDepreciationBasics(AssetSetup): expected_value_after_useful_life=10000, depreciation_start_date="2020-07-01", opening_accumulated_depreciation=10000, - number_of_depreciations_booked=5, + opening_number_of_booked_depreciations=5, do_not_save=1, ) self.assertRaises(frappe.ValidationError, asset.save) - # number_of_depreciations_booked = total_number_of_depreciations + # opening_number_of_booked_depreciations = total_number_of_depreciations asset_2 = create_asset( item_code="Macbook Pro", calculate_depreciation=1, @@ -1166,7 +1166,7 @@ class TestDepreciationBasics(AssetSetup): expected_value_after_useful_life=10000, depreciation_start_date="2020-07-01", opening_accumulated_depreciation=10000, - number_of_depreciations_booked=5, + opening_number_of_booked_depreciations=5, do_not_save=1, ) @@ -1502,7 +1502,7 @@ class TestDepreciationBasics(AssetSetup): asset = create_asset(calculate_depreciation=1) asset.opening_accumulated_depreciation = 2000 - asset.number_of_depreciations_booked = 1 + asset.opening_number_of_booked_depreciations = 1 asset.finance_books[0].expected_value_after_useful_life = 100 asset.save() @@ -1696,7 +1696,7 @@ def create_asset(**args): "purchase_date": args.purchase_date or "2015-01-01", "calculate_depreciation": args.calculate_depreciation or 0, "opening_accumulated_depreciation": args.opening_accumulated_depreciation or 0, - "number_of_depreciations_booked": args.number_of_depreciations_booked or 0, + "opening_number_of_booked_depreciations": args.opening_number_of_booked_depreciations or 0, "gross_purchase_amount": args.gross_purchase_amount or 100000, "purchase_amount": args.purchase_amount or 100000, "maintenance_required": args.maintenance_required or 0, diff --git a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.json b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.json index ffb06c7e276..76565cb4e38 100644 --- a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.json +++ b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.json @@ -13,7 +13,7 @@ "column_break_2", "gross_purchase_amount", "opening_accumulated_depreciation", - "number_of_depreciations_booked", + "opening_number_of_booked_depreciations", "finance_book", "finance_book_id", "depreciation_details_section", @@ -171,10 +171,10 @@ "read_only": 1 }, { - "fieldname": "number_of_depreciations_booked", + "fieldname": "opening_number_of_booked_depreciations", "fieldtype": "Int", "hidden": 1, - "label": "Number of Depreciations Booked", + "label": "Opening Number of Booked Depreciations", "print_hide": 1, "read_only": 1 }, diff --git a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py index f64e9123dc0..d9fc5b3dd47 100644 --- a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py +++ b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py @@ -50,7 +50,7 @@ class AssetDepreciationSchedule(Document): gross_purchase_amount: DF.Currency naming_series: DF.Literal["ACC-ADS-.YYYY.-"] notes: DF.SmallText | None - number_of_depreciations_booked: DF.Int + opening_number_of_booked_depreciations: DF.Int opening_accumulated_depreciation: DF.Currency rate_of_depreciation: DF.Percent shift_based: DF.Check @@ -161,7 +161,7 @@ class AssetDepreciationSchedule(Document): return ( asset_doc.gross_purchase_amount != self.gross_purchase_amount or asset_doc.opening_accumulated_depreciation != self.opening_accumulated_depreciation - or asset_doc.number_of_depreciations_booked != self.number_of_depreciations_booked + or asset_doc.opening_number_of_booked_depreciations != self.opening_number_of_booked_depreciations ) def not_manual_depr_or_have_manual_depr_details_been_modified(self, row): @@ -194,7 +194,7 @@ class AssetDepreciationSchedule(Document): self.finance_book = row.finance_book self.finance_book_id = row.idx self.opening_accumulated_depreciation = asset_doc.opening_accumulated_depreciation or 0 - self.number_of_depreciations_booked = asset_doc.number_of_depreciations_booked or 0 + self.opening_number_of_booked_depreciations = asset_doc.opening_number_of_booked_depreciations or 0 self.gross_purchase_amount = asset_doc.gross_purchase_amount self.depreciation_method = row.depreciation_method self.total_number_of_depreciations = row.total_number_of_depreciations @@ -263,7 +263,7 @@ class AssetDepreciationSchedule(Document): row.db_update() final_number_of_depreciations = cint(row.total_number_of_depreciations) - cint( - self.number_of_depreciations_booked + self.opening_number_of_booked_depreciations ) has_pro_rata = _check_is_pro_rata(asset_doc, row) @@ -328,7 +328,7 @@ class AssetDepreciationSchedule(Document): if date_of_disposal and getdate(schedule_date) >= getdate(date_of_disposal): from_date = add_months( getdate(asset_doc.available_for_use_date), - (asset_doc.number_of_depreciations_booked * row.frequency_of_depreciation), + (asset_doc.opening_number_of_booked_depreciations * row.frequency_of_depreciation), ) if self.depreciation_schedule: from_date = self.depreciation_schedule[-1].schedule_date @@ -378,13 +378,16 @@ class AssetDepreciationSchedule(Document): from_date = get_last_day( add_months( getdate(asset_doc.available_for_use_date), - ((self.number_of_depreciations_booked - 1) * row.frequency_of_depreciation), + ( + (self.opening_number_of_booked_depreciations - 1) + * row.frequency_of_depreciation + ), ) ) else: from_date = add_months( getdate(add_days(asset_doc.available_for_use_date, -1)), - (self.number_of_depreciations_booked * row.frequency_of_depreciation), + (self.opening_number_of_booked_depreciations * row.frequency_of_depreciation), ) depreciation_amount, days, months = _get_pro_rata_amt( row, @@ -400,7 +403,8 @@ class AssetDepreciationSchedule(Document): # In case of increase_in_asset_life, the asset.to_date is already set on asset_repair submission asset_doc.to_date = add_months( asset_doc.available_for_use_date, - (n + self.number_of_depreciations_booked) * cint(row.frequency_of_depreciation), + (n + self.opening_number_of_booked_depreciations) + * cint(row.frequency_of_depreciation), ) depreciation_amount_without_pro_rata = depreciation_amount @@ -546,7 +550,7 @@ def _check_is_pro_rata(asset_doc, row, wdv_or_dd_non_yearly=False): has_pro_rata = False # if not existing asset, from_date = available_for_use_date - # otherwise, if number_of_depreciations_booked = 2, available_for_use_date = 01/01/2020 and frequency_of_depreciation = 12 + # otherwise, if opening_number_of_booked_depreciations = 2, available_for_use_date = 01/01/2020 and frequency_of_depreciation = 12 # from_date = 01/01/2022 from_date = _get_modified_available_for_use_date(asset_doc, row, wdv_or_dd_non_yearly) days = date_diff(row.depreciation_start_date, from_date) + 1 @@ -567,12 +571,12 @@ def _get_modified_available_for_use_date(asset_doc, row, wdv_or_dd_non_yearly=Fa if wdv_or_dd_non_yearly: return add_months( asset_doc.available_for_use_date, - (asset_doc.number_of_depreciations_booked * 12), + (asset_doc.opening_number_of_booked_depreciations * 12), ) else: return add_months( asset_doc.available_for_use_date, - (asset_doc.number_of_depreciations_booked * row.frequency_of_depreciation), + (asset_doc.opening_number_of_booked_depreciations * row.frequency_of_depreciation), ) @@ -678,7 +682,7 @@ def get_straight_line_or_manual_depr_amount( flt(asset.gross_purchase_amount) - flt(asset.opening_accumulated_depreciation) - flt(row.expected_value_after_useful_life) - ) / flt(row.total_number_of_depreciations - asset.number_of_depreciations_booked) + ) / flt(row.total_number_of_depreciations - asset.opening_number_of_booked_depreciations) def get_daily_prorata_based_straight_line_depr( @@ -704,7 +708,7 @@ def get_shift_depr_amount(asset_depr_schedule, asset, row, schedule_idx): flt(asset.gross_purchase_amount) - flt(asset.opening_accumulated_depreciation) - flt(row.expected_value_after_useful_life) - ) / flt(row.total_number_of_depreciations - asset.number_of_depreciations_booked) + ) / flt(row.total_number_of_depreciations - asset.opening_number_of_booked_depreciations) asset_shift_factors_map = get_asset_shift_factors_map() shift = ( diff --git a/erpnext/assets/doctype/asset_depreciation_schedule/test_asset_depreciation_schedule.py b/erpnext/assets/doctype/asset_depreciation_schedule/test_asset_depreciation_schedule.py index 6e4966ac6cf..6009ac1496c 100644 --- a/erpnext/assets/doctype/asset_depreciation_schedule/test_asset_depreciation_schedule.py +++ b/erpnext/assets/doctype/asset_depreciation_schedule/test_asset_depreciation_schedule.py @@ -5,6 +5,9 @@ import frappe from frappe.tests.utils import FrappeTestCase from frappe.utils import cstr +from erpnext.assets.doctype.asset.depreciation import ( + post_depreciation_entries, +) from erpnext.assets.doctype.asset.test_asset import create_asset, create_asset_data from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( get_asset_depr_schedule_doc, @@ -28,7 +31,7 @@ class TestAssetDepreciationSchedule(FrappeTestCase): self.assertRaises(frappe.ValidationError, second_asset_depr_schedule.insert) - def test_daily_prorata_based_depr_on_sl_methond(self): + def test_daily_prorata_based_depr_on_sl_method(self): asset = create_asset( calculate_depreciation=1, depreciation_method="Straight Line", @@ -160,3 +163,35 @@ class TestAssetDepreciationSchedule(FrappeTestCase): for d in get_depr_schedule(asset.name, "Draft") ] self.assertEqual(schedules, expected_schedules) + + def test_update_total_number_of_booked_depreciations(self): + # check if updates total number of booked depreciations when depreciation gets booked + asset = create_asset( + item_code="Macbook Pro", + calculate_depreciation=1, + opening_accumulated_depreciation=2000, + opening_number_of_booked_depreciations=2, + depreciation_method="Straight Line", + available_for_use_date="2020-03-01", + depreciation_start_date="2020-03-31", + frequency_of_depreciation=1, + total_number_of_depreciations=24, + submit=1, + ) + + post_depreciation_entries(date="2021-03-31") + asset.reload() + """ + opening_number_of_booked_depreciations = 2 + number_of_booked_depreciations till 2021-03-31 = 13 + total_number_of_booked_depreciations = 15 + """ + self.assertEqual(asset.finance_books[0].total_number_of_booked_depreciations, 15) + + # cancel depreciation entry + depr_entry = get_depr_schedule(asset.name, "Active")[0].journal_entry + + frappe.get_doc("Journal Entry", depr_entry).cancel() + asset.reload() + + self.assertEqual(asset.finance_books[0].total_number_of_booked_depreciations, 14) diff --git a/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json b/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json index a18e3a6e0f3..927cf91594d 100644 --- a/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json +++ b/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json @@ -8,6 +8,7 @@ "finance_book", "depreciation_method", "total_number_of_depreciations", + "total_number_of_booked_depreciations", "daily_prorata_based", "shift_based", "column_break_5", @@ -104,12 +105,19 @@ "fieldname": "shift_based", "fieldtype": "Check", "label": "Depreciate based on shifts" + }, + { + "default": "0", + "fieldname": "total_number_of_booked_depreciations", + "fieldtype": "Int", + "label": "Total Number of Booked Depreciations ", + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:06:34.342264", + "modified": "2024-05-21 15:48:20.907250", "modified_by": "Administrator", "module": "Assets", "name": "Asset Finance Book", diff --git a/erpnext/assets/doctype/asset_finance_book/asset_finance_book.py b/erpnext/assets/doctype/asset_finance_book/asset_finance_book.py index f812a0816dd..d06d6355ec3 100644 --- a/erpnext/assets/doctype/asset_finance_book/asset_finance_book.py +++ b/erpnext/assets/doctype/asset_finance_book/asset_finance_book.py @@ -28,6 +28,7 @@ class AssetFinanceBook(Document): rate_of_depreciation: DF.Percent salvage_value_percentage: DF.Percent shift_based: DF.Check + total_number_of_booked_depreciations: DF.Int total_number_of_depreciations: DF.Int value_after_depreciation: DF.Currency # end: auto-generated types diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py index 27542bc6de8..ccde836fe0d 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -377,7 +377,7 @@ class AssetRepair(AccountsController): def calculate_last_schedule_date(self, asset, row, extra_months): asset.flags.increase_in_asset_life = True number_of_pending_depreciations = cint(row.total_number_of_depreciations) - cint( - asset.number_of_depreciations_booked + asset.opening_number_of_booked_depreciations ) depr_schedule = get_depr_schedule(asset.name, "Active", row.finance_book) @@ -410,7 +410,7 @@ class AssetRepair(AccountsController): def calculate_last_schedule_date_before_modification(self, asset, row, extra_months): asset.flags.increase_in_asset_life = True number_of_pending_depreciations = cint(row.total_number_of_depreciations) - cint( - asset.number_of_depreciations_booked + asset.opening_number_of_booked_depreciations ) depr_schedule = get_depr_schedule(asset.name, "Active", row.finance_book) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index f58737712f8..ac6f8eb5ebe 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -367,3 +367,4 @@ erpnext.patches.v15_0.remove_cancelled_asset_capitalization_from_asset erpnext.patches.v15_0.fix_debit_credit_in_transaction_currency erpnext.patches.v15_0.rename_purchase_receipt_amount_to_purchase_amount erpnext.patches.v14_0.enable_set_priority_for_pricing_rules #1 +erpnext.patches.v15_0.rename_number_of_depreciations_booked_to_opening_booked_depreciations diff --git a/erpnext/patches/v15_0/create_asset_depreciation_schedules_from_assets.py b/erpnext/patches/v15_0/create_asset_depreciation_schedules_from_assets.py index c31d754d2cd..a39b3e3cb24 100644 --- a/erpnext/patches/v15_0/create_asset_depreciation_schedules_from_assets.py +++ b/erpnext/patches/v15_0/create_asset_depreciation_schedules_from_assets.py @@ -43,7 +43,7 @@ def get_details_of_draft_or_submitted_depreciable_assets(): asset.name, asset.opening_accumulated_depreciation, asset.gross_purchase_amount, - asset.number_of_depreciations_booked, + asset.opening_number_of_booked_depreciations, asset.docstatus, ) .where(asset.calculate_depreciation == 1) diff --git a/erpnext/patches/v15_0/rename_number_of_depreciations_booked_to_opening_booked_depreciations.py b/erpnext/patches/v15_0/rename_number_of_depreciations_booked_to_opening_booked_depreciations.py new file mode 100644 index 00000000000..18183374554 --- /dev/null +++ b/erpnext/patches/v15_0/rename_number_of_depreciations_booked_to_opening_booked_depreciations.py @@ -0,0 +1,7 @@ +import frappe +from frappe.model.utils.rename_field import rename_field + + +def execute(): + if frappe.db.has_column("Asset", "number_of_depreciations_booked"): + rename_field("Asset", "number_of_depreciations_booked", "opening_number_of_booked_depreciations") diff --git a/erpnext/patches/v15_0/update_gpa_and_ndb_for_assdeprsch.py b/erpnext/patches/v15_0/update_gpa_and_ndb_for_assdeprsch.py index afb59e0f6f5..4399a95fda2 100644 --- a/erpnext/patches/v15_0/update_gpa_and_ndb_for_assdeprsch.py +++ b/erpnext/patches/v15_0/update_gpa_and_ndb_for_assdeprsch.py @@ -9,12 +9,12 @@ def execute(): ON `tabAsset Depreciation Schedule`.`asset`=`tabAsset`.`name` SET `tabAsset Depreciation Schedule`.`gross_purchase_amount`=`tabAsset`.`gross_purchase_amount`, - `tabAsset Depreciation Schedule`.`number_of_depreciations_booked`=`tabAsset`.`number_of_depreciations_booked` + `tabAsset Depreciation Schedule`.`opening_number_of_booked_depreciations`=`tabAsset`.`opening_number_of_booked_depreciations` WHERE ( `tabAsset Depreciation Schedule`.`gross_purchase_amount`<>`tabAsset`.`gross_purchase_amount` OR - `tabAsset Depreciation Schedule`.`number_of_depreciations_booked`<>`tabAsset`.`number_of_depreciations_booked` + `tabAsset Depreciation Schedule`.`opening_number_of_booked_depreciations`<>`tabAsset`.`opening_number_of_booked_depreciations` ) AND `tabAsset Depreciation Schedule`.`docstatus`<2""" ) From 1c9fe691ea924a4b5f579544b639c7fb51017d07 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Wed, 29 May 2024 12:25:56 +0530 Subject: [PATCH 16/74] fix: Filters in account balance report --- erpnext/accounts/report/account_balance/account_balance.py | 1 - 1 file changed, 1 deletion(-) diff --git a/erpnext/accounts/report/account_balance/account_balance.py b/erpnext/accounts/report/account_balance/account_balance.py index 628aca5bfc9..b98ae11cdd7 100644 --- a/erpnext/accounts/report/account_balance/account_balance.py +++ b/erpnext/accounts/report/account_balance/account_balance.py @@ -49,7 +49,6 @@ def get_conditions(filters): if filters.account_type: conditions["account_type"] = filters.account_type - return conditions if filters.company: conditions["company"] = filters.company From 388ba7f945ff086e75dc6f9a8572561e014f7742 Mon Sep 17 00:00:00 2001 From: Nijith anil <83776819+nijithanil@users.noreply.github.com> Date: Wed, 29 May 2024 23:32:07 +0530 Subject: [PATCH 17/74] fix: work order created message pops up if no items are selected (#41677) --- erpnext/selling/doctype/sales_order/sales_order.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index d77a0287cc9..08af44d3b55 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -893,6 +893,9 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex fields: fields, primary_action: function () { var data = { items: d.fields_dict.items.grid.get_selected_children() }; + if (!data) { + frappe.throw(__("Please select items")); + } me.frm.call({ method: "make_work_orders", args: { From 3f2343614b484a5046a64def3927d99ceefddd53 Mon Sep 17 00:00:00 2001 From: Dany Robert Date: Thu, 30 May 2024 05:21:22 +0000 Subject: [PATCH 18/74] fix: test for pricing rule transaction with cond --- .../doctype/pricing_rule/test_pricing_rule.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py index b047898b771..7f9c55ff24f 100644 --- a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py @@ -929,6 +929,30 @@ class TestPricingRule(unittest.TestCase): for doc in [si, si1]: doc.delete() + def test_pricing_rule_for_transaction_with_condition(self): + make_item("PR Transaction Condition") + frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule") + make_pricing_rule( + selling=1, + min_qty=0, + price_or_product_discount="Product", + apply_on="Transaction", + free_item="PR Transaction Condition", + free_qty=1, + free_item_rate=10, + condition="customer=='_Test Customer 1'", + ) + + si = create_sales_invoice(qty=5, customer="_Test Customer 1", do_not_submit=True) + self.assertEqual(len(si.items), 2) + self.assertEqual(si.items[1].rate, 10) + + si1 = create_sales_invoice(qty=5, customer="_Test Customer 2", do_not_submit=True) + self.assertEqual(len(si1.items), 1) + + for doc in [si, si1]: + doc.delete() + def test_remove_pricing_rule(self): item = make_item("Water Flask") make_item_price("Water Flask", "_Test Price List", 100) From 091c5496b20864577d133b0804e957ff8995606f Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 30 May 2024 11:47:44 +0530 Subject: [PATCH 19/74] refactor: enabling partial TDS application on partial invoice --- .../doctype/purchase_invoice/purchase_invoice.js | 2 +- .../doctype/purchase_invoice/purchase_invoice.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index 51b2e29f5e7..e4fe9e9a616 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -681,7 +681,7 @@ frappe.ui.form.on("Purchase Invoice", { if (frm.doc.supplier) { frm.doc.apply_tds = frm.doc.__onload.supplier_tds ? 1 : 0; } - if (!frm.doc.__onload.supplier_tds) { + if (!frm.doc.__onload.enable_apply_tds) { frm.set_df_property("apply_tds", "read_only", 1); } } diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index cebd730e489..91f525c6a52 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -348,6 +348,22 @@ class PurchaseInvoice(BuyingController): self.tax_withholding_category = tds_category self.set_onload("supplier_tds", tds_category) + # If Linked Purchase Order has TDS applied, enable 'apply_tds' checkbox + if purchase_orders := [x.purchase_order for x in self.items if x.purchase_order]: + po = qb.DocType("Purchase Order") + po_with_tds = ( + qb.from_(po) + .select(po.name) + .where( + po.docstatus.eq(1) + & (po.name.isin(purchase_orders)) + & (po.apply_tds.eq(1)) + & (po.tax_withholding_category.notnull()) + ) + .run() + ) + self.set_onload("enable_apply_tds", True if po_with_tds else False) + super().set_missing_values(for_validate) def validate_credit_to_acc(self): From be9f96070592d7ec2aef3d3b290745fb9ab2fc82 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Thu, 30 May 2024 14:05:20 +0530 Subject: [PATCH 20/74] fix: show material to supplier button (#41686) --- .../doctype/subcontracting_order/subcontracting_order.js | 8 +++++--- .../doctype/subcontracting_order/subcontracting_order.py | 6 ++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js index 9d788f0809d..4ed73805314 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js @@ -235,9 +235,11 @@ erpnext.buying.SubcontractingOrderController = class SubcontractingOrderControll } has_unsupplied_items() { - return this.frm.doc["supplied_items"].some( - (item) => item.required_qty > item.supplied_qty - item.returned_qty - ); + let over_transfer_allowance = this.frm.doc.__onload.over_transfer_allowance; + return this.frm.doc["supplied_items"].some((item) => { + let required_qty = item.required_qty + (item.required_qty * over_transfer_allowance) / 100; + return required_qty > item.supplied_qty - item.returned_qty; + }); } make_subcontracting_receipt() { diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py index 4d3441a6da2..e36a4be75c2 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py @@ -99,6 +99,12 @@ class SubcontractingOrder(SubcontractingController): } ] + def onload(self): + self.set_onload( + "over_transfer_allowance", + frappe.db.get_single_value("Buying Settings", "over_transfer_allowance"), + ) + def before_validate(self): super().before_validate() From 968120d0eba08b0bf8fd807be2bcb93d64817c8f Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Thu, 30 May 2024 18:06:29 +0530 Subject: [PATCH 21/74] fix: batch selection issue (#41692) --- erpnext/controllers/queries.py | 2 ++ erpnext/public/js/controllers/transaction.js | 3 +++ erpnext/stock/doctype/stock_entry/stock_entry.js | 4 +++- .../doctype/stock_settings/stock_settings.js | 16 ++++++++++++++++ 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index 47646295b2e..6154b6e7a93 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -427,6 +427,7 @@ def get_batches_from_stock_ledger_entries(searchfields, txt, filters, start=0, p & (stock_ledger_entry.batch_no.isnotnull()) ) .groupby(stock_ledger_entry.batch_no, stock_ledger_entry.warehouse) + .having(Sum(stock_ledger_entry.actual_qty) > 0) .offset(start) .limit(page_len) ) @@ -477,6 +478,7 @@ def get_batches_from_serial_and_batch_bundle(searchfields, txt, filters, start=0 & (stock_ledger_entry.serial_and_batch_bundle.isnotnull()) ) .groupby(bundle.batch_no, bundle.warehouse) + .having(Sum(bundle.qty) > 0) .offset(start) .limit(page_len) ) diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index c4eb38342a8..9de2631af62 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -2308,6 +2308,9 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe if (doc.is_return) { filters["is_return"] = 1; + if (["Sales Invoice", "Delivery Note"].includes(doc.doctype)) { + filters["is_inward"] = 1; + } } if (item.warehouse) filters["warehouse"] = item.warehouse; diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index 4e00de0d7ce..c54876713c3 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -111,7 +111,9 @@ frappe.ui.form.on("Stock Entry", { // or a pre-existing batch if (frm.doc.purpose != "Material Receipt") { filters["warehouse"] = item.s_warehouse || item.t_warehouse; - } else { + } + + if (!item.s_warehouse && item.t_warehouse) { filters["is_inward"] = 1; } diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.js b/erpnext/stock/doctype/stock_settings/stock_settings.js index 1972b193732..0443f3f1ece 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.js +++ b/erpnext/stock/doctype/stock_settings/stock_settings.js @@ -14,6 +14,22 @@ frappe.ui.form.on("Stock Settings", { frm.set_query("default_warehouse", filters); frm.set_query("sample_retention_warehouse", filters); }, + + use_serial_batch_fields(frm) { + if (frm.doc.use_serial_batch_fields && !frm.doc.disable_serial_no_and_batch_selector) { + frm.set_value("disable_serial_no_and_batch_selector", 1); + } + }, + + disable_serial_no_and_batch_selector(frm) { + if (!frm.doc.disable_serial_no_and_batch_selector && frm.doc.use_serial_batch_fields) { + frm.set_value("disable_serial_no_and_batch_selector", 1); + frappe.msgprint( + __("Serial No and Batch Selector cannot be use when Use Serial / Batch Fields is enabled.") + ); + } + }, + allow_negative_stock: function (frm) { if (!frm.doc.allow_negative_stock) { return; From 4e37ed9033f0374729593b9286b47cbee20e319b Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Fri, 31 May 2024 11:55:51 +0530 Subject: [PATCH 22/74] fix: key error for stock ledger report (#41700) --- erpnext/stock/report/stock_ledger/stock_ledger.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index 1a50e3dea89..2920ebf69e8 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -67,7 +67,12 @@ def execute(filters=None): if filters.get("batch_no") or inventory_dimension_filters_applied: actual_qty += flt(sle.actual_qty, precision) stock_value += sle.stock_value_difference - batch_balance_dict[sle.batch_no] += sle.actual_qty + if sle.batch_no: + if not batch_balance_dict.get(sle.batch_no): + batch_balance_dict[sle.batch_no] = 0 + + batch_balance_dict[sle.batch_no] += sle.actual_qty + if filters.get("segregate_serial_batch_bundle"): actual_qty = batch_balance_dict[sle.batch_no] From 05d17d0d7347fcb28e616ed297dfef41206d0960 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 31 May 2024 16:53:48 +0530 Subject: [PATCH 23/74] refactor: is_opening in payment entry --- .../doctype/payment_entry/payment_entry.json | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.json b/erpnext/accounts/doctype/payment_entry/payment_entry.json index f911352c884..4355b45c3e4 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.json +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.json @@ -89,6 +89,7 @@ "custom_remarks", "remarks", "base_in_words", + "is_opening", "column_break_16", "letter_head", "print_heading", @@ -778,6 +779,15 @@ "label": "Reconcile on Advance Payment Date", "no_copy": 1, "read_only": 1 + }, + { + "default": "No", + "fieldname": "is_opening", + "fieldtype": "Select", + "label": "Is Opening", + "options": "No\nYes", + "print_hide": 1, + "search_index": 1 } ], "index_web_pages_for_search": 1, @@ -791,7 +801,7 @@ "table_fieldname": "payment_entries" } ], - "modified": "2024-05-17 10:21:11.199445", + "modified": "2024-05-31 16:54:59.618516", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Entry", From c36f0e4a3376f28a82ba94d1fb46337b3fd62232 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 31 May 2024 17:11:40 +0530 Subject: [PATCH 24/74] refactor: restrict to 'Advance in Separate Party Account' type --- erpnext/accounts/doctype/payment_entry/payment_entry.json | 3 ++- erpnext/accounts/doctype/payment_entry/payment_entry.py | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.json b/erpnext/accounts/doctype/payment_entry/payment_entry.json index 4355b45c3e4..5983198928f 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.json +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.json @@ -782,6 +782,7 @@ }, { "default": "No", + "depends_on": "eval: doc.book_advance_payments_in_separate_party_account == 1", "fieldname": "is_opening", "fieldtype": "Select", "label": "Is Opening", @@ -801,7 +802,7 @@ "table_fieldname": "payment_entries" } ], - "modified": "2024-05-31 16:54:59.618516", + "modified": "2024-05-31 17:07:06.197249", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Entry", diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 4e05afa6be9..993074565b5 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -199,11 +199,13 @@ class PaymentEntry(AccountsController): self.book_advance_payments_in_separate_party_account = False if self.party_type not in ("Customer", "Supplier"): + self.is_opening = "No" return if not frappe.db.get_value( "Company", self.company, "book_advance_payments_in_separate_party_account" ): + self.is_opening = "No" return # Important to set this flag for the gl building logic to work properly @@ -215,6 +217,7 @@ class PaymentEntry(AccountsController): if (account_type == "Payable" and self.party_type == "Customer") or ( account_type == "Receivable" and self.party_type == "Supplier" ): + self.is_opening = "No" return if self.references: @@ -224,6 +227,7 @@ class PaymentEntry(AccountsController): # If there are referencers other than `allowed_types`, treat this as a normal payment entry if reference_types - allowed_types: self.book_advance_payments_in_separate_party_account = False + self.is_opening = "No" return liability_account = get_party_account( From ee846f59504a7f1751c284971847e9e564231d4d Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Sat, 1 Jun 2024 15:25:51 +0530 Subject: [PATCH 25/74] feat: optional to reconcile all serial nos / batches in stock reconciliation (#41696) feat: optional to reconcile all serial/batch --- .../stock/doctype/pick_list/test_pick_list.py | 1 + .../stock_reconciliation.js | 1 + .../stock_reconciliation.py | 89 ++++++++++++++- .../test_stock_reconciliation.py | 101 ++++++++++++++++++ .../stock_reconciliation_item.json | 10 +- .../stock_reconciliation_item.py | 1 + erpnext/stock/serial_batch_bundle.py | 8 +- 7 files changed, 205 insertions(+), 6 deletions(-) diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 65fe853ec8d..116a0bd833d 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -167,6 +167,7 @@ class TestPickList(FrappeTestCase): "item_code": "_Test Serialized Item", "warehouse": "_Test Warehouse - _TC", "valuation_rate": 100, + "reconcile_all_serial_batch": 1, "qty": 5, "serial_and_batch_bundle": make_serial_batch_bundle( frappe._dict( diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js index 8532b60d59c..31985678009 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js @@ -206,6 +206,7 @@ frappe.ui.form.on("Stock Reconciliation", { posting_date: frm.doc.posting_date, posting_time: frm.doc.posting_time, batch_no: d.batch_no, + row: d, }, callback: function (r) { const row = frappe.model.get_doc(cdt, cdn); diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index f92d7361f41..89e69c153d4 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -3,7 +3,7 @@ import frappe -from frappe import _, bold, msgprint +from frappe import _, bold, json, msgprint from frappe.query_builder.functions import CombineDatetime, Sum from frappe.utils import add_to_date, cint, cstr, flt @@ -162,6 +162,11 @@ class StockReconciliation(StockController): def set_current_serial_and_batch_bundle(self, voucher_detail_no=None, save=False) -> None: """Set Serial and Batch Bundle for each item""" for item in self.items: + if not item.reconcile_all_serial_batch and item.serial_and_batch_bundle: + bundle = self.get_bundle_for_specific_serial_batch(item) + item.current_serial_and_batch_bundle = bundle + continue + if not save and item.use_serial_batch_fields: continue @@ -273,6 +278,75 @@ class StockReconciliation(StockController): } ) + def get_bundle_for_specific_serial_batch(self, row) -> str: + from erpnext.stock.serial_batch_bundle import SerialBatchCreation + + if row.current_serial_and_batch_bundle and not self.has_change_in_serial_batch(row): + return row.current_serial_and_batch_bundle + + cls_obj = SerialBatchCreation( + { + "type_of_transaction": "Outward", + "serial_and_batch_bundle": row.serial_and_batch_bundle, + "item_code": row.get("item_code"), + "warehouse": row.get("warehouse"), + "posting_date": self.posting_date, + "posting_time": self.posting_time, + "do_not_save": True, + } + ) + + reco_obj = cls_obj.duplicate_package() + + total_current_qty = 0.0 + for entry in reco_obj.entries: + if not entry.batch_no or entry.serial_no: + total_current_qty += entry.qty + entry.qty *= -1 + continue + + current_qty = get_batch_qty( + entry.batch_no, + row.warehouse, + row.item_code, + posting_date=self.posting_date, + posting_time=self.posting_time, + ) + + total_current_qty += current_qty + entry.qty = current_qty * -1 + + reco_obj.flags.ignore_validate = True + reco_obj.save() + + row.current_qty = total_current_qty + + return reco_obj.name + + def has_change_in_serial_batch(self, row) -> bool: + bundles = {row.serial_and_batch_bundle: [], row.current_serial_and_batch_bundle: []} + + data = frappe.get_all( + "Serial and Batch Entry", + fields=["serial_no", "batch_no", "parent"], + filters={"parent": ("in", [row.serial_and_batch_bundle, row.current_serial_and_batch_bundle])}, + order_by="idx", + ) + + for d in data: + bundles[d.parent].append(d.serial_no or d.batch_no) + + diff = set(bundles[row.serial_and_batch_bundle]) - set(bundles[row.current_serial_and_batch_bundle]) + + if diff: + bundle = row.current_serial_and_batch_bundle + row.current_serial_and_batch_bundle = None + frappe.delete_doc("Serial and Batch Bundle", bundle) + + return True + + return False + def set_new_serial_and_batch_bundle(self): for item in self.items: if item.use_serial_batch_fields: @@ -340,6 +414,7 @@ class StockReconciliation(StockController): self.posting_time, batch_no=item.batch_no, inventory_dimensions_dict=inventory_dimensions_dict, + row=item, ) if ( @@ -840,6 +915,7 @@ class StockReconciliation(StockController): row.warehouse, self.posting_date, self.posting_time, + row=row, ) current_qty = item_dict.get("qty") @@ -1166,11 +1242,18 @@ def get_stock_balance_for( batch_no: str | None = None, with_valuation_rate: bool = True, inventory_dimensions_dict=None, + row=None, ): frappe.has_permission("Stock Reconciliation", "write", throw=True) item_dict = frappe.get_cached_value("Item", item_code, ["has_serial_no", "has_batch_no"], as_dict=1) + if isinstance(row, str): + row = json.loads(row) + + if isinstance(row, dict): + row = frappe._dict(row) + if not item_dict: # In cases of data upload to Items table msg = _("Item {} does not exist.").format(item_code) @@ -1188,7 +1271,7 @@ def get_stock_balance_for( "qty": 0, "rate": 0, "serial_nos": None, - "use_serial_batch_fields": use_serial_batch_fields, + "use_serial_batch_fields": row.use_serial_batch_fields if row else use_serial_batch_fields, } # TODO: fetch only selected batch's values @@ -1214,7 +1297,7 @@ def get_stock_balance_for( "qty": qty, "rate": rate, "serial_nos": serial_nos, - "use_serial_batch_fields": use_serial_batch_fields, + "use_serial_batch_fields": row.use_serial_batch_fields if row else use_serial_batch_fields, } diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 92a931036e9..4397616e30c 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -1070,6 +1070,103 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin): self.assertTrue(sr.items[0].serial_and_batch_bundle) self.assertFalse(sr.items[0].current_serial_and_batch_bundle) + def test_not_reconcile_all_batch(self): + from erpnext.stock.doctype.batch.batch import get_batch_qty + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item = self.make_item( + "Test Batch Item Not Reconcile All Serial Batch", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TEST-BATCH-NRALL-SRCOSRWFEE-.###", + }, + ) + + warehouse = "_Test Warehouse - _TC" + + batches = [] + for qty in [10, 20, 30]: + se = make_stock_entry( + item_code=item.name, + target=warehouse, + qty=qty, + basic_rate=100 + qty, + posting_date=nowdate(), + ) + + batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle) + batches.append(frappe._dict({"batch_no": batch_no, "qty": qty})) + + sr = create_stock_reconciliation( + item_code=item.name, + warehouse=warehouse, + qty=100, + rate=1000, + reconcile_all_serial_batch=0, + batch_no=batches[0].batch_no, + ) + + sr.reload() + current_sabb = sr.items[0].current_serial_and_batch_bundle + doc = frappe.get_doc("Serial and Batch Bundle", current_sabb) + for row in doc.entries: + self.assertEqual(row.batch_no, batches[0].batch_no) + self.assertEqual(row.qty, batches[0].qty * -1) + + batch_qty = get_batch_qty(batches[0].batch_no, warehouse, item.name) + self.assertEqual(batch_qty, 100) + + def test_not_reconcile_all_serial_nos(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + from erpnext.stock.utils import get_incoming_rate + + item = self.make_item( + "Test Serial NO Item Not Reconcile All Serial Batch", + { + "is_stock_item": 1, + "has_serial_no": 1, + "serial_no_series": "SNN-TEST-BATCH-NRALL-S-.###", + }, + ) + + warehouse = "_Test Warehouse - _TC" + + serial_nos = [] + for qty in [5, 5, 5]: + se = make_stock_entry( + item_code=item.name, + target=warehouse, + qty=qty, + basic_rate=100 + qty, + posting_date=nowdate(), + ) + + serial_nos.extend(get_serial_nos_from_bundle(se.items[0].serial_and_batch_bundle)) + + sr = create_stock_reconciliation( + item_code=item.name, + warehouse=warehouse, + qty=5, + rate=1000, + reconcile_all_serial_batch=0, + serial_no=serial_nos[0:5], + ) + + sr.reload() + current_sabb = sr.items[0].current_serial_and_batch_bundle + doc = frappe.get_doc("Serial and Batch Bundle", current_sabb) + for row in doc.entries: + self.assertEqual(row.serial_no, serial_nos[row.idx - 1]) + + sabb = sr.items[0].serial_and_batch_bundle + doc = frappe.get_doc("Serial and Batch Bundle", sabb) + for row in doc.entries: + self.assertEqual(row.qty, 1) + self.assertAlmostEqual(row.incoming_rate, 1000.00) + self.assertEqual(row.serial_no, serial_nos[row.idx - 1]) + def create_batch_item_with_batch(item_name, batch_id): batch_item_doc = create_item(item_name, is_stock_item=1) @@ -1193,12 +1290,16 @@ def create_stock_reconciliation(**args): ) ).name + if args.reconcile_all_serial_batch is None: + args.reconcile_all_serial_batch = 1 + sr.append( "items", { "item_code": args.item_code or "_Test Item", "warehouse": args.warehouse or "_Test Warehouse - _TC", "qty": args.qty, + "reconcile_all_serial_batch": args.reconcile_all_serial_batch, "valuation_rate": args.rate, "serial_no": args.serial_no if args.use_serial_batch_fields else None, "batch_no": args.batch_no if args.use_serial_batch_fields else None, diff --git a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json index 7cd14d5657e..8c01eedf76d 100644 --- a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json +++ b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json @@ -20,6 +20,7 @@ "serial_no_and_batch_section", "add_serial_batch_bundle", "use_serial_batch_fields", + "reconcile_all_serial_batch", "column_break_11", "serial_and_batch_bundle", "current_serial_and_batch_bundle", @@ -243,11 +244,18 @@ { "fieldname": "column_break_eefq", "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "eval:!doc.use_serial_batch_fields", + "fieldname": "reconcile_all_serial_batch", + "fieldtype": "Check", + "label": "Reconcile All Serial Nos / Batches" } ], "istable": 1, "links": [], - "modified": "2024-03-27 13:10:44.893356", + "modified": "2024-05-30 23:20:00.947243", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reconciliation Item", diff --git a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.py b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.py index 1938fec32b0..f2a9aeba8f4 100644 --- a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.py +++ b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.py @@ -33,6 +33,7 @@ class StockReconciliationItem(Document): parenttype: DF.Data qty: DF.Float quantity_difference: DF.ReadOnly | None + reconcile_all_serial_batch: DF.Check serial_and_batch_bundle: DF.Link | None serial_no: DF.LongText | None use_serial_batch_fields: DF.Check diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index fcebf0491ac..1fa5665c141 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -848,10 +848,14 @@ class SerialBatchCreation: new_package.docstatus = 0 new_package.warehouse = self.warehouse new_package.voucher_no = "" - new_package.posting_date = today() - new_package.posting_time = nowtime() + new_package.posting_date = self.posting_date if hasattr(self, "posting_date") else today() + new_package.posting_time = self.posting_time if hasattr(self, "posting_time") else nowtime() new_package.type_of_transaction = self.type_of_transaction new_package.returned_against = self.get("returned_against") + + if self.get("do_not_save"): + return new_package + new_package.save() self.serial_and_batch_bundle = new_package.name From 169d77da54edf9f12f0c4873a032f1f131b37621 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Sat, 1 Jun 2024 19:31:28 +0530 Subject: [PATCH 26/74] fix: Transaction currency value in Journal Entry --- .../accounts/doctype/journal_entry/journal_entry.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index e757f13108f..6a3254197cc 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -1058,6 +1058,17 @@ class JournalEntry(AccountsController): def build_gl_map(self): gl_map = [] + + company_currency = erpnext.get_company_currency(self.company) + if self.multi_currency: + for row in self.get("accounts"): + if row.account_currency != company_currency: + self.currency = row.account_currency + self.conversion_rate = row.exchange_rate + break + else: + self.currency = company_currency + for d in self.get("accounts"): if d.debit or d.credit or (self.voucher_type == "Exchange Gain Or Loss"): r = [d.user_remark, self.remark] From 9e9296e4445471f94a9a2ab413401bcbfd37ce55 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Sun, 2 Jun 2024 01:28:58 +0530 Subject: [PATCH 27/74] feat: track Semi-finished goods (including subcontracted items) against Job Cards (#38341) * feat: Track Semi-finished goods (including subcontracted items) against Job Cards * feat: option to add raw materials manually against operation --- .../doctype/purchase_order/purchase_order.py | 10 +- .../purchase_order_item.json | 17 +- erpnext/manufacturing/doctype/bom/bom.js | 182 ++++++- erpnext/manufacturing/doctype/bom/bom.json | 56 ++- erpnext/manufacturing/doctype/bom/bom.py | 74 ++- .../doctype/bom_creator/bom_creator.js | 90 ++++ .../doctype/bom_creator/bom_creator.json | 117 ++++- .../doctype/bom_creator/bom_creator.py | 171 ++++++- .../bom_creator_item/bom_creator_item.json | 96 +++- .../bom_creator_item/bom_creator_item.py | 9 + .../doctype/bom_item/bom_item.json | 9 +- .../doctype/bom_item/bom_item.py | 2 + .../doctype/bom_operation/bom_operation.json | 120 ++++- .../doctype/bom_operation/bom_operation.py | 10 + .../doctype/job_card/job_card.js | 338 ++++++++----- .../doctype/job_card/job_card.json | 192 ++++++-- .../doctype/job_card/job_card.py | 431 ++++++++++++---- .../doctype/job_card/job_card_dashboard.py | 1 + .../job_card_time_log/job_card_time_log.json | 6 +- .../doctype/operation/operation.json | 4 +- .../doctype/work_order/work_order.js | 55 ++- .../doctype/work_order/work_order.json | 54 +-- .../doctype/work_order/work_order.py | 80 ++- .../work_order_item/work_order_item.json | 9 +- .../work_order_operation.json | 85 +++- .../work_order_operation.py | 8 + .../doctype/workstation/workstation.js | 458 ++++++++++++++---- .../doctype/workstation/workstation.json | 8 +- .../doctype/workstation/workstation.py | 161 ++++-- .../workstation/workstation_job_card.html | 151 +++--- erpnext/patches.txt | 1 + .../patches/v15_0/add_default_operations.py | 5 + .../bom_configurator.bundle.js | 363 ++++++++++++-- .../visual_plant_floor_template.html | 10 +- erpnext/public/scss/erpnext.scss | 10 +- erpnext/setup/install.py | 9 + .../doctype/stock_entry/stock_entry.json | 2 +- .../stock/doctype/stock_entry/stock_entry.py | 44 +- .../stock_entry_type/stock_entry_type.py | 120 ++++- .../subcontracting_bom/subcontracting_bom.py | 2 +- .../subcontracting_order_item.json | 21 +- .../subcontracting_receipt.py | 20 + .../subcontracting_receipt_item.json | 11 +- 43 files changed, 2937 insertions(+), 685 deletions(-) create mode 100644 erpnext/patches/v15_0/add_default_operations.py diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 13f1f3b8757..1d87af60bab 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -369,7 +369,7 @@ class PurchaseOrder(BuyingController): item.idx, item.fg_item ) ) - elif not frappe.get_value("Item", item.fg_item, "default_bom"): + elif not item.bom and not frappe.get_value("Item", item.fg_item, "default_bom"): frappe.throw( _("Row #{0}: Default BOM not found for FG Item {1}").format( item.idx, item.fg_item @@ -919,6 +919,14 @@ def get_mapped_subcontracting_order(source_name, target_doc=None): for idx, item in enumerate(target_doc.items): item.warehouse = source_doc.items[idx].warehouse + for idx, item in enumerate(target_doc.items): + item.job_card = source_doc.items[idx].job_card + if not target_doc.supplier_warehouse: + # WIP warehouse is set as Supplier Warehouse in Job Card + target_doc.supplier_warehouse = frappe.get_cached_value( + "Job Card", item.job_card, "wip_warehouse" + ) + return target_doc diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index bce7ed15b12..5f4c9f0fd43 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -110,7 +110,9 @@ "production_plan", "production_plan_item", "production_plan_sub_assembly_item", - "page_break" + "page_break", + "column_break_pjyo", + "job_card" ], "fields": [ { @@ -909,13 +911,24 @@ { "fieldname": "column_break_fyqr", "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_pjyo", + "fieldtype": "Column Break" + }, + { + "fieldname": "job_card", + "fieldtype": "Link", + "label": "Job Card", + "options": "Job Card", + "search_index": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:10:24.979325", + "modified": "2024-03-27 13:12:24.979325", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index 6267ee4d029..f2692d21bff 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -19,6 +19,21 @@ frappe.ui.form.on("BOM", { }; }); + frm.set_query("bom_no", "operations", function (doc, cdt, cdn) { + let row = locals[cdt][cdn]; + return { + query: "erpnext.controllers.queries.bom", + filters: { + currency: frm.doc.currency, + company: frm.doc.company, + item: row.finished_good, + is_active: 1, + docstatus: 1, + track_semi_finished_goods: 0, + }, + }; + }); + frm.set_query("source_warehouse", "items", function () { return { filters: { @@ -85,6 +100,27 @@ frappe.ui.form.on("BOM", { frm.get_field("items").grid.set_multiple_add("item_code", "qty"); }, + default_source_warehouse(frm) { + if (frm.doc.default_source_warehouse) { + frm.doc.operations.forEach((d) => { + frappe.model.set_value( + d.doctype, + d.name, + "source_warehouse", + frm.doc.default_source_warehouse + ); + }); + } + }, + + default_target_warehouse(frm) { + if (frm.doc.default_source_warehouse) { + frm.doc.operations.forEach((d) => { + frappe.model.set_value(d.doctype, d.name, "fg_warehouse", frm.doc.default_target_warehouse); + }); + } + }, + refresh(frm) { frm.toggle_enable("item", frm.doc.__islocal); @@ -96,22 +132,35 @@ frappe.ui.form.on("BOM", { }); if (!frm.is_new() && frm.doc.docstatus < 2) { - frm.add_custom_button(__("Update Cost"), function () { - frm.events.update_cost(frm, true); - }); - frm.add_custom_button(__("Browse BOM"), function () { - frappe.route_options = { - bom: frm.doc.name, - }; - frappe.set_route("Tree", "BOM"); - }); + frm.add_custom_button( + __("Update Cost"), + function () { + frm.events.update_cost(frm, true); + }, + __("Actions") + ); + + frm.add_custom_button( + __("Browse BOM"), + function () { + frappe.route_options = { + bom: frm.doc.name, + }; + frappe.set_route("Tree", "BOM"); + }, + __("Actions") + ); } if (!frm.is_new() && !frm.doc.docstatus == 0) { - frm.add_custom_button(__("New Version"), function () { - let new_bom = frappe.model.copy_doc(frm.doc); - frappe.set_route("Form", "BOM", new_bom.name); - }); + frm.add_custom_button( + __("New Version"), + function () { + let new_bom = frappe.model.copy_doc(frm.doc); + frappe.set_route("Form", "BOM", new_bom.name); + }, + __("Actions") + ); } if (frm.doc.docstatus == 1) { @@ -432,6 +481,28 @@ frappe.ui.form.on("BOM", { }, }); +frappe.ui.form.on("BOM Operation", { + bom_no(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + + if (row.bom_no && row.finished_good) { + frappe.call({ + method: "add_materials_from_bom", + doc: frm.doc, + args: { + finished_good: row.finished_good, + bom_no: row.bom_no, + operation_row_id: row.idx, + qty: row.finished_good_qty, + }, + callback(r) { + refresh_field("items"); + }, + }); + } + }, +}); + erpnext.bom.BomController = class BomController extends erpnext.TransactionController { conversion_rate(doc) { if (this.frm.doc.currency === this.get_company_currency()) { @@ -801,3 +872,88 @@ function trigger_process_loss_qty_prompt(frm, cdt, cdn, item_code) { __("Set Quantity") ); } + +frappe.ui.form.on("BOM Operation", { + add_raw_materials(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + frm.events._prompt_for_raw_materials(frm, row); + }, +}); + +frappe.ui.form.on("BOM", { + _prompt_for_raw_materials(frm, row) { + let fields = frm.events.get_fields_for_prompt(frm, row); + frm._bom_rm_dialog = new frappe.ui.Dialog({ + title: __("Add Raw Materials"), + fields: fields, + primary_action_label: __("Add"), + primary_action: () => { + let values = frm._bom_rm_dialog.get_values(); + if (values) { + frm.events._add_raw_materials(frm, values); + frm._bom_rm_dialog.hide(); + } + }, + }); + + frm._bom_rm_dialog.show(); + }, + + get_fields_for_prompt(frm, row) { + return [ + { + label: __("Raw Materials"), + fieldname: "items", + fieldtype: "Table", + reqd: 1, + fields: [ + { + label: __("Item"), + fieldname: "item_code", + fieldtype: "Link", + options: "Item", + reqd: 1, + in_list_view: 1, + change() { + let doc = this.doc; + doc.qty = 1.0; + this.grid.set_value("qty", 1.0, doc); + }, + get_query() { + return { + filters: { + name: ["!=", row.finished_good], + }, + }; + }, + }, + { + label: __("Qty"), + fieldname: "qty", + default: 1.0, + fieldtype: "Float", + reqd: 1, + in_list_view: 1, + }, + ], + }, + { + fieldname: "operation_row_id", + fieldtype: "Data", + hidden: 1, + default: row.idx, + }, + ]; + }, + + _add_raw_materials(frm, values) { + frm.call({ + method: "add_raw_materials", + doc: frm.doc, + args: { + operation_row_id: values.operation_row_id, + items: values.items, + }, + }); + }, +}); diff --git a/erpnext/manufacturing/doctype/bom/bom.json b/erpnext/manufacturing/doctype/bom/bom.json index 67de6a0632b..dcc7a4f2176 100644 --- a/erpnext/manufacturing/doctype/bom/bom.json +++ b/erpnext/manufacturing/doctype/bom/bom.json @@ -26,19 +26,22 @@ "column_break_ivyw", "currency", "conversion_rate", - "materials_section", - "items", - "section_break_21", "operations_section_section", "with_operations", + "track_semi_finished_goods", "column_break_23", "transfer_material_against", "routing", "fg_based_operating_cost", + "column_break_joxb", + "default_source_warehouse", + "default_target_warehouse", "fg_based_section_section", "operating_cost_per_bom_quantity", "operations_section", "operations", + "materials_section", + "items", "scrap_section", "scrap_items_section", "scrap_items", @@ -59,8 +62,8 @@ "base_total_cost", "more_info_tab", "item_name", - "description", "column_break_27", + "description", "has_variants", "quality_inspection_section_break", "inspection_required", @@ -211,7 +214,7 @@ }, { "default": "Work Order", - "depends_on": "with_operations", + "depends_on": "eval: doc.with_operations === 1 && doc.track_semi_finished_goods === 0", "fieldname": "transfer_material_against", "fieldtype": "Select", "label": "Transfer Material Against", @@ -406,8 +409,8 @@ { "depends_on": "eval:!doc.__islocal", "fieldname": "section_break0", - "fieldtype": "Section Break", - "label": "Materials Required (Exploded)" + "fieldtype": "Tab Break", + "label": "Exploded Items" }, { "fieldname": "exploded_items", @@ -485,11 +488,6 @@ "fieldtype": "Check", "label": "Show Operations" }, - { - "fieldname": "section_break_21", - "fieldtype": "Tab Break", - "label": "Operations" - }, { "fieldname": "column_break_23", "fieldtype": "Column Break" @@ -534,6 +532,8 @@ "show_dashboard": 1 }, { + "collapsible": 1, + "collapsible_depends_on": "eval:doc.with_operations", "fieldname": "operations_section_section", "fieldtype": "Section Break", "label": "Operations" @@ -617,7 +617,8 @@ "no_copy": 1, "options": "BOM Creator", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "search_index": 1 }, { "fieldname": "bom_creator_item", @@ -625,11 +626,36 @@ "label": "BOM Creator Item", "no_copy": 1, "print_hide": 1, - "read_only": 1 + "read_only": 1, + "search_index": 1 }, { "fieldname": "column_break_oxbz", "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "with_operations", + "description": "Users can consume raw materials and add semi-finished goods or final finished goods against the operation using job cards.", + "fieldname": "track_semi_finished_goods", + "fieldtype": "Check", + "label": "Track Semi Finished Goods" + }, + { + "fieldname": "column_break_joxb", + "fieldtype": "Column Break" + }, + { + "fieldname": "default_source_warehouse", + "fieldtype": "Link", + "label": "Default Source Warehouse", + "options": "Warehouse" + }, + { + "fieldname": "default_target_warehouse", + "fieldtype": "Link", + "label": "Default Target Warehouse", + "options": "Warehouse" } ], "icon": "fa fa-sitemap", @@ -637,7 +663,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2024-04-02 16:22:47.518411", + "modified": "2024-04-02 16:24:47.518411", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM", diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 40b4c4f7455..5ff531e797d 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -10,7 +10,7 @@ import frappe from frappe import _ from frappe.core.doctype.version.version import get_diff from frappe.model.mapper import get_mapped_doc -from frappe.utils import cint, cstr, flt, today +from frappe.utils import cint, cstr, flt, parse_json, today from frappe.website.website_generator import WebsiteGenerator import erpnext @@ -125,6 +125,8 @@ class BOM(WebsiteGenerator): company: DF.Link conversion_rate: DF.Float currency: DF.Link + default_source_warehouse: DF.Link | None + default_target_warehouse: DF.Link | None description: DF.SmallText | None exploded_items: DF.Table[BOMExplosionItem] fg_based_operating_cost: DF.Check @@ -136,6 +138,7 @@ class BOM(WebsiteGenerator): item: DF.Link item_name: DF.Data | None items: DF.Table[BOMItem] + track_semi_finished_goods: DF.Check operating_cost: DF.Currency operating_cost_per_bom_quantity: DF.Currency operations: DF.Table[BOMOperation] @@ -245,6 +248,7 @@ class BOM(WebsiteGenerator): self.clear_inspection() self.validate_main_item() self.validate_currency() + self.set_materials_based_on_operation_bom() self.set_conversion_rate() self.set_plc_conversion_rate() self.validate_uom_is_interger() @@ -544,6 +548,9 @@ class BOM(WebsiteGenerator): if not self.with_operations: self.set("operations", []) + if not self.with_operations and self.track_semi_finished_goods: + self.track_semi_finished_goods = 0 + def clear_inspection(self): if not self.inspection_required: self.quality_inspection_template = None @@ -645,6 +652,49 @@ class BOM(WebsiteGenerator): if self.name in {d.bom_no for d in self.items}: _throw_error(self.name) + def set_materials_based_on_operation_bom(self): + if not self.track_semi_finished_goods: + return + + for row in self.get("operations"): + if row.bom_no and row.finished_good: + self.add_materials_from_bom(row.finished_good, row.bom_no, row.idx, qty=row.finished_good_qty) + + @frappe.whitelist() + def add_raw_materials(self, operation_row_id, items): + if isinstance(items, str): + items = parse_json(items) + + for row in items: + row = parse_json(row) + + row.update(get_item_details(row.get("item_code"))) + row.operation_row_id = operation_row_id + row.idx = None + row.name = None + self.append("items", row) + + self.save() + + @frappe.whitelist() + def add_materials_from_bom(self, finished_good, bom_no, operation_row_id, qty=None): + if not frappe.db.exists("BOM", {"item": finished_good, "name": bom_no, "docstatus": 1}): + frappe.throw(_("BOM {0} not found for the item {1}").format(bom_no, finished_good)) + + if not qty: + qty = 1 + + for row in self.items: + if row.operation_row_id == operation_row_id: + return + + bom_items = get_bom_items(bom_no, self.company, qty=qty, fetch_exploded=0) + for row in bom_items: + row.uom = row.stock_uom + row.operation_row_id = operation_row_id + row.idx = None + self.append("items", row) + def traverse_tree(self, bom_list=None): def _get_children(bom_no): children = frappe.cache().hget("bom_children", bom_no) @@ -1094,6 +1144,11 @@ def get_bom_items_as_dict( ): item_dict = {} + group_by_cond = "group by item_code, stock_uom" + if frappe.get_cached_value("BOM", bom, "track_semi_finished_goods"): + fetch_exploded = 0 + group_by_cond = "group by item_code, operation_row_id, stock_uom" + # Did not use qty_consumed_per_unit in the query, as it leads to rounding loss query = """select bom_item.item_code, @@ -1122,7 +1177,7 @@ def get_bom_items_as_dict( and bom.name = %(bom)s and item.is_stock_item in (1, {is_stock_item}) {where_conditions} - group by item_code, stock_uom + {group_by_cond} order by idx""" is_stock_item = 0 if include_non_stock_items else 1 @@ -1132,6 +1187,7 @@ def get_bom_items_as_dict( where_conditions="", is_stock_item=is_stock_item, qty_field="stock_qty", + group_by_cond=group_by_cond, select_columns=""", bom_item.source_warehouse, bom_item.operation, bom_item.include_item_in_manufacturing, bom_item.description, bom_item.rate, bom_item.sourced_by_supplier, (Select idx from `tabBOM Item` where item_code = bom_item.item_code and parent = %(parent)s limit 1) as idx""", @@ -1147,6 +1203,7 @@ def get_bom_items_as_dict( select_columns=", item.description", is_stock_item=is_stock_item, qty_field="stock_qty", + group_by_cond=group_by_cond, ) items = frappe.db.sql(query, {"qty": qty, "bom": bom, "company": company}, as_dict=True) @@ -1158,15 +1215,20 @@ def get_bom_items_as_dict( qty_field="stock_qty" if fetch_qty_in_stock_uom else "qty", select_columns=""", bom_item.uom, bom_item.conversion_factor, bom_item.source_warehouse, bom_item.operation, bom_item.include_item_in_manufacturing, bom_item.sourced_by_supplier, - bom_item.description, bom_item.base_rate as rate """, + bom_item.description, bom_item.base_rate as rate, bom_item.operation_row_id """, + group_by_cond=group_by_cond, ) items = frappe.db.sql(query, {"qty": qty, "bom": bom, "company": company}, as_dict=True) for item in items: - if item.item_code in item_dict: - item_dict[item.item_code]["qty"] += flt(item.qty) + key = item.item_code + if item.operation_row_id: + key = (item.item_code, item.operation_row_id) + + if key in item_dict: + item_dict[key]["qty"] += flt(item.qty) else: - item_dict[item.item_code] = item + item_dict[key] = item for item, item_details in item_dict.items(): for d in [ diff --git a/erpnext/manufacturing/doctype/bom_creator/bom_creator.js b/erpnext/manufacturing/doctype/bom_creator/bom_creator.js index 32231aa4949..b44c9f53f23 100644 --- a/erpnext/manufacturing/doctype/bom_creator/bom_creator.js +++ b/erpnext/manufacturing/doctype/bom_creator/bom_creator.js @@ -88,9 +88,77 @@ frappe.ui.form.on("BOM Creator", { reqd: 1, default: 1.0, }, + { fieldtype: "Section Break" }, + { + label: __("Track Operations"), + fieldtype: "Check", + fieldname: "track_operations", + onchange: (r) => { + let track_operations = dialog.get_value("track_operations"); + if (r.type === "input" && !track_operations) { + dialog.set_value("track_semi_finished_goods", 0); + } + }, + }, + { fieldtype: "Column Break" }, + { + label: __("Track Semi Finished Goods"), + fieldtype: "Check", + fieldname: "track_semi_finished_goods", + depends_on: "eval:doc.track_operations", + }, + { + fieldtype: "Section Break", + label: __("Final Product Operation"), + depends_on: "eval:doc.track_semi_finished_goods", + }, + { + label: __("Operation"), + fieldtype: "Link", + fieldname: "operation", + options: "Operation", + default: "Assembly", + mandatory_depends_on: "eval:doc.track_semi_finished_goods", + depends_on: "eval:doc.track_semi_finished_goods", + }, + { + label: __("Operation Time (in mins)"), + fieldtype: "Float", + fieldname: "operation_time", + mandatory_depends_on: "eval:doc.track_semi_finished_goods", + depends_on: "eval:doc.track_semi_finished_goods", + }, + { fieldtype: "Column Break" }, + { + label: __("Workstation Type"), + fieldtype: "Link", + fieldname: "workstation_type", + options: "Workstation", + depends_on: "eval:doc.track_semi_finished_goods", + }, + { + label: __("Workstation"), + fieldtype: "Link", + fieldname: "workstation", + options: "Workstation", + depends_on: "eval:doc.track_semi_finished_goods", + get_query() { + let workstation_type = dialog.get_value("workstation_type"); + + if (workstation_type) { + return { + filters: { + workstation_type: dialog.get_value("workstation_type"), + }, + }; + } + }, + }, ], primary_action_label: __("Create"), primary_action: (values) => { + frm.events.validate_dialog_values(frm, values); + values.doctype = frm.doc.doctype; frappe.db.insert(values).then((doc) => { frappe.set_route("Form", doc.doctype, doc.name); @@ -102,6 +170,18 @@ frappe.ui.form.on("BOM Creator", { dialog.show(); }, + validate_dialog_values(frm, values) { + if (values.track_semi_finished_goods) { + if (values.final_operation_time <= 0) { + frappe.throw(__("Operation Time must be greater than 0")); + } + + if (!values.workstation && !values.workstation_type) { + frappe.throw(__("Either Workstation or Workstation Type is mandatory")); + } + } + }, + set_queries(frm) { frm.set_query("bom_no", "items", function (doc, cdt, cdn) { let item = frappe.get_doc(cdt, cdn); @@ -121,6 +201,16 @@ frappe.ui.form.on("BOM Creator", { query: "erpnext.controllers.queries.item_query", }; }); + + frm.set_query("workstation", (doc) => { + if (doc.workstation_type) { + return { + filters: { + workstation_type: doc.workstation_type, + }, + }; + } + }); }, refresh(frm) { diff --git a/erpnext/manufacturing/doctype/bom_creator/bom_creator.json b/erpnext/manufacturing/doctype/bom_creator/bom_creator.json index 1e8237c03f7..2b4b9a055aa 100644 --- a/erpnext/manufacturing/doctype/bom_creator/bom_creator.json +++ b/erpnext/manufacturing/doctype/bom_creator/bom_creator.json @@ -37,6 +37,23 @@ "items", "costing_detail", "raw_material_cost", + "configuration_section", + "track_operations", + "column_break_obzr", + "track_semi_finished_goods", + "final_product_operation_section", + "operation", + "operation_time", + "column_break_xnlu", + "workstation_type", + "workstation", + "final_product_warehouse_section", + "skip_material_transfer", + "backflush_from_wip_warehouse", + "source_warehouse", + "column_break_buha", + "wip_warehouse", + "fg_warehouse", "remarks_tab", "remarks", "section_break_yixm", @@ -278,6 +295,104 @@ "fieldtype": "Text", "label": "Error Log", "read_only": 1 + }, + { + "fieldname": "configuration_section", + "fieldtype": "Section Break", + "label": "Operation" + }, + { + "default": "0", + "depends_on": "track_operations", + "fieldname": "track_semi_finished_goods", + "fieldtype": "Check", + "label": "Track Semi Finished Goods" + }, + { + "fieldname": "column_break_obzr", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "track_operations", + "fieldtype": "Check", + "label": "Track Operations" + }, + { + "depends_on": "eval:doc.track_semi_finished_goods === 1", + "fieldname": "final_product_operation_section", + "fieldtype": "Section Break", + "label": "Final Product Operation & Workstation" + }, + { + "fieldname": "column_break_xnlu", + "fieldtype": "Column Break" + }, + { + "fieldname": "operation", + "fieldtype": "Link", + "label": "Operation", + "options": "Operation" + }, + { + "fieldname": "operation_time", + "fieldtype": "Float", + "label": "Operation Time (in mins)" + }, + { + "fieldname": "workstation", + "fieldtype": "Link", + "label": "Workstation", + "options": "Workstation" + }, + { + "fieldname": "workstation_type", + "fieldtype": "Link", + "label": "Workstation Type", + "options": "Workstation Type" + }, + { + "depends_on": "eval:!doc.backflush_from_wip_warehouse", + "fieldname": "source_warehouse", + "fieldtype": "Link", + "label": "Source Warehouse", + "options": "Warehouse" + }, + { + "depends_on": "eval:!doc.skip_material_transfer || doc.backflush_from_wip_warehouse", + "fieldname": "wip_warehouse", + "fieldtype": "Link", + "label": "Work In Progress Warehouse", + "options": "Warehouse" + }, + { + "fieldname": "fg_warehouse", + "fieldtype": "Link", + "label": "Finished Good Warehouse", + "options": "Warehouse" + }, + { + "depends_on": "eval:doc.track_semi_finished_goods === 1", + "fieldname": "final_product_warehouse_section", + "fieldtype": "Section Break", + "label": "Final Product Warehouse" + }, + { + "default": "0", + "fieldname": "skip_material_transfer", + "fieldtype": "Check", + "label": "Skip Material Transfer" + }, + { + "default": "0", + "depends_on": "eval:doc.skip_material_transfer", + "fieldname": "backflush_from_wip_warehouse", + "fieldtype": "Check", + "label": "Backflush Materials From WIP Warehouse" + }, + { + "fieldname": "column_break_buha", + "fieldtype": "Column Break" } ], "icon": "fa fa-sitemap", @@ -288,7 +403,7 @@ "link_fieldname": "bom_creator" } ], - "modified": "2024-04-02 16:30:59.779190", + "modified": "2024-05-26 15:47:10.101420", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Creator", diff --git a/erpnext/manufacturing/doctype/bom_creator/bom_creator.py b/erpnext/manufacturing/doctype/bom_creator/bom_creator.py index e236e7a6345..69455027f90 100644 --- a/erpnext/manufacturing/doctype/bom_creator/bom_creator.py +++ b/erpnext/manufacturing/doctype/bom_creator/bom_creator.py @@ -43,16 +43,20 @@ class BOMCreator(Document): from erpnext.manufacturing.doctype.bom_creator_item.bom_creator_item import BOMCreatorItem amended_from: DF.Link | None + backflush_from_wip_warehouse: DF.Check buying_price_list: DF.Link | None company: DF.Link conversion_rate: DF.Float currency: DF.Link default_warehouse: DF.Link | None error_log: DF.Text | None + fg_warehouse: DF.Link | None item_code: DF.Link item_group: DF.Link | None item_name: DF.Data | None items: DF.Table[BOMCreatorItem] + operation: DF.Link | None + operation_time: DF.Float plc_conversion_rate: DF.Float price_list_currency: DF.Link | None project: DF.Link | None @@ -61,8 +65,15 @@ class BOMCreator(Document): remarks: DF.TextEditor | None rm_cost_as_per: DF.Literal["Valuation Rate", "Last Purchase Rate", "Price List"] set_rate_based_on_warehouse: DF.Check + skip_material_transfer: DF.Check + source_warehouse: DF.Link | None status: DF.Literal["Draft", "Submitted", "In Progress", "Completed", "Failed", "Cancelled"] + track_operations: DF.Check + track_semi_finished_goods: DF.Check uom: DF.Link | None + wip_warehouse: DF.Link | None + workstation: DF.Link | None + workstation_type: DF.Link | None # end: auto-generated types def before_save(self): @@ -236,8 +247,10 @@ class BOMCreator(Document): self.db_set("status", "In Progress") production_item_wise_rm = OrderedDict({}) + + final_product = (self.item_code, self.name) production_item_wise_rm.setdefault( - (self.item_code, self.name), frappe._dict({"items": [], "bom_no": "", "fg_item_data": self}) + final_product, frappe._dict({"items": [], "bom_no": "", "fg_item_data": self}) ) for row in self.items: @@ -257,9 +270,15 @@ class BOMCreator(Document): try: for d in reverse_tree: + if self.track_operations and self.track_semi_finished_goods and final_product == d: + continue + fg_item_data = production_item_wise_rm.get(d).fg_item_data self.create_bom(fg_item_data, production_item_wise_rm) + if self.track_operations and self.track_semi_finished_goods: + self.make_bom_for_final_product(production_item_wise_rm) + frappe.msgprint(_("BOMs created successfully")) except Exception: traceback = frappe.get_traceback(with_context=True) @@ -272,6 +291,81 @@ class BOMCreator(Document): frappe.msgprint(_("BOMs creation failed")) + def make_bom_for_final_product(self, production_item_wise_rm): + bom = frappe.new_doc("BOM") + bom.update( + { + "item": self.item_code, + "bom_type": "Production", + "quantity": self.qty, + "allow_alternative_item": 1, + "bom_creator": self.name, + "bom_creator_item": self.name, + "rm_cost_as_per": "Manual", + "with_operations": 1, + "track_semi_finished_goods": 1, + } + ) + + for field in BOM_FIELDS: + if self.get(field): + bom.set(field, self.get(field)) + + for item in self.items: + if not item.is_expandable or not item.operation: + continue + + bom.append( + "operations", + { + "operation": item.operation, + "workstation": item.workstation, + "source_warehouse": item.source_warehouse, + "wip_warehouse": item.wip_warehouse, + "fg_warehouse": item.fg_warehouse, + "finished_good": item.item_code, + "finished_good_qty": item.qty, + "bom_no": production_item_wise_rm[(item.item_code, item.name)].bom_no, + "workstation_type": item.workstation_type, + "time_in_mins": item.operation_time, + "is_subcontracted": item.is_subcontracted, + "skip_material_transfer": item.skip_material_transfer, + "backflush_from_wip_warehouse": item.backflush_from_wip_warehouse, + }, + ) + + operation_row = bom.append( + "operations", + { + "operation": self.operation, + "time_in_mins": self.operation_time, + "workstation": self.workstation, + "workstation_type": self.workstation_type, + "finished_good": self.item_code, + "finished_good_qty": self.qty, + "source_warehouse": self.source_warehouse, + "wip_warehouse": self.wip_warehouse, + "fg_warehouse": self.fg_warehouse, + "skip_material_transfer": self.skip_material_transfer, + "backflush_from_wip_warehouse": self.backflush_from_wip_warehouse, + }, + ) + + final_product = (self.item_code, self.name) + items = production_item_wise_rm.get(final_product).get("items") + + bom.set_materials_based_on_operation_bom() + + for item in items: + item_args = {"operation_row_id": operation_row.idx} + for field in BOM_ITEM_FIELDS: + item_args[field] = item.get(field) + + bom.append("items", item_args) + + bom.save(ignore_permissions=True) + bom.submit() + def create_bom(self, row, production_item_wise_rm): bom_creator_item = row.name if row.name != self.name else "" if frappe.db.exists( @@ -297,6 +391,24 @@ class BOMCreator(Document): } ) + if self.track_operations and not self.track_semi_finished_goods: + if row.item_code == self.item_code: + bom.with_operations = 1 + bom.transfer_material_against = "Work Order" + for item in self.items: + if not item.operation: + continue + + bom.append( + "operations", + { + "operation": item.operation, + "workstation_type": item.workstation_type, + "workstation": item.workstation, + "time_in_mins": item.operation_time, + }, + ) + for field in BOM_FIELDS: if self.get(field): bom.set(field, self.get(field)) @@ -352,6 +464,16 @@ def get_children(doctype=None, parent=None, **kwargs): "uom", "rate", "amount", + "workstation_type", + "operation", + "operation_time", + "is_subcontracted", + "workstation", + "source_warehouse", + "wip_warehouse", + "fg_warehouse", + "skip_material_transfer", + "backflush_from_wip_warehouse", ] query_filters = { @@ -365,6 +487,12 @@ def get_children(doctype=None, parent=None, **kwargs): return frappe.get_all("BOM Creator Item", fields=fields, filters=query_filters, order_by="idx") +def get_parent_row_no(doc, name): + for row in doc.items: + if row.name == name: + return row.idx + + @frappe.whitelist() def add_item(**kwargs): if isinstance(kwargs, str): @@ -375,6 +503,11 @@ def add_item(**kwargs): doc = frappe.get_doc("BOM Creator", kwargs.parent) item_info = get_item_details(kwargs.item_code) + + parent_row_no = "" + if kwargs.fg_reference_id and doc.name != kwargs.fg_reference_id: + parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id) + kwargs.update( { "uom": item_info.stock_uom, @@ -383,6 +516,9 @@ def add_item(**kwargs): } ) + if parent_row_no: + kwargs.update({"parent_row_no": parent_row_no}) + doc.append("items", kwargs) doc.save() @@ -402,6 +538,7 @@ def add_sub_assembly(**kwargs): name = kwargs.fg_reference_id parent_row_no = "" + if not kwargs.convert_to_sub_assembly: item_info = get_item_details(bom_item.item_code) item_row = doc.append( @@ -417,6 +554,14 @@ def add_sub_assembly(**kwargs): "do_not_explode": 1, "is_expandable": 1, "stock_uom": item_info.stock_uom, + "operation": bom_item.operation, + "workstation_type": bom_item.workstation_type, + "operation_time": bom_item.operation_time, + "is_subcontracted": bom_item.is_subcontracted, + "workstation": bom_item.workstation, + "source_warehouse": bom_item.source_warehouse, + "wip_warehouse": bom_item.wip_warehouse, + "fg_warehouse": bom_item.fg_warehouse, }, ) @@ -426,6 +571,20 @@ def add_sub_assembly(**kwargs): parent_row_no = [row.idx for row in doc.items if row.name == kwargs.fg_reference_id] if parent_row_no: parent_row_no = parent_row_no[0] + doc.items[parent_row_no - 1].update( + { + "operation": bom_item.operation, + "workstation_type": bom_item.workstation_type, + "operation_time": bom_item.operation_time, + "is_subcontracted": bom_item.is_subcontracted, + "workstation": bom_item.workstation, + "source_warehouse": bom_item.source_warehouse, + "wip_warehouse": bom_item.wip_warehouse, + "fg_warehouse": bom_item.fg_warehouse, + "skip_material_transfer": bom_item.skip_material_transfer, + "backflush_from_wip_warehouse": bom_item.backflush_from_wip_warehouse, + } + ) for row in bom_item.get("items"): row = frappe._dict(row) @@ -482,10 +641,16 @@ def delete_node(**kwargs): @frappe.whitelist() -def edit_qty(doctype, docname, qty, parent): - frappe.db.set_value(doctype, docname, "qty", qty) +def edit_bom_creator(doctype, docname, data, parent): + if isinstance(data, str): + data = frappe.parse_json(data) + + frappe.db.set_value(doctype, docname, data) + doc = frappe.get_doc("BOM Creator", parent) doc.set_rate_for_items() doc.save() + frappe.msgprint(_("Updated successfully"), alert=True) + return doc diff --git a/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.json b/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.json index e9545ac5385..9be55667f80 100644 --- a/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.json +++ b/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.json @@ -11,10 +11,23 @@ "item_group", "column_break_f63f", "fg_item", - "source_warehouse", "is_expandable", "sourced_by_supplier", "bom_created", + "is_subcontracted", + "operation_section", + "operation", + "operation_time", + "column_break_cbnk", + "workstation_type", + "workstation", + "warehouse_section", + "skip_material_transfer", + "backflush_from_wip_warehouse", + "source_warehouse", + "column_break_xutc", + "wip_warehouse", + "fg_warehouse", "description_section", "description", "quantity_and_rate_section", @@ -75,16 +88,18 @@ "reqd": 1 }, { + "depends_on": "eval:doc.skip_material_transfer && !doc.backflush_from_wip_warehouse", "fieldname": "source_warehouse", "fieldtype": "Link", - "in_list_view": 1, "label": "Source Warehouse", "options": "Warehouse" }, { + "columns": 1, "default": "0", "fieldname": "is_expandable", "fieldtype": "Check", + "in_list_view": 1, "label": "Is Expandable", "read_only": 1 }, @@ -225,12 +240,87 @@ "label": "BOM Created", "no_copy": 1, "print_hide": 1 + }, + { + "fieldname": "operation_section", + "fieldtype": "Section Break", + "label": "Operation" + }, + { + "fieldname": "operation", + "fieldtype": "Link", + "label": "Operation", + "options": "Operation" + }, + { + "fieldname": "column_break_cbnk", + "fieldtype": "Column Break" + }, + { + "fieldname": "workstation_type", + "fieldtype": "Link", + "label": "Workstation Type", + "options": "Workstation Type" + }, + { + "description": "In Mins", + "fieldname": "operation_time", + "fieldtype": "Int", + "label": "Operation Time" + }, + { + "fieldname": "workstation", + "fieldtype": "Link", + "label": "Workstation", + "options": "Workstation" + }, + { + "fieldname": "warehouse_section", + "fieldtype": "Section Break", + "label": "Warehouse" + }, + { + "depends_on": "eval:!doc.skip_material_transfer || doc.backflush_from_wip_warehouse", + "fieldname": "wip_warehouse", + "fieldtype": "Link", + "label": "Work In Progress Warehouse", + "options": "Warehouse" + }, + { + "fieldname": "column_break_xutc", + "fieldtype": "Column Break" + }, + { + "fieldname": "fg_warehouse", + "fieldtype": "Link", + "label": "Finished Good Warehouse", + "options": "Warehouse" + }, + { + "default": "0", + "fieldname": "skip_material_transfer", + "fieldtype": "Check", + "label": "Skip Material Transfer" + }, + { + "default": "0", + "depends_on": "eval:doc.skip_material_transfer", + "fieldname": "backflush_from_wip_warehouse", + "fieldtype": "Check", + "label": "Backflush Materials From WIP Warehouse" + }, + { + "default": "0", + "fieldname": "is_subcontracted", + "fieldtype": "Check", + "label": "Is Subcontracted", + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:06:40.764747", + "modified": "2024-06-01 18:45:24.339532", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Creator Item", diff --git a/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.py b/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.py index e172f36224d..2510a02ddc7 100644 --- a/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.py +++ b/erpnext/manufacturing/doctype/bom_creator_item/bom_creator_item.py @@ -15,6 +15,7 @@ class BOMCreatorItem(Document): from frappe.types import DF amount: DF.Currency + backflush_from_wip_warehouse: DF.Check base_amount: DF.Currency base_rate: DF.Currency bom_created: DF.Check @@ -23,22 +24,30 @@ class BOMCreatorItem(Document): do_not_explode: DF.Check fg_item: DF.Link fg_reference_id: DF.Data | None + fg_warehouse: DF.Link | None instruction: DF.SmallText | None is_expandable: DF.Check + is_subcontracted: DF.Check item_code: DF.Link item_group: DF.Link | None item_name: DF.Data | None + operation: DF.Link | None + operation_time: DF.Int parent: DF.Data parent_row_no: DF.Data | None parentfield: DF.Data parenttype: DF.Data qty: DF.Float rate: DF.Currency + skip_material_transfer: DF.Check source_warehouse: DF.Link | None sourced_by_supplier: DF.Check stock_qty: DF.Float stock_uom: DF.Link | None uom: DF.Link | None + wip_warehouse: DF.Link | None + workstation: DF.Link | None + workstation_type: DF.Link | None # end: auto-generated types pass diff --git a/erpnext/manufacturing/doctype/bom_item/bom_item.json b/erpnext/manufacturing/doctype/bom_item/bom_item.json index 226cfe0162f..1d530af34a2 100644 --- a/erpnext/manufacturing/doctype/bom_item/bom_item.json +++ b/erpnext/manufacturing/doctype/bom_item/bom_item.json @@ -9,6 +9,7 @@ "item_code", "item_name", "operation", + "operation_row_id", "column_break_3", "do_not_explode", "bom_no", @@ -293,13 +294,19 @@ "fieldtype": "Check", "label": "Is Stock Item", "read_only": 1 + }, + { + "depends_on": "eval:parent.track_semi_finished_goods ==1", + "fieldname": "operation_row_id", + "fieldtype": "Int", + "label": "Operation ID" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:06:41.079752", + "modified": "2024-03-27 13:08:41.079752", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Item", diff --git a/erpnext/manufacturing/doctype/bom_item/bom_item.py b/erpnext/manufacturing/doctype/bom_item/bom_item.py index 466253bf0bf..87430d7d47d 100644 --- a/erpnext/manufacturing/doctype/bom_item/bom_item.py +++ b/erpnext/manufacturing/doctype/bom_item/bom_item.py @@ -25,9 +25,11 @@ class BOMItem(Document): has_variants: DF.Check image: DF.Attach | None include_item_in_manufacturing: DF.Check + is_stock_item: DF.Check item_code: DF.Link item_name: DF.Data | None operation: DF.Link | None + operation_row_id: DF.Int original_item: DF.Link | None parent: DF.Data parentfield: DF.Data diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json index aa62b027b06..b9e960ab66e 100644 --- a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json +++ b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json @@ -6,24 +6,36 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "sequence_id", "operation", + "sequence_id", + "finished_good", + "finished_good_qty", + "bom_no", + "add_raw_materials", "col_break1", "workstation_type", "workstation", "time_in_mins", "fixed_time", + "is_subcontracted", + "is_final_finished_good", + "set_cost_based_on_bom_qty", + "warehouse_section", + "skip_material_transfer", + "backflush_from_wip_warehouse", + "source_warehouse", + "column_break_lbhy", + "wip_warehouse", + "fg_warehouse", "costing_section", "hour_rate", "base_hour_rate", - "column_break_9", - "operating_cost", - "base_operating_cost", - "column_break_11", "batch_size", - "set_cost_based_on_bom_qty", + "column_break_11", "cost_per_unit", "base_cost_per_unit", + "operating_cost", + "base_operating_cost", "more_information_section", "description", "column_break_18", @@ -71,6 +83,7 @@ "precision": "2" }, { + "columns": 1, "description": "In minutes", "fetch_from": "operation.total_operation_time", "fetch_if_empty": 1, @@ -87,7 +100,6 @@ "description": "Operation time does not depend on quantity to produce", "fieldname": "fixed_time", "fieldtype": "Check", - "in_list_view": 1, "label": "Fixed Time" }, { @@ -172,10 +184,6 @@ "fieldname": "column_break_18", "fieldtype": "Column Break" }, - { - "fieldname": "column_break_9", - "fieldtype": "Column Break" - }, { "default": "0", "fieldname": "set_cost_based_on_bom_qty", @@ -183,18 +191,106 @@ "label": "Set Operating Cost Based On BOM Quantity" }, { + "columns": 1, "fieldname": "workstation_type", "fieldtype": "Link", "in_list_view": 1, "label": "Workstation Type", "options": "Workstation Type" + }, + { + "fieldname": "finished_good", + "fieldtype": "Link", + "in_list_view": 1, + "label": "FG / Semi FG Item", + "options": "Item" + }, + { + "columns": 1, + "fieldname": "bom_no", + "fieldtype": "Link", + "in_list_view": 1, + "label": "BOM No", + "options": "BOM" + }, + { + "columns": 1, + "default": "1", + "fieldname": "finished_good_qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "FG Qty" + }, + { + "default": "0", + "fieldname": "is_final_finished_good", + "fieldtype": "Check", + "label": "Is Final Finished Good" + }, + { + "fieldname": "warehouse_section", + "fieldtype": "Section Break", + "label": "Warehouse" + }, + { + "depends_on": "eval:!doc.skip_material_transfer || doc.backflush_from_wip_warehouse", + "fieldname": "wip_warehouse", + "fieldtype": "Link", + "label": "WIP WH", + "options": "Warehouse" + }, + { + "fieldname": "column_break_lbhy", + "fieldtype": "Column Break" + }, + { + "columns": 1, + "fieldname": "fg_warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "FG WH", + "options": "Warehouse" + }, + { + "columns": 1, + "depends_on": "eval:doc.skip_material_transfer && !doc.backflush_from_wip_warehouse", + "fieldname": "source_warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Source WH", + "options": "Warehouse" + }, + { + "default": "0", + "fieldname": "is_subcontracted", + "fieldtype": "Check", + "label": "Is Subcontracted" + }, + { + "depends_on": "eval:!doc.bom_no", + "fieldname": "add_raw_materials", + "fieldtype": "Button", + "label": "Add Raw Materials" + }, + { + "default": "0", + "fieldname": "skip_material_transfer", + "fieldtype": "Check", + "label": " Skip Material Transfer" + }, + { + "default": "0", + "depends_on": "eval:doc.skip_material_transfer", + "fieldname": "backflush_from_wip_warehouse", + "fieldtype": "Check", + "label": "Backflush Materials From WIP Warehouse" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:06:41.248462", + "modified": "2024-05-26 15:46:49.404875", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Operation", diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.py b/erpnext/manufacturing/doctype/bom_operation/bom_operation.py index 66ac02891b9..fd197e89e62 100644 --- a/erpnext/manufacturing/doctype/bom_operation/bom_operation.py +++ b/erpnext/manufacturing/doctype/bom_operation/bom_operation.py @@ -14,15 +14,22 @@ class BOMOperation(Document): if TYPE_CHECKING: from frappe.types import DF + backflush_from_wip_warehouse: DF.Check base_cost_per_unit: DF.Float base_hour_rate: DF.Currency base_operating_cost: DF.Currency batch_size: DF.Int + bom_no: DF.Link | None cost_per_unit: DF.Float description: DF.TextEditor | None + fg_warehouse: DF.Link | None + finished_good: DF.Link | None + finished_good_qty: DF.Float fixed_time: DF.Check hour_rate: DF.Currency image: DF.Attach | None + is_final_finished_good: DF.Check + is_subcontracted: DF.Check operating_cost: DF.Currency operation: DF.Link parent: DF.Data @@ -30,7 +37,10 @@ class BOMOperation(Document): parenttype: DF.Data sequence_id: DF.Int set_cost_based_on_bom_qty: DF.Check + skip_material_transfer: DF.Check + source_warehouse: DF.Link | None time_in_mins: DF.Float + wip_warehouse: DF.Link | None workstation: DF.Link | None workstation_type: DF.Link | None # end: auto-generated types diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index 4cc60a3b4a6..2de5d9dad11 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -32,21 +32,61 @@ frappe.ui.form.on("Job Card", { }); }, + make_fields_read_only(frm) { + if (frm.doc.docstatus === 1) { + frm.set_df_property("employee", "read_only", 1); + frm.set_df_property("time_logs", "read_only", 1); + } + + if (frm.doc.is_subcontracted) { + frm.set_df_property("wip_warehouse", "label", __("Supplier Warehouse")); + } + }, + + setup_stock_entry(frm) { + if ( + frm.doc.finished_good && + frm.doc.docstatus === 1 && + !frm.doc.is_subcontracted && + flt(frm.doc.for_quantity) + flt(frm.doc.process_loss_qty) > flt(frm.doc.manufactured_qty) + ) { + frm.add_custom_button(__("Make Stock Entry"), () => { + frm.call({ + method: "make_stock_entry_for_semi_fg_item", + args: { + auto_submit: 1, + }, + doc: frm.doc, + freeze: true, + callback() { + frm.reload_doc(); + }, + }); + }).addClass("btn-primary"); + } + }, + refresh: function (frm) { - frappe.flags.pause_job = 0; - frappe.flags.resume_job = 0; + frm.trigger("setup_stock_entry"); + let has_items = frm.doc.items && frm.doc.items.length; + frm.trigger("make_fields_read_only"); if (!frm.is_new() && frm.doc.__onload.work_order_closed) { frm.disable_save(); return; } + if (frm.doc.is_subcontracted) { + frm.trigger("make_subcontracting_po"); + return; + } + let has_stock_entry = frm.doc.__onload && frm.doc.__onload.has_stock_entry ? true : false; frm.toggle_enable("for_quantity", !has_stock_entry); - if (!frm.is_new() && has_items && frm.doc.docstatus < 2) { + if (!frm.is_new() && !frm.doc.skip_material_transfer && 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; @@ -63,11 +103,11 @@ frappe.ui.form.on("Job Card", { if (to_transfer || excess_transfer_allowed) { frm.add_custom_button(__("Material Transfer"), () => { frm.trigger("make_stock_entry"); - }).addClass("btn-primary"); + }); } } - if (frm.doc.docstatus == 1 && !frm.doc.is_corrective_job_card) { + if (frm.doc.docstatus == 1 && !frm.doc.is_corrective_job_card && !frm.doc.finished_good) { frm.trigger("setup_corrective_job_card"); } @@ -84,31 +124,67 @@ frappe.ui.form.on("Job Card", { frm.trigger("toggle_operation_number"); if ( - frm.doc.docstatus == 0 && - !frm.is_new() && - (frm.doc.for_quantity > frm.doc.total_completed_qty || !frm.doc.for_quantity) && - (frm.doc.items || !frm.doc.items.length || frm.doc.for_quantity == frm.doc.transferred_qty) + frm.doc.for_quantity + frm.doc.process_loss_qty > frm.doc.total_completed_qty && + (frm.doc.skip_material_transfer || + frm.doc.transferred_qty >= frm.doc.for_quantity + frm.doc.process_loss_qty || + !frm.doc.finished_good) ) { - // if Job Card is link to Work Order, the job card must not be able to start if Work Order not "Started" - // and if stock mvt for WIP is required - if (frm.doc.work_order) { - frappe.db.get_value( - "Work Order", - frm.doc.work_order, - ["skip_transfer", "status"], - (result) => { - if ( - result.skip_transfer === 1 || - result.status == "In Process" || - frm.doc.transferred_qty > 0 || - !frm.doc.items.length - ) { - frm.trigger("prepare_timer_buttons"); - } + if (!frm.doc.time_logs?.length) { + frm.add_custom_button(__("Start Job"), () => { + let from_time = frappe.datetime.now_datetime(); + if ((frm.doc.employee && !frm.doc.employee.length) || !frm.doc.employee) { + frappe.prompt( + { + fieldtype: "Table MultiSelect", + label: __("Select Employees"), + options: "Job Card Time Log", + fieldname: "employees", + }, + (d) => { + frm.events.start_timer(frm, from_time, d.employees); + }, + __("Assign Job to Employee") + ); + } else { + frm.events.start_timer(frm, from_time, frm.doc.employee); } - ); + }); + } else if (frm.doc.is_paused) { + frm.add_custom_button(__("Resume Job"), () => { + frm.call({ + method: "resume_job", + doc: frm.doc, + args: { + start_time: frappe.datetime.now_datetime(), + }, + callback() { + frm.reload_doc(); + }, + }); + }); } else { - frm.trigger("prepare_timer_buttons"); + if (frm.doc.for_quantity - frm.doc.manufactured_qty > 0) { + if (!frm.doc.is_paused) { + frm.add_custom_button(__("Pause Job"), () => { + frm.call({ + method: "pause_job", + doc: frm.doc, + args: { + end_time: frappe.datetime.now_datetime(), + }, + callback() { + frm.reload_doc(); + }, + }); + }); + } + + frm.add_custom_button(__("Complete Job"), () => { + frm.trigger("complete_job_card"); + }); + } + + frm.trigger("make_dashboard"); } } @@ -116,7 +192,7 @@ frappe.ui.form.on("Job Card", { if (frm.doc.work_order) { frappe.db.get_value("Work Order", frm.doc.work_order, "transfer_material_against").then((r) => { - if (r.message.transfer_material_against == "Work Order") { + if (r.message.transfer_material_against == "Work Order" && !frm.doc.operation_row_id) { frm.set_df_property("items", "hidden", 1); } }); @@ -134,6 +210,75 @@ frappe.ui.form.on("Job Card", { } }, + make_subcontracting_po(frm) { + if (frm.doc.docstatus === 1 && frm.doc.for_quantity > frm.doc.manufactured_qty) { + frm.add_custom_button(__("Make Subcontracting PO"), () => { + frappe.model.open_mapped_doc({ + method: "erpnext.manufacturing.doctype.job_card.job_card.make_subcontracting_po", + frm: frm, + }); + }).addClass("btn-primary"); + } + }, + + start_timer(frm, start_time, employees) { + frm.call({ + method: "start_timer", + doc: frm.doc, + args: { + start_time: start_time, + employees: employees, + }, + callback: function (r) { + frm.reload_doc(); + frm.trigger("make_dashboard"); + }, + }); + }, + + make_finished_good(frm) { + let fields = [ + { + fieldtype: "Float", + label: __("Completed Quantity"), + fieldname: "qty", + reqd: 1, + default: frm.doc.for_quantity - frm.doc.manufactured_qty, + }, + { + fieldtype: "Datetime", + label: __("End Time"), + fieldname: "end_time", + default: frappe.datetime.now_datetime(), + }, + ]; + + frappe.prompt( + fields, + (data) => { + if (data.qty <= 0) { + frappe.throw(__("Quantity should be greater than 0")); + } + + frm.call({ + method: "make_finished_good", + doc: frm.doc, + args: { + qty: data.qty, + end_time: data.end_time, + }, + callback: function (r) { + var doc = frappe.model.sync(r.message); + frappe.set_route("Form", doc[0].doctype, doc[0].name); + }, + }); + }, + __("Enter Value"), + __("Update"), + __("Set Finished Good Quantity") + ); + }, + setup_quality_inspection: function (frm) { let quality_inspection_field = frm.get_docfield("quality_inspection"); quality_inspection_field.get_route_options_for_new_doc = function (frm) { @@ -262,90 +407,6 @@ frappe.ui.form.on("Job Card", { frm.toggle_reqd("operation_row_number", !frm.doc.operation_id && frm.doc.operation); }, - prepare_timer_buttons: function (frm) { - frm.trigger("make_dashboard"); - - if (!frm.doc.started_time && !frm.doc.current_time) { - frm.add_custom_button(__("Start Job"), () => { - if ((frm.doc.employee && !frm.doc.employee.length) || !frm.doc.employee) { - frappe.prompt( - { - fieldtype: "Table MultiSelect", - label: __("Select Employees"), - options: "Job Card Time Log", - fieldname: "employees", - }, - (d) => { - frm.events.start_job(frm, "Work In Progress", d.employees); - }, - __("Assign Job to Employee") - ); - } else { - frm.events.start_job(frm, "Work In Progress", frm.doc.employee); - } - }).addClass("btn-primary"); - } else if (frm.doc.status == "On Hold") { - frm.add_custom_button(__("Resume Job"), () => { - frm.events.start_job(frm, "Resume Job", frm.doc.employee); - }).addClass("btn-primary"); - } else { - frm.add_custom_button(__("Pause Job"), () => { - frm.events.complete_job(frm, "On Hold"); - }); - - frm.add_custom_button(__("Complete Job"), () => { - var sub_operations = frm.doc.sub_operations; - - let set_qty = true; - if (sub_operations && sub_operations.length > 1) { - set_qty = false; - let last_op_row = sub_operations[sub_operations.length - 2]; - - if (last_op_row.status == "Complete") { - set_qty = true; - } - } - - if (set_qty) { - frappe.prompt( - { - fieldtype: "Float", - label: __("Completed Quantity"), - fieldname: "qty", - default: frm.doc.for_quantity, - }, - (data) => { - frm.events.complete_job(frm, "Complete", data.qty); - }, - __("Enter Value") - ); - } else { - frm.events.complete_job(frm, "Complete", 0.0); - } - }).addClass("btn-primary"); - } - }, - - start_job: function (frm, status, employee) { - const args = { - job_card_id: frm.doc.name, - start_time: frappe.datetime.now_datetime(), - employees: employee, - status: status, - }; - frm.events.make_time_log(frm, args); - }, - - complete_job: function (frm, status, completed_qty) { - const args = { - job_card_id: frm.doc.name, - complete_time: frappe.datetime.now_datetime(), - status: status, - completed_qty: completed_qty, - }; - frm.events.make_time_log(frm, args); - }, - make_time_log: function (frm, args) { frm.events.update_sub_operation(frm, args); @@ -392,7 +453,7 @@ frappe.ui.form.on("Job Card", { function updateStopwatch(increment) { var hours = Math.floor(increment / 3600); var minutes = Math.floor((increment - hours * 3600) / 60); - var seconds = increment - hours * 3600 - minutes * 60; + var seconds = flt(increment - hours * 3600 - minutes * 60, 2); $(section) .find(".hours") @@ -415,7 +476,7 @@ frappe.ui.form.on("Job Card", { frm.dashboard.refresh(); const timer = `
00 : 00 @@ -424,21 +485,34 @@ frappe.ui.form.on("Job Card", {
`; var section = frm.toolbar.page.add_inner_message(timer); - - let currentIncrement = frm.doc.current_time || 0; - if (frm.doc.started_time || frm.doc.current_time) { - if (frm.doc.status == "On Hold") { - updateStopwatch(currentIncrement); - } else { - currentIncrement += moment(frappe.datetime.now_datetime()).diff( - moment(frm.doc.started_time), - "seconds" - ); - initialiseTimer(); - } + let currentIncrement = frm.events.get_current_time(frm); + if (frm.doc.time_logs?.length && frm.doc.time_logs[cint(frm.doc.time_logs.length) - 1].to_time) { + updateStopwatch(currentIncrement); + } else if (frm.doc.status == "On Hold") { + updateStopwatch(currentIncrement); + } else { + initialiseTimer(); } }, + get_current_time(frm) { + let current_time = 0; + + frm.doc.time_logs.forEach((d) => { + if (d.to_time) { + if (d.time_in_mins) { + current_time += flt(d.time_in_mins, 2) * 60; + } else { + current_time += get_seconds_diff(d.to_time, d.from_time); + } + } else { + current_time += get_seconds_diff(frappe.datetime.now_datetime(), d.from_time); + } + }); + + return current_time; + }, + hide_timer: function (frm) { frm.toolbar.page.inner_toolbar.find(".stopwatch").remove(); }, @@ -492,6 +566,14 @@ frappe.ui.form.on("Job Card", { refresh_field("total_completed_qty"); }, + + source_warehouse(frm) { + if (frm.doc.source_warehouse) { + frm.doc.items.forEach((d) => { + frappe.model.set_value(d.doctype, d.name, "source_warehouse", frm.doc.source_warehouse); + }); + } + }, }); frappe.ui.form.on("Job Card Time Log", { @@ -503,3 +585,7 @@ frappe.ui.form.on("Job Card Time Log", { frm.set_value("started_time", ""); }, }); + +function get_seconds_diff(d1, d2) { + return moment(d1).diff(d2, "seconds"); +} diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index 531c71f9c63..9a0d1c69a1b 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -8,43 +8,57 @@ "field_order": [ "naming_series", "work_order", - "bom_no", - "production_item", "employee", + "is_subcontracted", "column_break_4", "posting_date", "company", - "for_quantity", + "project", + "bom_no", + "semi_finished_good__finished_good_section", + "finished_good", + "production_item", + "semi_fg_bom", "total_completed_qty", + "column_break_mcnb", + "for_quantity", + "transferred_qty", + "manufactured_qty", "process_loss_qty", + "production_section", + "operation", + "source_warehouse", + "wip_warehouse", + "skip_material_transfer", + "backflush_from_wip_warehouse", + "column_break_12", + "workstation_type", + "workstation", + "target_warehouse", + "section_break_8", + "items", + "quality_inspection_section", + "quality_inspection_template", + "column_break_fcmp", + "quality_inspection", + "scheduled_time_tab", "scheduled_time_section", "expected_start_date", "time_required", "column_break_jkir", "expected_end_date", - "section_break_05am", + "section_break_rzeo", "scheduled_time_logs", "timing_detail", - "time_logs", "section_break_13", "actual_start_date", "total_time_in_mins", "column_break_15", "actual_end_date", - "production_section", - "operation", - "wip_warehouse", - "column_break_12", - "workstation_type", - "workstation", - "quality_inspection_section", - "quality_inspection_template", - "column_break_fcmp", - "quality_inspection", + "section_break_jbas", + "time_logs", "section_break_21", "sub_operations", - "section_break_8", - "items", "scrap_items_section", "scrap_items", "corrective_operation_section", @@ -54,11 +68,11 @@ "hour_rate", "for_operation", "more_information", - "project", "item_name", - "transferred_qty", "requested_qty", "status", + "operation_row_id", + "is_paused", "column_break_20", "operation_row_number", "operation_id", @@ -68,7 +82,6 @@ "batch_no", "serial_no", "barcode", - "job_started", "started_time", "current_time", "amended_from", @@ -86,10 +99,11 @@ "search_index": 1 }, { + "depends_on": "eval:!doc.finished_good", "fetch_from": "work_order.bom_no", "fieldname": "bom_no", "fieldtype": "Link", - "label": "BOM No", + "label": "Final BOM", "options": "BOM", "read_only": 1 }, @@ -105,6 +119,7 @@ "fieldname": "operation", "fieldtype": "Link", "in_list_view": 1, + "in_preview": 1, "label": "Operation", "options": "Operation", "reqd": 1 @@ -130,22 +145,24 @@ "fieldname": "for_quantity", "fieldtype": "Float", "in_list_view": 1, + "in_preview": 1, "label": "Qty To Manufacture" }, { "fieldname": "wip_warehouse", "fieldtype": "Link", "label": "WIP Warehouse", - "options": "Warehouse", - "reqd": 1 + "mandatory_depends_on": "eval:!doc.finished_good || doc.skip_material_transfer === 0 || (doc.skip_material_transfer && doc.backflush_from_wip_warehouse)", + "options": "Warehouse" }, { "fieldname": "timing_detail", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Actual Time" }, { "allow_bulk_edit": 1, + "allow_on_submit": 1, "fieldname": "time_logs", "fieldtype": "Table", "label": "Time Logs", @@ -157,9 +174,12 @@ "hide_border": 1 }, { + "allow_on_submit": 1, "default": "0", + "depends_on": "eval:doc.is_subcontracted===0", "fieldname": "total_completed_qty", "fieldtype": "Float", + "in_preview": 1, "label": "Total Completed Qty", "read_only": 1 }, @@ -175,7 +195,7 @@ }, { "fieldname": "section_break_8", - "fieldtype": "Tab Break", + "fieldtype": "Section Break", "label": "Raw Materials" }, { @@ -199,9 +219,10 @@ }, { "default": "0", + "depends_on": "items", "fieldname": "transferred_qty", "fieldtype": "Float", - "label": "FG Qty from Transferred Raw Materials", + "label": "Transferred Raw Materials", "read_only": 1 }, { @@ -227,6 +248,7 @@ "fieldtype": "Column Break" }, { + "allow_on_submit": 1, "default": "Open", "fieldname": "status", "fieldtype": "Select", @@ -236,16 +258,7 @@ "read_only": 1 }, { - "default": "0", - "fieldname": "job_started", - "fieldtype": "Check", - "hidden": 1, - "label": "Job Started", - "no_copy": 1, - "print_hide": 1, - "read_only": 1 - }, - { + "allow_on_submit": 1, "fieldname": "started_time", "fieldtype": "Datetime", "hidden": 1, @@ -273,18 +286,19 @@ }, { "fieldname": "production_section", - "fieldtype": "Tab Break", - "label": "Operation & Workstation" + "fieldtype": "Section Break", + "label": "Operation & Materials" }, { "fieldname": "column_break_12", "fieldtype": "Column Break" }, { + "depends_on": "eval:!doc.finished_good", "fetch_from": "work_order.production_item", "fieldname": "production_item", "fieldtype": "Link", - "label": "Production Item", + "label": "Final Product", "options": "Item", "read_only": 1 }, @@ -302,6 +316,7 @@ "label": "Item Name" }, { + "allow_on_submit": 1, "fieldname": "current_time", "fieldtype": "Int", "hidden": 1, @@ -384,6 +399,7 @@ "options": "Operation" }, { + "allow_on_submit": 1, "fieldname": "employee", "fieldtype": "Table MultiSelect", "label": "Employee", @@ -463,6 +479,7 @@ "show_dashboard": 1 }, { + "depends_on": "expected_start_date", "fieldname": "scheduled_time_section", "fieldtype": "Section Break", "label": "Scheduled Time" @@ -476,10 +493,6 @@ "fieldtype": "Float", "label": "Expected Time Required (In Mins)" }, - { - "fieldname": "section_break_05am", - "fieldtype": "Section Break" - }, { "fieldname": "scheduled_time_logs", "fieldtype": "Table", @@ -507,11 +520,101 @@ { "fieldname": "column_break_fcmp", "fieldtype": "Column Break" + }, + { + "fieldname": "finished_good", + "fieldtype": "Link", + "in_preview": 1, + "label": "Finished Good", + "options": "Item", + "read_only": 1 + }, + { + "fieldname": "target_warehouse", + "fieldtype": "Link", + "label": "Target Warehouse", + "mandatory_depends_on": "eval:doc.finished_good", + "options": "Warehouse" + }, + { + "fieldname": "operation_row_id", + "fieldtype": "Int", + "label": "Operation Row ID" + }, + { + "fieldname": "source_warehouse", + "fieldtype": "Link", + "label": "Source Warehouse", + "options": "Warehouse" + }, + { + "fieldname": "semi_finished_good__finished_good_section", + "fieldtype": "Section Break", + "label": "Semi Finished Good / Finished Good" + }, + { + "fieldname": "semi_fg_bom", + "fieldtype": "Link", + "label": "Semi FG BOM", + "options": "BOM", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_subcontracted", + "fieldtype": "Check", + "label": " Is Subcontracted", + "read_only": 1 + }, + { + "fieldname": "column_break_mcnb", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_rzeo", + "fieldtype": "Section Break" + }, + { + "fieldname": "section_break_jbas", + "fieldtype": "Section Break" + }, + { + "fieldname": "scheduled_time_tab", + "fieldtype": "Tab Break", + "label": "Scheduled Time" + }, + { + "depends_on": "finished_good", + "fieldname": "manufactured_qty", + "fieldtype": "Float", + "label": "Manufactured Qty", + "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval:doc.finished_good", + "fieldname": "skip_material_transfer", + "fieldtype": "Check", + "label": "Skip Material Transfer to WIP" + }, + { + "default": "0", + "depends_on": "eval:doc.finished_good && doc.skip_material_transfer === 1", + "fieldname": "backflush_from_wip_warehouse", + "fieldtype": "Check", + "label": "Backflush Materials From WIP Warehouse" + }, + { + "default": "0", + "fieldname": "is_paused", + "fieldtype": "Check", + "label": "Is Paused", + "read_only": 1 } ], "is_submittable": 1, "links": [], - "modified": "2024-03-27 13:09:56.634418", + "modified": "2024-05-26 17:44:18.324743", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card", @@ -564,6 +667,7 @@ "write": 1 } ], + "show_preview_popup": 1, "sort_field": "creation", "sort_order": "DESC", "states": [], diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index c565c910c4e..100ca45a9f0 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -9,7 +9,7 @@ from frappe import _, bold from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc from frappe.query_builder import Criterion -from frappe.query_builder.functions import IfNull, Max, Min +from frappe.query_builder.functions import IfNull, Max, Min, Sum from frappe.utils import ( add_days, add_to_date, @@ -21,13 +21,15 @@ from frappe.utils import ( getdate, time_diff, time_diff_in_hours, - time_diff_in_seconds, ) from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import ( get_mins_between_operations, ) from erpnext.manufacturing.doctype.workstation_type.workstation_type import get_workstations +from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import ( + get_subcontracting_boms_for_finished_goods, +) class OverlapError(frappe.ValidationError): @@ -64,14 +66,13 @@ class JobCard(Document): from erpnext.manufacturing.doctype.job_card_scheduled_time.job_card_scheduled_time import ( JobCardScheduledTime, ) - from erpnext.manufacturing.doctype.job_card_scrap_item.job_card_scrap_item import ( - JobCardScrapItem, - ) + from erpnext.manufacturing.doctype.job_card_scrap_item.job_card_scrap_item import JobCardScrapItem from erpnext.manufacturing.doctype.job_card_time_log.job_card_time_log import JobCardTimeLog actual_end_date: DF.Datetime | None actual_start_date: DF.Datetime | None amended_from: DF.Link | None + backflush_from_wip_warehouse: DF.Check barcode: DF.Barcode | None batch_no: DF.Link | None bom_no: DF.Link | None @@ -80,18 +81,22 @@ class JobCard(Document): employee: DF.TableMultiSelect[JobCardTimeLog] expected_end_date: DF.Datetime | None expected_start_date: DF.Datetime | None + finished_good: DF.Link | None for_job_card: DF.Link | None for_operation: DF.Link | None for_quantity: DF.Float hour_rate: DF.Currency is_corrective_job_card: DF.Check + is_paused: DF.Check + is_subcontracted: DF.Check item_name: DF.ReadOnly | None items: DF.Table[JobCardItem] - job_started: DF.Check + manufactured_qty: DF.Float naming_series: DF.Literal["PO-JOB.#####"] operation: DF.Link operation_id: DF.Data | None - operation_row_number: DF.Literal + operation_row_id: DF.Int + operation_row_number: DF.Literal[None] posting_date: DF.Date | None process_loss_qty: DF.Float production_item: DF.Link | None @@ -102,9 +107,12 @@ class JobCard(Document): requested_qty: DF.Float scheduled_time_logs: DF.Table[JobCardScheduledTime] scrap_items: DF.Table[JobCardScrapItem] + semi_fg_bom: DF.Link | None sequence_id: DF.Int serial_and_batch_bundle: DF.Link | None serial_no: DF.SmallText | None + skip_material_transfer: DF.Check + source_warehouse: DF.Link | None started_time: DF.Datetime | None status: DF.Literal[ "Open", @@ -116,12 +124,13 @@ class JobCard(Document): "Completed", ] sub_operations: DF.Table[JobCardOperation] + target_warehouse: DF.Link | None time_logs: DF.Table[JobCardTimeLog] time_required: DF.Float total_completed_qty: DF.Float total_time_in_mins: DF.Float transferred_qty: DF.Float - wip_warehouse: DF.Link + wip_warehouse: DF.Link | None work_order: DF.Link workstation: DF.Link workstation_type: DF.Link | None @@ -141,6 +150,7 @@ class JobCard(Document): def validate(self): self.validate_time_logs() + self.validate_on_hold() self.set_status() self.validate_operation_id() self.validate_sequence_id() @@ -151,6 +161,31 @@ class JobCard(Document): def on_update(self): self.validate_job_card_qty() + def validate_on_hold(self): + if self.is_paused and not self.time_logs: + self.is_paused = 0 + + def set_manufactured_qty(self): + table_name = "Stock Entry" + if self.is_subcontracted: + table_name = "Subcontracting Receipt Item" + + table = frappe.qb.DocType(table_name) + query = frappe.qb.from_(table).where((table.job_card == self.name) & (table.docstatus == 1)) + + if self.is_subcontracted: + query = query.select(Sum(table.qty)) + else: + query = query.select(Sum(table.fg_completed_qty)) + query = query.where(table.purpose == "Manufacture") + + qty = query.run()[0][0] or 0.0 + self.manufactured_qty = flt(qty) + self.db_set("manufactured_qty", self.manufactured_qty) + + self.update_semi_finished_good_details() + self.set_status(update_status=True) + def validate_job_card_qty(self): if not (self.operation_id and self.work_order): return @@ -511,13 +546,14 @@ class JobCard(Document): if self.time_logs and len(self.time_logs) > 0: last_row = self.time_logs[-1] - self.reset_timer_value(args) if last_row and args.get("complete_time"): for row in self.time_logs: if not row.to_time: - row.update( + to_time = get_datetime(args.get("complete_time")) + row.db_set( { - "to_time": get_datetime(args.get("complete_time")), + "to_time": to_time, + "time_in_mins": time_diff_in_minutes(to_time, row.from_time), "operation": args.get("sub_operation"), "completed_qty": (args.get("completed_qty") if last_row.idx == row.idx else 0.0), } @@ -538,35 +574,17 @@ class JobCard(Document): else: self.add_start_time_log(new_args) - if not self.employee and employees: - self.set_employees(employees) - - if self.status == "On Hold": - self.current_time = time_diff_in_seconds(last_row.to_time, last_row.from_time) - - self.save() - def add_start_time_log(self, args): - self.append("time_logs", args) + if args.from_time and args.to_time: + args.time_in_mins = time_diff_in_minutes(args.to_time, args.from_time) + + row = self.append("time_logs", args) + row.db_update() def set_employees(self, employees): for name in employees: self.append("employee", {"employee": name.get("employee"), "completed_qty": 0.0}) - - def reset_timer_value(self, args): - self.started_time = None - - if args.get("status") in ["Work In Progress", "Complete"]: - self.current_time = 0.0 - - if args.get("status") == "Work In Progress": - self.started_time = get_datetime(args.get("start_time")) - - if args.get("status") == "Resume Job": - args["status"] = "Work In Progress" - - if args.get("status"): - self.status = args.get("status") + self.save() def update_sub_operation_status(self): if not (self.sub_operations and self.time_logs): @@ -628,23 +646,25 @@ class JobCard(Document): return doc = frappe.get_doc("Work Order", self.get("work_order")) - if doc.transfer_material_against == "Work Order" or doc.skip_transfer: + if not doc.track_semi_finished_goods and ( + doc.transfer_material_against == "Work Order" or doc.skip_transfer + ): return for d in doc.required_items: - if not d.operation: + if not d.operation and not d.operation_row_id: frappe.throw( _("Row {0} : Operation is required against the raw material item {1}").format( d.idx, d.item_code ) ) - if self.get("operation") == d.operation: + if self.get("operation") == d.operation or self.operation_row_id == d.operation_row_id: self.append( "items", { "item_code": d.item_code, - "source_warehouse": d.source_warehouse, + "source_warehouse": self.source_warehouse or d.source_warehouse, "uom": frappe.db.get_value("Item", d.item_code, "stock_uom"), "item_name": d.item_name, "description": d.description, @@ -669,7 +689,7 @@ class JobCard(Document): self.set_transferred_qty() def validate_transfer_qty(self): - if self.items and self.transferred_qty < self.for_quantity: + if not self.finished_good and self.items and self.transferred_qty < self.for_quantity: frappe.throw( _( "Materials needs to be transferred to the work in progress warehouse for the job card {0}" @@ -677,6 +697,9 @@ class JobCard(Document): ) def validate_job_card(self): + if self.finished_good: + return + if self.work_order and frappe.get_cached_value("Work Order", self.work_order, "status") == "Stopped": frappe.throw( _("Transaction not allowed against stopped Work Order {0}").format( @@ -745,6 +768,9 @@ class JobCard(Document): ) def update_work_order(self): + if self.finished_good: + return + if not self.work_order: return @@ -756,7 +782,6 @@ class JobCard(Document): return for_quantity, time_in_mins, process_loss_qty = 0, 0, 0 - _from_time_list, _to_time_list = [], [] data = self.get_current_operation_data() if data and len(data) > 0: @@ -773,6 +798,20 @@ class JobCard(Document): self.validate_produced_quantity(for_quantity, process_loss_qty, wo) self.update_work_order_data(for_quantity, process_loss_qty, time_in_mins, wo) + def update_semi_finished_good_details(self): + if self.operation_id: + frappe.db.set_value( + "Work Order Operation", self.operation_id, "completed_qty", self.manufactured_qty + ) + if ( + self.finished_good + and frappe.get_cached_value("Work Order", self.work_order, "production_item") + == self.finished_good + ): + _wo_doc = frappe.get_doc("Work Order", self.work_order) + _wo_doc.db_set("produced_qty", self.manufactured_qty) + _wo_doc.db_set("status", _wo_doc.get_status()) + def update_corrective_in_work_order(self, wo): wo.corrective_operation_cost = 0.0 for row in frappe.get_all( @@ -913,64 +952,71 @@ class JobCard(Document): frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", flt(transferred_qty)) def set_transferred_qty(self, update_status=False): - "Set total FG Qty in Job Card for which RM was transferred." - if not self.items: - self.transferred_qty = self.for_quantity if self.docstatus == 1 else 0 + from frappe.query_builder.functions import Sum - doc = frappe.get_doc("Work Order", self.get("work_order")) - if doc.transfer_material_against == "Work Order" or doc.skip_transfer: - return + stock_entry = frappe.qb.DocType("Stock Entry") - if self.items: - # sum of 'For Quantity' of Stock Entries against JC - self.transferred_qty = ( - frappe.db.get_value( - "Stock Entry", - { - "job_card": self.name, - "work_order": self.work_order, - "docstatus": 1, - "purpose": "Material Transfer for Manufacture", - }, - "sum(fg_completed_qty)", - ) - or 0 + query = ( + frappe.qb.from_(stock_entry) + .select(Sum(stock_entry.fg_completed_qty)) + .where( + (stock_entry.job_card == self.name) + & (stock_entry.docstatus == 1) + & (stock_entry.purpose == "Material Transfer for Manufacture") ) + .groupby(stock_entry.job_card) + ) - self.db_set("transferred_qty", self.transferred_qty) - + query = query.run() qty = 0 - if self.work_order: - doc = frappe.get_doc("Work Order", self.work_order) - if doc.transfer_material_against == "Job Card" and not doc.skip_transfer: - completed = True - for d in doc.operations: - if d.status != "Completed": - completed = False - break - if completed: - job_cards = frappe.get_all( - "Job Card", - filters={"work_order": self.work_order, "docstatus": ("!=", 2)}, - fields="sum(transferred_qty) as qty", - group_by="operation_id", - ) + if query and query[0][0]: + qty = flt(query[0][0]) - if job_cards: - qty = min(d.qty for d in job_cards) + self.db_set("transferred_qty", qty) + self.set_status(update_status) + + if self.work_order and not frappe.get_cached_value( + "Work Order", self.work_order, "track_semi_finished_goods" + ): + self.set_transferred_qty_in_work_order() + + def set_transferred_qty_in_work_order(self): + doc = frappe.get_doc("Work Order", self.work_order) + + qty = 0.0 + if doc.transfer_material_against == "Job Card" and not doc.skip_transfer: + completed = True + for d in doc.operations: + if d.status != "Completed": + completed = False + break + + if completed: + job_cards = frappe.get_all( + "Job Card", + filters={"work_order": self.work_order, "docstatus": ("!=", 2)}, + fields="sum(transferred_qty) as qty", + group_by="operation_id", + ) + + if job_cards: + qty = min(d.qty for d in job_cards) doc.db_set("material_transferred_for_manufacturing", qty) - self.set_status(update_status) - def set_status(self, update_status=False): - if self.status == "On Hold" and self.docstatus == 0: - return - self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0] + if self.finished_good and self.docstatus == 1: + if self.manufactured_qty >= self.for_quantity: + self.status = "Completed" + elif self.transferred_qty > 0 or self.skip_material_transfer: + self.status = "Work In Progress" - if self.docstatus < 2: + if self.docstatus == 0 and self.time_logs: + self.status = "Work In Progress" + + if not self.finished_good and self.docstatus < 2: if flt(self.for_quantity) <= flt(self.transferred_qty): self.status = "Material Transferred" @@ -980,16 +1026,14 @@ class JobCard(Document): if self.docstatus == 1 and (self.for_quantity <= self.total_completed_qty or not self.items): self.status = "Completed" + if self.is_paused: + self.status = "On Hold" + if update_status: self.db_set("status", self.status) - if self.status in ["Completed", "Work In Progress"]: - status = { - "Completed": "Off", - "Work In Progress": "Production", - }.get(self.status) - - self.update_status_in_workstation(status) + if self.workstation: + self.update_workstation_status() def set_wip_warehouse(self): if not self.wip_warehouse: @@ -1012,7 +1056,30 @@ class JobCard(Document): OperationMismatchError, ) + @frappe.whitelist() + def pause_job(self, **kwargs): + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + self.db_set("is_paused", 1) + self.add_time_logs(to_time=kwargs.end_time, completed_qty=0.0, employees=self.employee) + + @frappe.whitelist() + def resume_job(self, **kwargs): + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + self.db_set("is_paused", 0) + self.add_time_logs( + from_time=kwargs.start_time, + employees=self.employee, + completed_qty=0.0, + ) + def validate_sequence_id(self): + if self.is_new(): + return + if self.is_corrective_job_card: return @@ -1038,6 +1105,14 @@ class JobCard(Document): ) for row in data: + if not row.completed_qty: + frappe.throw( + _("{0}, complete the operation {1} before the operation {2}.").format( + message, bold(row.operation), bold(self.operation) + ), + OperationSequenceError, + ) + if row.status != "Completed" and row.completed_qty < current_operation_qty: frappe.throw( _("{0}, complete the operation {1} before the operation {2}.").format( @@ -1075,16 +1150,173 @@ class JobCard(Document): frappe.db.set_value("Workstation", self.workstation, "status", status) + def add_time_logs(self, **kwargs): + row = None + kwargs = frappe._dict(kwargs) + + update_status = False + for employee in kwargs.employees: + kwargs.employee = employee.get("employee") + if kwargs.from_time and not kwargs.to_time: + row = self.append("time_logs", kwargs) + row.db_update() + self.db_set("status", "Work In Progress") + else: + update_status = True + for row in self.time_logs: + if row.to_time or row.employee != kwargs.employee: + continue + + row.to_time = kwargs.to_time + row.time_in_mins = time_diff_in_minutes(row.to_time, row.from_time) + + if kwargs.employees[-1].get("employee") == row.employee: + row.completed_qty = kwargs.completed_qty + + row.db_update() + + self.set_status(update_status=update_status) + + if not self.employee and kwargs.employees: + self.set_employees(kwargs.employees) + + def update_workstation_status(self): + status_map = { + "Open": "Off", + "Work In Progress": "Production", + "Completed": "Off", + "On Hold": "Idle", + } + + job_cards = frappe.get_all( + "Job Card", + fields=["name", "status"], + filters={"workstation": self.workstation, "docstatus": 0, "status": ("!=", "Completed")}, + order_by="status desc", + ) + + if not job_cards: + frappe.db.set_value("Workstation", self.workstation, "status", "Off") + + for row in job_cards: + frappe.db.set_value("Workstation", self.workstation, "status", status_map.get(row.status)) + return + + @frappe.whitelist() + def start_timer(self, **kwargs): + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + if isinstance(kwargs.employees, str): + kwargs.employees = [{"employee": kwargs.employees}] + + if kwargs.start_time: + self.add_time_logs(from_time=kwargs.start_time, employees=kwargs.employees) + + @frappe.whitelist() + def complete_job_card(self, **kwargs): + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + if kwargs.end_time: + self.add_time_logs(to_time=kwargs.end_time, completed_qty=kwargs.qty, employees=self.employee) + self.save() + + if kwargs.auto_submit: + self.submit() + self.make_stock_entry_for_semi_fg_item(kwargs.auto_submit) + frappe.msgprint( + _("Job Card {0} has been completed").format(get_link_to_form("Job Card", self.name)) + ) + + @frappe.whitelist() + def make_stock_entry_for_semi_fg_item(self, auto_submit=False): + from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry + + ste = ManufactureEntry( + { + "for_quantity": self.for_quantity - self.manufactured_qty, + "job_card": self.name, + "skip_material_transfer": self.skip_material_transfer, + "backflush_from_wip_warehouse": self.backflush_from_wip_warehouse, + "work_order": self.work_order, + "purpose": "Manufacture", + "production_item": self.finished_good, + "company": self.company, + "wip_warehouse": self.wip_warehouse, + "fg_warehouse": self.target_warehouse, + "bom_no": self.semi_fg_bom, + "project": frappe.db.get_value("Work Order", self.work_order, "project"), + } + ) + + ste.make_stock_entry() + ste.stock_entry.flags.ignore_mandatory = True + ste.stock_entry.save() + + if auto_submit: + ste.stock_entry.submit() + + frappe.msgprint( + _("Stock Entry {0} has created").format(get_link_to_form("Stock Entry", ste.stock_entry.name)) + ) + + return ste.stock_entry.as_dict() + @frappe.whitelist() -def make_time_log(args): - if isinstance(args, str): - args = json.loads(args) +def make_subcontracting_po(source_name, target_doc=None): + def set_missing_values(source, target): + _item_details = get_subcontracting_boms_for_finished_goods(source.finished_good) - args = frappe._dict(args) - doc = frappe.get_doc("Job Card", args.job_card_id) + pending_qty = source.for_quantity - source.manufactured_qty + service_item_qty = flt(_item_details.service_item_qty) or 1.0 + fg_item_qty = flt(_item_details.finished_good_qty) or 1.0 + + target.is_subcontracted = 1 + target.supplier_warehouse = source.wip_warehouse + target.append( + "items", + { + "item_code": _item_details.service_item, + "fg_item": source.finished_good, + "uom": _item_details.service_item_uom, + "stock_uom": _item_details.service_item_uom, + "conversion_factor": _item_details.conversion_factor or 1, + "item_name": _item_details.service_item, + "qty": pending_qty * service_item_qty / fg_item_qty, + "fg_item_qty": pending_qty, + "job_card": source.name, + "bom": source.semi_fg_bom, + "warehouse": source.target_warehouse, + }, + ) + + doclist = get_mapped_doc( + "Job Card", + source_name, + { + "Job Card": { + "doctype": "Purchase Order", + }, + }, + target_doc, + set_missing_values, + ) + + return doclist + + +@frappe.whitelist() +def make_time_log(kwargs): + if isinstance(kwargs, str): + kwargs = json.loads(kwargs) + + kwargs = frappe._dict(kwargs) + doc = frappe.get_doc("Job Card", kwargs.job_card_id) doc.validate_sequence_id() - doc.add_time_log(args) + doc.add_time_log(kwargs) + doc.set_status(update_status=True) @frappe.whitelist() @@ -1271,7 +1503,6 @@ def make_corrective_job_card(source_name, operation=None, for_operation=None, ta target.set("sub_operations", []) target.set_sub_operations() target.get_required_items() - target.validate_time_logs() doclist = get_mapped_doc( "Job Card", diff --git a/erpnext/manufacturing/doctype/job_card/job_card_dashboard.py b/erpnext/manufacturing/doctype/job_card/job_card_dashboard.py index 14c1f36d0dc..1718ea54795 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card_dashboard.py +++ b/erpnext/manufacturing/doctype/job_card/job_card_dashboard.py @@ -7,6 +7,7 @@ def get_data(): "non_standard_fieldnames": {"Quality Inspection": "reference_name"}, "transactions": [ {"label": _("Transactions"), "items": ["Material Request", "Stock Entry"]}, + {"label": _("Subcontracting"), "items": ["Purchase Order", "Subcontracting Order"]}, {"label": _("Reference"), "items": ["Quality Inspection"]}, ], } diff --git a/erpnext/manufacturing/doctype/job_card_time_log/job_card_time_log.json b/erpnext/manufacturing/doctype/job_card_time_log/job_card_time_log.json index 884e83e0f26..aed78636aa5 100644 --- a/erpnext/manufacturing/doctype/job_card_time_log/job_card_time_log.json +++ b/erpnext/manufacturing/doctype/job_card_time_log/job_card_time_log.json @@ -15,12 +15,14 @@ ], "fields": [ { + "allow_on_submit": 1, "fieldname": "from_time", "fieldtype": "Datetime", "in_list_view": 1, "label": "From Time" }, { + "allow_on_submit": 1, "fieldname": "to_time", "fieldtype": "Datetime", "in_list_view": 1, @@ -31,6 +33,7 @@ "fieldtype": "Column Break" }, { + "allow_on_submit": 1, "fieldname": "time_in_mins", "fieldtype": "Float", "in_list_view": 1, @@ -38,6 +41,7 @@ "read_only": 1 }, { + "allow_on_submit": 1, "default": "0", "fieldname": "completed_qty", "fieldtype": "Float", @@ -63,7 +67,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-05-21 12:40:55.765860", + "modified": "2024-05-21 12:41:55.765860", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card Time Log", diff --git a/erpnext/manufacturing/doctype/operation/operation.json b/erpnext/manufacturing/doctype/operation/operation.json index ba531164e08..c712a9f140d 100644 --- a/erpnext/manufacturing/doctype/operation/operation.json +++ b/erpnext/manufacturing/doctype/operation/operation.json @@ -40,6 +40,7 @@ { "fieldname": "description", "fieldtype": "Text", + "in_preview": 1, "label": "Description" }, { @@ -104,7 +105,7 @@ "icon": "fa fa-wrench", "index_web_pages_for_search": 1, "links": [], - "modified": "2024-03-27 13:10:06.841479", + "modified": "2024-05-26 17:59:44.338741", "modified_by": "Administrator", "module": "Manufacturing", "name": "Operation", @@ -134,6 +135,7 @@ } ], "quick_entry": 1, + "show_preview_popup": 1, "sort_field": "creation", "sort_order": "DESC", "states": [], diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 1da33f0ad9b..8a806c179e9 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -149,13 +149,7 @@ frappe.ui.form.on("Work Order", { frm.doc.operations && frm.doc.operations.length ) { - const not_completed = frm.doc.operations.filter((d) => { - if (d.status != "Completed") { - return true; - } - }); - - if (not_completed && not_completed.length) { + if (frm.doc.__onload?.show_create_job_card_button) { frm.add_custom_button(__("Create Job Card"), () => { frm.trigger("make_job_card"); }).addClass("btn-primary"); @@ -277,6 +271,18 @@ frappe.ui.form.on("Work Order", { label: __("Sequence Id"), read_only: 1, }, + { + fieldtype: "Check", + fieldname: "skip_material_transfer", + label: __("Skip Material Transfer"), + read_only: 1, + }, + { + fieldtype: "Check", + fieldname: "backflush_from_wip_warehouse", + label: __("Backflush Materials From WIP Warehouse"), + read_only: 1, + }, ], data: operations_data, in_place_edit: true, @@ -317,6 +323,8 @@ frappe.ui.form.on("Work Order", { qty: pending_qty, pending_qty: pending_qty, sequence_id: data.sequence_id, + skip_material_transfer: data.skip_material_transfer, + backflush_from_wip_warehouse: data.backflush_from_wip_warehouse, }); } } @@ -615,22 +623,25 @@ erpnext.work_order = { ); } - const show_start_btn = - frm.doc.skip_transfer || frm.doc.transfer_material_against == "Job Card" ? 0 : 1; + if (!frm.doc.track_semi_finished_goods) { + const show_start_btn = + frm.doc.skip_transfer || frm.doc.transfer_material_against == "Job Card" ? 0 : 1; - if (show_start_btn) { - let pending_to_transfer = frm.doc.required_items.some( - (item) => flt(item.transferred_qty) < flt(item.required_qty) - ); - if (pending_to_transfer && frm.doc.status != "Stopped") { - frm.has_start_btn = true; - frm.add_custom_button(__("Create Pick List"), function () { - erpnext.work_order.create_pick_list(frm); - }); - var start_btn = frm.add_custom_button(__("Start"), function () { - erpnext.work_order.make_se(frm, "Material Transfer for Manufacture"); - }); - start_btn.addClass("btn-primary"); + if (show_start_btn) { + let pending_to_transfer = frm.doc.required_items.some( + (item) => flt(item.transferred_qty) < flt(item.required_qty) + ); + if (pending_to_transfer && frm.doc.status != "Stopped") { + frm.has_start_btn = true; + frm.add_custom_button(__("Create Pick List"), function () { + erpnext.work_order.create_pick_list(frm); + }); + + var start_btn = frm.add_custom_button(__("Start"), function () { + erpnext.work_order.make_se(frm, "Material Transfer for Manufacture"); + }); + start_btn.addClass("btn-primary"); + } } } diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 36b992d0de5..e564af27d95 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -22,6 +22,16 @@ "produced_qty", "process_loss_qty", "project", + "track_semi_finished_goods", + "warehouses", + "source_warehouse", + "wip_warehouse", + "column_break_12", + "fg_warehouse", + "scrap_warehouse", + "operations_section", + "transfer_material_against", + "operations", "section_break_ndpq", "required_items", "work_order_configuration", @@ -32,22 +42,11 @@ "skip_transfer", "from_wip_warehouse", "update_consumed_material_cost_in_project", - "warehouses", - "source_warehouse", - "wip_warehouse", - "column_break_12", - "fg_warehouse", - "scrap_warehouse", "serial_no_and_batch_for_finished_good_section", "has_serial_no", "has_batch_no", "column_break_18", "batch_size", - "required_items_section", - "materials_and_operations_tab", - "operations_section", - "transfer_material_against", - "operations", "time", "planned_start_date", "planned_end_date", @@ -196,7 +195,7 @@ }, { "default": "0", - "depends_on": "eval:doc.docstatus==1 && doc.skip_transfer==0", + "depends_on": "eval:doc.docstatus==1 && doc.skip_transfer==0 && doc.track_semi_finished_goods === 0", "fieldname": "material_transferred_for_manufacturing", "fieldtype": "Float", "label": "Material Transferred for Manufacturing", @@ -248,7 +247,7 @@ "fieldname": "wip_warehouse", "fieldtype": "Link", "label": "Work-in-Progress Warehouse", - "mandatory_depends_on": "eval:!doc.skip_transfer || doc.from_wip_warehouse", + "mandatory_depends_on": "eval:(!doc.skip_transfer || doc.from_wip_warehouse) && !doc.track_semi_finished_goods", "options": "Warehouse" }, { @@ -256,8 +255,7 @@ "fieldname": "fg_warehouse", "fieldtype": "Link", "label": "Target Warehouse", - "options": "Warehouse", - "reqd": 1 + "options": "Warehouse" }, { "fieldname": "column_break_12", @@ -270,15 +268,9 @@ "label": "Scrap Warehouse", "options": "Warehouse" }, - { - "fieldname": "required_items_section", - "fieldtype": "Section Break", - "label": "Required Items" - }, { "fieldname": "required_items", "fieldtype": "Table", - "label": "Required Items", "no_copy": 1, "options": "Work Order Item", "print_hide": 1 @@ -336,7 +328,7 @@ "options": "fa fa-wrench" }, { - "depends_on": "operations", + "depends_on": "eval: doc.operations?.length && doc.track_semi_finished_goods === 0", "fetch_from": "bom_no.transfer_material_against", "fetch_if_empty": 1, "fieldname": "transfer_material_against", @@ -579,13 +571,19 @@ "label": "Configuration" }, { - "fieldname": "materials_and_operations_tab", - "fieldtype": "Tab Break", - "label": "Operations" + "collapsible": 1, + "collapsible_depends_on": "eval:!doc.operations?.length", + "fieldname": "section_break_ndpq", + "fieldtype": "Section Break", + "label": "Required Items" }, { - "fieldname": "section_break_ndpq", - "fieldtype": "Section Break" + "default": "0", + "fetch_from": "bom_no.track_semi_finished_goods", + "fieldname": "track_semi_finished_goods", + "fieldtype": "Check", + "label": "Track Semi Finished Goods", + "read_only": 1 } ], "icon": "fa fa-cogs", @@ -593,7 +591,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2024-03-27 13:11:00.129434", + "modified": "2024-03-27 13:13:00.129434", "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 b5c6cd9330f..b4d1e9d2693 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -143,6 +143,24 @@ class WorkOrder(Document): self.set_onload("material_consumption", ms.material_consumption) self.set_onload("backflush_raw_materials_based_on", ms.backflush_raw_materials_based_on) self.set_onload("overproduction_percentage", ms.overproduction_percentage_for_work_order) + self.set_onload("show_create_job_card_button", self.show_create_job_card_button()) + + def show_create_job_card_button(self): + operation_details = frappe._dict( + frappe.get_all( + "Job Card", + fields=["operation", "for_quantity"], + filters={"docstatus": ("<", 2), "work_order": self.name}, + as_list=1, + ) + ) + + for d in self.operations: + job_card_qty = self.qty - flt(operation_details.get(d.operation)) + if job_card_qty > 0: + return True + + return False def validate(self): self.validate_production_item() @@ -422,15 +440,20 @@ class WorkOrder(Document): self.update_status() production_plan.run_method("update_produced_pending_qty", produced_qty, self.production_plan_item) + def validate_warehouse(self): + if self.track_semi_finished_goods: + return + + if not self.wip_warehouse and not self.skip_transfer: + frappe.throw(_("Work-in-Progress Warehouse is required before Submit")) + if not self.fg_warehouse: + frappe.throw(_("Target Warehouse is required before Submit")) + def before_submit(self): self.create_serial_no_batch_no() def on_submit(self): - if not self.wip_warehouse and not self.skip_transfer: - frappe.throw(_("Work-in-Progress Warehouse is required before Submit")) - if not self.fg_warehouse: - frappe.throw(_("For Warehouse is required before Submit")) - + self.validate_warehouse() if self.production_plan and frappe.db.exists( "Production Plan Item Reference", {"parent": self.production_plan} ): @@ -667,6 +690,9 @@ class WorkOrder(Document): ) def update_planned_qty(self): + if self.track_semi_finished_goods: + return + from erpnext.manufacturing.doctype.production_plan.production_plan import ( get_reserved_qty_for_sub_assembly, ) @@ -811,13 +837,21 @@ class WorkOrder(Document): "description", "workstation", "idx", + "finished_good", + "is_subcontracted", + "wip_warehouse", + "source_warehouse", + "fg_warehouse", "workstation_type", "base_hour_rate as hour_rate", "time_in_mins", "parent as bom", + "bom_no", "batch_size", "sequence_id", "fixed_time", + "skip_material_transfer", + "backflush_from_wip_warehouse", ], order_by="idx", ) @@ -827,6 +861,9 @@ class WorkOrder(Document): d.time_in_mins = flt(d.time_in_mins) * flt(qty) d.status = "Pending" + if self.track_semi_finished_goods and not d.sequence_id: + d.sequence_id = d.idx + return data self.set("operations", []) @@ -1084,6 +1121,7 @@ class WorkOrder(Document): "required_qty": item.qty, "source_warehouse": item.source_warehouse or item.default_warehouse, "include_item_in_manufacturing": item.include_item_in_manufacturing, + "operation_row_id": item.operation_row_id, }, ) @@ -1284,6 +1322,7 @@ def make_work_order(bom_no, item, qty=0, project=None, variant_items=None): item_details = get_item_details(item, project) wo_doc = frappe.new_doc("Work Order") + wo_doc.track_semi_finished_goods = frappe.db.get_value("BOM", bom_no, "track_semi_finished_goods") wo_doc.production_item = item wo_doc.update(item_details) wo_doc.bom_no = bom_no @@ -1450,6 +1489,8 @@ def make_job_card(work_order, operations): work_order = frappe.get_doc("Work Order", work_order) for row in operations: row = frappe._dict(row) + row.update(get_operation_details(row.name, work_order)) + validate_operation_data(row) qty = row.get("qty") while qty > 0: @@ -1458,6 +1499,21 @@ def make_job_card(work_order, operations): create_job_card(work_order, row, auto_create=True) +def get_operation_details(name, work_order): + for row in work_order.operations: + if row.name == name: + return { + "workstation": row.workstation, + "workstation_type": row.workstation_type, + "source_warehouse": row.source_warehouse, + "fg_warehouse": row.fg_warehouse, + "wip_warehouse": row.wip_warehouse, + "finished_good": row.finished_good, + "bom_no": row.get("bom_no"), + "is_subcontracted": row.get("is_subcontracted"), + } + + @frappe.whitelist() def close_work_order(work_order, status): if not frappe.has_permission("Work Order", "write"): @@ -1558,6 +1614,7 @@ def create_job_card(work_order, row, enable_capacity_planning=False, auto_create "workstation_type": row.get("workstation_type"), "operation": row.get("operation"), "workstation": row.get("workstation"), + "operation_row_id": cint(row.idx), "posting_date": nowdate(), "for_quantity": row.job_card_qty or work_order.get("qty", 0), "operation_id": row.get("name"), @@ -1565,13 +1622,22 @@ def create_job_card(work_order, row, enable_capacity_planning=False, auto_create "project": work_order.project, "company": work_order.company, "sequence_id": row.get("sequence_id"), - "wip_warehouse": work_order.wip_warehouse, "hour_rate": row.get("hour_rate"), "serial_no": row.get("serial_no"), + "source_warehouse": row.get("source_warehouse"), + "target_warehouse": row.get("fg_warehouse"), + "wip_warehouse": work_order.wip_warehouse or row.get("wip_warehouse"), + "skip_material_transfer": row.get("skip_material_transfer"), + "backflush_from_wip_warehouse": row.get("backflush_from_wip_warehouse"), + "finished_good": row.get("finished_good"), + "semi_fg_bom": row.get("bom_no"), + "is_subcontracted": row.get("is_subcontracted"), } ) - if work_order.transfer_material_against == "Job Card" and not work_order.skip_transfer: + if work_order.track_semi_finished_goods or ( + work_order.transfer_material_against == "Job Card" and not work_order.skip_transfer + ): doc.get_required_items() if auto_create: diff --git a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json index 0d3500a96c1..abb73f0ccc8 100644 --- a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json +++ b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json @@ -8,6 +8,7 @@ "operation", "item_code", "source_warehouse", + "operation_row_id", "column_break_3", "item_name", "description", @@ -138,11 +139,17 @@ "in_list_view": 1, "label": "Returned Qty ", "read_only": 1 + }, + { + "fieldname": "operation_row_id", + "fieldtype": "Int", + "label": "Operation Row Id", + "read_only": 1 } ], "istable": 1, "links": [], - "modified": "2024-03-27 13:11:00.429838", + "modified": "2024-03-27 13:12:00.429838", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order Item", diff --git a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json index 83c27e8c2f7..9146122a858 100644 --- a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json +++ b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json @@ -15,6 +15,16 @@ "workstation_type", "workstation", "sequence_id", + "section_break_insy", + "bom_no", + "finished_good", + "is_subcontracted", + "skip_material_transfer", + "backflush_from_wip_warehouse", + "column_break_vjih", + "source_warehouse", + "wip_warehouse", + "fg_warehouse", "section_break_10", "description", "estimated_time_and_cost", @@ -52,7 +62,6 @@ "columns": 2, "fieldname": "bom", "fieldtype": "Link", - "in_list_view": 1, "label": "BOM", "no_copy": 1, "options": "BOM", @@ -66,11 +75,10 @@ "oldfieldtype": "Text" }, { - "columns": 2, + "columns": 1, "description": "Operation completed for how many finished goods?", "fieldname": "completed_qty", "fieldtype": "Float", - "in_list_view": 1, "label": "Completed Qty", "no_copy": 1 }, @@ -213,16 +221,83 @@ "columns": 2, "fieldname": "process_loss_qty", "fieldtype": "Float", - "in_list_view": 1, "label": "Process Loss Qty", "no_copy": 1, "read_only": 1 + }, + { + "depends_on": "eval:parent.track_semi_finished_goods === 1", + "fieldname": "section_break_insy", + "fieldtype": "Section Break" + }, + { + "fieldname": "bom_no", + "fieldtype": "Link", + "label": "BOM No (For Semi-FG)", + "options": "BOM", + "read_only": 1 + }, + { + "fieldname": "column_break_vjih", + "fieldtype": "Column Break" + }, + { + "fieldname": "finished_good", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Semi FG / FG", + "options": "Item", + "read_only": 1 + }, + { + "columns": 1, + "fieldname": "wip_warehouse", + "fieldtype": "Link", + "label": "WIP WH", + "options": "Warehouse" + }, + { + "columns": 2, + "fieldname": "fg_warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "FG Warehouse", + "options": "Warehouse" + }, + { + "columns": 2, + "fieldname": "source_warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Source Warehouse", + "options": "Warehouse" + }, + { + "default": "0", + "fieldname": "is_subcontracted", + "fieldtype": "Check", + "label": "Is Subcontracted", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "skip_material_transfer", + "fieldtype": "Check", + "label": "Skip Material Transfer", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "backflush_from_wip_warehouse", + "fieldtype": "Check", + "label": "Backflush Materials From WIP Warehouse", + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:11:00.595376", + "modified": "2024-05-26 15:57:17.958543", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order Operation", diff --git a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.py b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.py index 5bd3ab1b21f..fb8b3feb4dd 100644 --- a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.py +++ b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.py @@ -18,11 +18,16 @@ class WorkOrderOperation(Document): actual_operating_cost: DF.Currency actual_operation_time: DF.Float actual_start_time: DF.Datetime | None + backflush_from_wip_warehouse: DF.Check batch_size: DF.Float bom: DF.Link | None + bom_no: DF.Link | None completed_qty: DF.Float description: DF.TextEditor | None + fg_warehouse: DF.Link | None + finished_good: DF.Link | None hour_rate: DF.Float + is_subcontracted: DF.Check operation: DF.Link parent: DF.Data parentfield: DF.Data @@ -32,8 +37,11 @@ class WorkOrderOperation(Document): planned_start_time: DF.Datetime | None process_loss_qty: DF.Float sequence_id: DF.Int + skip_material_transfer: DF.Check + source_warehouse: DF.Link | None status: DF.Literal["Pending", "Work in Progress", "Completed"] time_in_mins: DF.Float + wip_warehouse: DF.Link | None workstation: DF.Link | None workstation_type: DF.Link | None # end: auto-generated types diff --git a/erpnext/manufacturing/doctype/workstation/workstation.js b/erpnext/manufacturing/doctype/workstation/workstation.js index c3bf9ef5c8c..dbcb0113c8f 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.js +++ b/erpnext/manufacturing/doctype/workstation/workstation.js @@ -105,16 +105,118 @@ class WorkstationDashboard { } render_job_cards() { - let template = frappe.render_template("workstation_job_card", { + this.template = frappe.render_template("workstation_job_card", { data: this.job_cards, }); - this.$wrapper.html(template); + this.timer_job_cards = {}; + this.$wrapper.html(this.template); + this.setup_qrcode_fields(); this.prepare_timer(); + this.setup_menu_actions(); this.toggle_job_card(); this.bind_events(); } + setup_qrcode_fields() { + this.start_job_qrcode = frappe.ui.form.make_control({ + df: { + label: __("Start Job"), + fieldtype: "Data", + options: "Barcode", + placeholder: __("Scan Job Card Qrcode"), + }, + parent: this.$wrapper.find(".qrcode-fields"), + render_input: true, + }); + + this.start_job_qrcode.$wrapper.addClass("form-column col-sm-6"); + + this.start_job_qrcode.$input.on("input", (e) => { + clearTimeout(this.start_job_qrcode_search); + this.start_job_qrcode_search = setTimeout(() => { + let job_card = this.start_job_qrcode.get_value(); + if (job_card) { + this.validate_job_card(job_card, "Open", (job_card, qty) => { + this.start_job(job_card); + }); + + this.start_job_qrcode.set_value(""); + } + }, 300); + }); + + this.complete_job_qrcode = frappe.ui.form.make_control({ + df: { + label: __("Complete Job"), + fieldtype: "Data", + options: "Barcode", + placeholder: __("Scan Job Card Qrcode"), + }, + parent: this.$wrapper.find(".qrcode-fields"), + render_input: true, + }); + + this.complete_job_qrcode.$input.on("input", (e) => { + clearTimeout(this.complete_job_qrcode_search); + this.complete_job_qrcode_search = setTimeout(() => { + let job_card = this.complete_job_qrcode.get_value(); + if (job_card) { + this.validate_job_card(job_card, "Work In Progress", (job_card, qty) => { + this.complete_job(job_card, qty); + }); + + this.complete_job_qrcode.set_value(""); + } + }, 300); + }); + + this.complete_job_qrcode.$wrapper.addClass("form-column col-sm-6"); + } + + validate_job_card(job_card, status, callback) { + frappe.call({ + method: "erpnext.manufacturing.doctype.workstation.workstation.validate_job_card", + args: { + job_card: job_card, + status: status, + }, + callback(r) { + callback(job_card, r.message); + }, + }); + } + + setup_menu_actions() { + let me = this; + this.job_cards.forEach((data) => { + me.menu_actions = me.$wrapper.find(`.menu-actions[data-job-card='${data.name}']`); + $(me.menu_actions).find(".btn-start").hide(); + $(me.menu_actions).find(".btn-resume").hide(); + $(me.menu_actions).find(".btn-pause").hide(); + $(me.menu_actions).find(".btn-complete").hide(); + + if ( + data.for_quantity + data.process_loss_qty > data.total_completed_qty && + (data.skip_material_transfer || + data.transferred_qty >= data.for_quantity + data.process_loss_qty || + !data.finished_good) + ) { + if (!data.time_logs?.length) { + $(me.menu_actions).find(".btn-start").show(); + } else if (data.is_paused) { + $(me.menu_actions).find(".btn-resume").show(); + } else if (data.for_quantity - data.manufactured_qty > 0) { + if (!data.is_paused) { + $(me.menu_actions).find(".btn-pause").show(); + } + + $(me.menu_actions).find(".btn-complete").show(); + } + } + }); + } + toggle_job_card() { this.$wrapper.find(".collapse-indicator-job").on("click", (e) => { $(e.currentTarget) @@ -133,106 +235,75 @@ class WorkstationDashboard { } bind_events() { - this.$wrapper.find(".make-material-request").on("click", (e) => { - let job_card = $(e.currentTarget).attr("job-card"); + let me = this; + + this.$wrapper.find(".btn-transfer-materials").on("click", (e) => { + let job_card = $(e.currentTarget).closest("ul").attr("data-job-card"); this.make_material_request(job_card); }); this.$wrapper.find(".btn-start").on("click", (e) => { - let job_card = $(e.currentTarget).attr("job-card"); + let job_card = $(e.currentTarget).closest("ul").attr("data-job-card"); this.start_job(job_card); }); + this.$wrapper.find(".btn-pause").on("click", (e) => { + let job_card = $(e.currentTarget).closest("ul").attr("data-job-card"); + me.update_job_card(job_card, "pause_job", { + end_time: frappe.datetime.now_datetime(), + }); + }); + + this.$wrapper.find(".btn-resume").on("click", (e) => { + let job_card = $(e.currentTarget).closest("ul").attr("data-job-card"); + me.update_job_card(job_card, "resume_job", { + start_time: frappe.datetime.now_datetime(), + }); + }); + this.$wrapper.find(".btn-complete").on("click", (e) => { - let job_card = $(e.currentTarget).attr("job-card"); - let pending_qty = flt($(e.currentTarget).attr("pending-qty")); - this.complete_job(job_card, pending_qty); + let job_card = $(e.currentTarget).closest("ul").attr("data-job-card"); + let for_quantity = $(e.currentTarget).attr("data-qty"); + me.complete_job(job_card, for_quantity); }); } start_job(job_card) { let me = this; - frappe.prompt( - [ - { - fieldtype: "Datetime", - label: __("Start Time"), - fieldname: "start_time", - reqd: 1, - default: frappe.datetime.now_datetime(), - }, - { - label: __("Operator"), - fieldname: "employee", - fieldtype: "Link", - options: "Employee", - }, - ], - (data) => { - this.frm.call({ - method: "start_job", - doc: this.frm.doc, - args: { - job_card: job_card, - from_time: data.start_time, - employee: data.employee, - }, - callback(r) { - if (r.message) { - me.job_cards = [r.message]; - me.prepare_timer(); - me.update_job_card_details(); - me.frm.reload_doc(); - } - }, - }); - }, - __("Enter Value"), - __("Start Job") - ); + + let fields = this.get_fields_for_employee(); + + this.employee_dialog = frappe.prompt(fields, (values) => { + me.update_job_card(job_card, "start_timer", values); + }); + + let default_employee = this.job_cards[0]?.user_employee; + if (default_employee) { + this.employee_dialog.fields_dict.employees.df.data.push({ + employee: default_employee, + }); + this.employee_dialog.fields_dict.employees.grid.refresh(); + } } - complete_job(job_card, qty_to_manufacture) { - let me = this; - let fields = [ - { - fieldtype: "Float", - label: __("Completed Quantity"), - fieldname: "qty", - reqd: 1, - default: flt(qty_to_manufacture || 0), - }, - { - fieldtype: "Datetime", - label: __("End Time"), - fieldname: "end_time", - default: frappe.datetime.now_datetime(), - }, - ]; - + complete_job(job_card, for_quantity) { frappe.prompt( - fields, + { + fieldname: "qty", + label: __("Completed Quantity"), + fieldtype: "Float", + reqd: 1, + default: flt(for_quantity || 0), + }, (data) => { - if (data.qty <= 0) { + if (flt(data.qty) <= 0) { frappe.throw(__("Quantity should be greater than 0")); } - this.frm.call({ - method: "complete_job", - doc: this.frm.doc, - args: { - job_card: job_card, - qty: data.qty, - to_time: data.end_time, - }, - callback: function (r) { - if (r.message) { - me.job_cards = [r.message]; - me.prepare_timer(); - me.update_job_card_details(); - me.frm.reload_doc(); - } - }, + this.update_job_card(job_card, "complete_job_card", { + qty: flt(data.qty), + end_time: frappe.datetime.now_datetime(), + auto_submit: 1, }); }, __("Enter Value"), @@ -240,26 +311,219 @@ class WorkstationDashboard { ); } - make_material_request(job_card) { + get_fields_for_employee() { + let me = this; + + return [ + { + label: __("Employee"), + fieldname: "employee", + fieldtype: "Link", + options: "Employee", + change() { + let employee = this.get_value(); + let employees = me.employee_dialog.fields_dict.employees.df.data; + + if (employee) { + let employee_exists = employees.find((d) => d.employee === employee); + + if (!employee_exists) { + me.employee_dialog.fields_dict.employees.df.data.push({ + employee: employee, + }); + + me.employee_dialog.fields_dict.employees.grid.refresh(); + } + } + }, + }, + { + label: __("Start Time"), + fieldname: "start_time", + fieldtype: "Datetime", + default: frappe.datetime.now_datetime(), + }, + { fieldtype: "Section Break" }, + { + label: __("Employees"), + fieldname: "employees", + fieldtype: "Table", + data: [], + cannot_add_rows: 1, + cannot_delete_rows: 1, + fields: [ + { + label: __("Employee"), + fieldname: "employee", + fieldtype: "Link", + options: "Employee", + in_list_view: 1, + }, + ], + }, + ]; + } + + update_job_card(job_card, method, data) { + let me = this; + frappe.call({ - method: "erpnext.manufacturing.doctype.job_card.job_card.make_material_request", + method: "erpnext.manufacturing.doctype.workstation.workstation.update_job_card", args: { - source_name: job_card, + job_card: job_card, + method: method, + start_time: data.start_time || "", + employees: data.employees || [], + end_time: data.end_time || "", + qty: data.qty || 0, + auto_submit: data.auto_submit || 0, + }, + callback: () => { + $.each(me.timer_job_cards, (index, value) => { + clearInterval(value); + }); + + me.frm.reload_doc(); + }, + }); + } + + make_material_request(job_card) { + let me = this; + frappe.call({ + method: "erpnext.manufacturing.doctype.workstation.workstation.get_raw_materials", + args: { + job_card: job_card, }, callback: (r) => { if (r.message) { - var doc = frappe.model.sync(r.message)[0]; - frappe.set_route("Form", doc.doctype, doc.name); + me.prepare_materials_modal(r.message, job_card, (job_card) => { + frappe.call({ + method: "erpnext.manufacturing.doctype.job_card.job_card.make_stock_entry", + args: { + source_name: job_card, + }, + callback: (r) => { + var doc = frappe.model.sync(r.message); + frappe.set_route("Form", doc[0].doctype, doc[0].name); + }, + }); + }); } }, }); } + prepare_materials_modal(raw_materials, job_card, callback) { + let fields = this.get_raw_material_fields(raw_materials); + + this.materials_dialog = new frappe.ui.Dialog({ + title: "Raw Materials", + fields: fields, + size: "large", + primary_action_label: __("Make Transfer Entry"), + primary_action: () => { + this.materials_dialog.hide(); + callback(job_card); + }, + }); + + raw_materials.forEach((row) => { + this.materials_dialog.fields_dict.items.df.data.push(row); + }); + + this.materials_dialog.fields_dict.items.grid.refresh(); + this.materials_dialog.show(); + } + + get_raw_material_fields(raw_materials) { + return [ + { + label: __("Warehouse"), + fieldname: "warehouse", + fieldtype: "Link", + options: "Warehouse", + read_only: 1, + default: raw_materials[0].warehouse, + }, + { fieldtype: "Column Break" }, + { + label: __("Skip Material Transfer"), + fieldname: "skip_material_transfer", + fieldtype: "Check", + read_only: 1, + default: raw_materials[0].skip_material_transfer, + }, + { fieldtype: "Section Break" }, + { + label: __("Raw Materials"), + fieldname: "items", + fieldtype: "Table", + cannot_add_rows: 1, + cannot_delete_rows: 1, + data: [], + size: "extra-large", + fields: [ + { + label: __("Item Code"), + fieldname: "item_code", + fieldtype: "Link", + options: "Item", + in_list_view: 1, + read_only: 1, + columns: 2, + }, + { + label: __("UOM"), + fieldname: "uom", + fieldtype: "Link", + options: "UOM", + in_list_view: 1, + read_only: 1, + columns: 1, + }, + { + label: __("Reqired Qty"), + fieldname: "required_qty", + fieldtype: "Float", + in_list_view: 1, + read_only: 1, + columns: 2, + }, + { + label: __("Transferred Qty"), + fieldname: "transferred_qty", + fieldtype: "Float", + in_list_view: 1, + read_only: 1, + columns: 2, + }, + { + label: __("Available Qty"), + fieldname: "stock_qty", + fieldtype: "Float", + in_list_view: 1, + read_only: 1, + columns: 2, + }, + { + label: __("Available"), + fieldname: "material_availability_status", + fieldtype: "Check", + in_list_view: 1, + read_only: 1, + columns: 1, + }, + ], + }, + ]; + } + prepare_timer() { this.job_cards.forEach((data) => { if (data.time_logs?.length) { data._current_time = this.get_current_time(data); - if (data.time_logs[cint(data.time_logs.length) - 1].to_time) { + if (data.time_logs[cint(data.time_logs.length) - 1].to_time || data.is_paused) { this.updateStopwatch(data); } else { this.initialiseTimer(data); @@ -283,23 +547,23 @@ class WorkstationDashboard { [data-name='${data.name}']`); $(job_card_selector).find(".job-card-status").text(data.status); - $(job_card_selector).find(".job-card-status").css("backgroundColor", color_map[data.status]); - if (data.status === "Work In Progress") { - $(job_card_selector).find(".btn-start").addClass("hide"); - $(job_card_selector).find(".btn-complete").removeClass("hide"); - } else if (data.status === "Completed") { - $(job_card_selector).find(".btn-start").addClass("hide"); - $(job_card_selector).find(".btn-complete").addClass("hide"); - } + ["blue", "gray", "green", "orange", "yellow"].forEach((color) => { + $(job_card_selector).find(".job-card-status").removeClass(color); + }); + + $(job_card_selector).find(".job-card-status").addClass(data.status_color); + $(job_card_selector).find(".job-card-status").css("backgroundColor", color_map[data.status]); }); } initialiseTimer(data) { - setInterval(() => { + let timeout = setInterval(() => { data._current_time += 1; this.updateStopwatch(data); }, 1000); + + this.timer_job_cards[data.name] = timeout; } updateStopwatch(data) { diff --git a/erpnext/manufacturing/doctype/workstation/workstation.json b/erpnext/manufacturing/doctype/workstation/workstation.json index 97d7216af81..7df7cb727a0 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.json +++ b/erpnext/manufacturing/doctype/workstation/workstation.json @@ -9,6 +9,7 @@ "engine": "InnoDB", "field_order": [ "dashboard_tab", + "section_break_mqqv", "workstation_dashboard", "details_tab", "workstation_name", @@ -246,13 +247,18 @@ "fieldname": "workstation_dashboard", "fieldtype": "HTML", "label": "Workstation Dashboard" + }, + { + "fieldname": "section_break_mqqv", + "fieldtype": "Section Break", + "hide_border": 1 } ], "icon": "icon-wrench", "idx": 1, "image_field": "on_status_image", "links": [], - "modified": "2024-03-27 13:11:00.760717", + "modified": "2024-06-01 14:48:47.341354", "modified_by": "Administrator", "module": "Manufacturing", "name": "Workstation", diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py index 47cb74228be..8d2e1e92e56 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.py +++ b/erpnext/manufacturing/doctype/workstation/workstation.py @@ -55,7 +55,14 @@ class Workstation(Document): hour_rate_electricity: DF.Currency hour_rate_labour: DF.Currency hour_rate_rent: DF.Currency + off_status_image: DF.AttachImage | None + on_status_image: DF.AttachImage | None + parts_per_hour: DF.Float + plant_floor: DF.Link | None production_capacity: DF.Int + status: DF.Literal["Production", "Off", "Idle", "Problem", "Maintenance", "Setup"] + total_working_hours: DF.Float + warehouse: DF.Link | None working_hours: DF.Table[WorkstationWorkingHour] workstation_name: DF.Data workstation_type: DF.Link | None @@ -189,7 +196,7 @@ class Workstation(Document): @frappe.whitelist() -def get_job_cards(workstation): +def get_job_cards(workstation, job_card=None): if frappe.has_permission("Job Card", "read"): jc_data = frappe.get_all( "Job Card", @@ -200,15 +207,22 @@ def get_job_cards(workstation): "operation", "total_completed_qty", "for_quantity", + "process_loss_qty", + "finished_good", "transferred_qty", "status", "expected_start_date", "expected_end_date", "time_required", "wip_warehouse", + "skip_material_transfer", + "backflush_from_wip_warehouse", + "is_paused", + "manufactured_qty", ], filters={ "workstation": workstation, + "is_subcontracted": 0, "docstatus": ("<", 2), "status": ["not in", ["Completed", "Stopped"]], }, @@ -216,64 +230,98 @@ def get_job_cards(workstation): ) job_cards = [row.name for row in jc_data] - raw_materials = get_raw_materials(job_cards) time_logs = get_time_logs(job_cards) allow_excess_transfer = frappe.db.get_single_value( "Manufacturing Settings", "job_card_excess_transfer" ) + user_employee = frappe.db.get_value("Employee", {"user_id": frappe.session.user}, "name") + for row in jc_data: - row.progress_percent = ( - flt(row.total_completed_qty / row.for_quantity * 100, 2) if row.for_quantity else 0 - ) - row.progress_title = _("Total completed quantity: {0}").format(row.total_completed_qty) + item_code = row.finished_good or row.production_item + row.fg_uom = frappe.get_cached_value("Item", item_code, "stock_uom") + row.status_color = get_status_color(row.status) - row.job_card_link = get_link_to_form("Job Card", row.name) + row.job_card_link = f""" + {row.name} + """ + + row.operation_link = f""" + {row.operation} + """ row.work_order_link = get_link_to_form("Work Order", row.work_order) - row.raw_materials = raw_materials.get(row.name, []) row.time_logs = time_logs.get(row.name, []) row.make_material_request = False if row.for_quantity > row.transferred_qty or allow_excess_transfer: row.make_material_request = True + row.user_employee = user_employee + return jc_data def get_status_color(status): color_map = { - "Pending": "var(--bg-blue)", - "In Process": "var(--bg-yellow)", - "Submitted": "var(--bg-blue)", - "Open": "var(--bg-gray)", - "Closed": "var(--bg-green)", - "Work In Progress": "var(--bg-orange)", + "Pending": "blue", + "In Process": "yellow", + "Submitted": "blue", + "Open": "gray", + "Closed": "green", + "Work In Progress": "orange", } - return color_map.get(status, "var(--bg-blue)") + return color_map.get(status, "blue") -def get_raw_materials(job_cards): - raw_materials = {} - - data = frappe.get_all( - "Job Card Item", +@frappe.whitelist() +def get_raw_materials(job_card): + raw_materials = frappe.get_all( + "Job Card", fields=[ - "parent", - "item_code", - "item_group", - "uom", - "item_name", - "source_warehouse", - "required_qty", - "transferred_qty", + "`tabJob Card`.`skip_material_transfer`", + "`tabJob Card`.`backflush_from_wip_warehouse`", + "`tabJob Card`.`wip_warehouse`", + "`tabJob Card Item`.`parent`", + "`tabJob Card Item`.`item_code`", + "`tabJob Card Item`.`item_group`", + "`tabJob Card Item`.`uom`", + "`tabJob Card Item`.`item_name`", + "`tabJob Card Item`.`source_warehouse`", + "`tabJob Card Item`.`required_qty`", + "`tabJob Card Item`.`transferred_qty`", ], - filters={"parent": ["in", job_cards]}, + filters={"name": job_card}, ) - for row in data: - raw_materials.setdefault(row.parent, []).append(row) + if not raw_materials: + return [] + + for row in raw_materials: + warehouse = row.source_warehouse + if row.skip_material_transfer and row.backflush_from_wip_warehouse: + warehouse = row.wip_warehouse + + row.stock_qty = ( + frappe.db.get_value( + "Bin", + { + "item_code": row.item_code, + "warehouse": warehouse, + }, + "actual_qty", + ) + or 0.0 + ) + + row.warehouse = warehouse + + row.material_availability_status = 0 + if row.skip_material_transfer and row.stock_qty >= row.required_qty: + row.material_availability_status = 1 + elif row.transferred_qty >= row.required_qty: + row.material_availability_status = 1 return raw_materials @@ -392,20 +440,57 @@ def get_workstations(**kwargs): data = query.run(as_dict=True) color_map = { - "Production": "var(--green-600)", - "Off": "var(--gray-600)", - "Idle": "var(--gray-600)", - "Problem": "var(--red-600)", - "Maintenance": "var(--yellow-600)", - "Setup": "var(--blue-600)", + "Production": "green", + "Off": "gray", + "Idle": "gray", + "Problem": "red", + "Maintenance": "yellow", + "Setup": "blue", } for d in data: d.workstation_name = get_link_to_form("Workstation", d.name) d.status_image = d.on_status_image - d.background_color = color_map.get(d.status, "var(--red-600)") + d.color = color_map.get(d.status, "red") d.workstation_link = get_url_to_form("Workstation", d.name) if d.status != "Production": d.status_image = d.off_status_image return data + + +@frappe.whitelist() +def update_job_card(job_card, method, **kwargs): + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + if kwargs.get("employees"): + kwargs.employees = frappe.parse_json(kwargs.employees) + + if kwargs.qty and isinstance(kwargs.qty, str): + kwargs.qty = flt(kwargs.qty) + + doc = frappe.get_doc("Job Card", job_card) + doc.run_method(method, **kwargs) + + +@frappe.whitelist() +def validate_job_card(job_card, status): + job_card_details = frappe.db.get_value("Job Card", job_card, ["status", "for_quantity"], as_dict=1) + + current_status = job_card_details.status + if current_status != status: + if status == "Open": + frappe.throw( + _("The job card {0} is in {1} state and you cannot start it again.").format( + job_card, current_status + ) + ) + else: + frappe.throw( + _("The job card {0} is in {1} state and you cannot complete.").format( + job_card, current_status + ) + ) + + return job_card_details.for_quantity diff --git a/erpnext/manufacturing/doctype/workstation/workstation_job_card.html b/erpnext/manufacturing/doctype/workstation/workstation_job_card.html index 97707855db0..847963b7088 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation_job_card.html +++ b/erpnext/manufacturing/doctype/workstation/workstation_job_card.html @@ -1,6 +1,6 @@ -
+
+ +
+ {% $.each(data, (idx, d) => { %}