From ef202d7cd0c0fda494f24fea07f903366e75c7c8 Mon Sep 17 00:00:00 2001 From: Khushi Rawat <142375893+khushi8112@users.noreply.github.com> Date: Wed, 25 Jun 2025 11:43:18 +0530 Subject: [PATCH 01/35] fix: update salvage value after value adjustment (backport #48228) (#48248) fix: update salvage value after value adjustment --- .../asset_value_adjustment.py | 8 ++++ .../test_asset_value_adjustment.py | 37 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py index 7f5d480538b..1877f173168 100644 --- a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py +++ b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py @@ -192,6 +192,10 @@ class AssetValueAdjustment(Document): if asset.calculate_depreciation: for row in asset.finance_books: if cstr(row.finance_book) == cstr(self.finance_book): + salvage_value_adjustment = ( + self.get_adjusted_salvage_value_amount(row, difference_amount) or 0 + ) + row.expected_value_after_useful_life += salvage_value_adjustment row.value_after_depreciation += flt(difference_amount) row.db_update() @@ -208,6 +212,10 @@ class AssetValueAdjustment(Document): asset.save() asset.set_status() + def get_adjusted_salvage_value_amount(self, row, difference_amount): + if row.expected_value_after_useful_life: + salvage_value_adjustment = (difference_amount * row.salvage_value_percentage) / 100 + return flt(salvage_value_adjustment if self.docstatus == 1 else -1 * salvage_value_adjustment) @frappe.whitelist() def get_value_of_accounting_dimensions(asset_name): diff --git a/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py b/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py index 82fa3ba17e9..dfde9a7d885 100644 --- a/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py +++ b/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py @@ -292,6 +292,43 @@ class TestAssetValueAdjustment(unittest.TestCase): asset_doc.load_from_db() self.assertEqual(asset_doc.value_after_depreciation, 50000.0) + def test_expected_value_after_useful_life(self): + pr = make_purchase_receipt(item_code="Macbook Pro", qty=1, rate=100000.0, location="Test Location") + + asset_name = frappe.db.get_value("Asset", {"purchase_receipt": pr.name}, "name") + asset_doc = frappe.get_doc("Asset", asset_name) + asset_doc.calculate_depreciation = 1 + asset_doc.available_for_use_date = "2023-01-15" + asset_doc.purchase_date = "2023-01-15" + + asset_doc.append( + "finance_books", + { + "expected_value_after_useful_life": 5000, + "salvage_value_percentage": 5, + "depreciation_method": "Straight Line", + "total_number_of_depreciations": 12, + "frequency_of_depreciation": 1, + "depreciation_start_date": "2023-01-31", + }, + ) + asset_doc.submit() + self.assertEqual(asset_doc.finance_books[0].expected_value_after_useful_life, 5000.0) + + current_asset_value = get_asset_value_after_depreciation(asset_doc.name) + adj_doc = make_asset_value_adjustment( + asset=asset_doc.name, + current_asset_value=current_asset_value, + new_asset_value=40000, + date="2023-08-21", + ) + adj_doc.submit() + difference_amount = adj_doc.new_asset_value - adj_doc.current_asset_value + self.assertEqual(difference_amount, -60000) + asset_doc.load_from_db() + self.assertEqual(asset_doc.finance_books[0].value_after_depreciation, 40000.0) + self.assertEqual(asset_doc.finance_books[0].expected_value_after_useful_life, 2000.0) + def make_asset_value_adjustment(**args): args = frappe._dict(args) From 1f7eccdac5b9b71fe49f9524f3c1a0de1331c3f8 Mon Sep 17 00:00:00 2001 From: Khushi Rawat <142375893+khushi8112@users.noreply.github.com> Date: Wed, 4 Jun 2025 15:33:43 +0530 Subject: [PATCH 02/35] feat: added Transfer and Issue option in purpose (cherry picked from commit f5e5146021d0b8348142e010fdb7715f7f45d386) # Conflicts: # erpnext/assets/doctype/asset_movement/asset_movement.json --- .../doctype/asset_movement/asset_movement.json | 14 ++++++++++++-- .../doctype/asset_movement/asset_movement.py | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/erpnext/assets/doctype/asset_movement/asset_movement.json b/erpnext/assets/doctype/asset_movement/asset_movement.json index 5382f9e75f2..1bd1ad83ead 100644 --- a/erpnext/assets/doctype/asset_movement/asset_movement.json +++ b/erpnext/assets/doctype/asset_movement/asset_movement.json @@ -33,7 +33,7 @@ "fieldname": "purpose", "fieldtype": "Select", "label": "Purpose", - "options": "\nIssue\nReceipt\nTransfer", + "options": "\nIssue\nReceipt\nTransfer\nTransfer and Issue", "reqd": 1 }, { @@ -93,10 +93,15 @@ "fieldtype": "Column Break" } ], + "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], +<<<<<<< HEAD "modified": "2023-06-28 16:54:26.571083", +======= + "modified": "2025-05-30 17:01:55.864353", +>>>>>>> f5e5146021 (feat: added Transfer and Issue option in purpose) "modified_by": "Administrator", "module": "Assets", "name": "Asset Movement", @@ -149,7 +154,12 @@ "write": 1 } ], +<<<<<<< HEAD "sort_field": "modified", +======= + "row_format": "Dynamic", + "sort_field": "creation", +>>>>>>> f5e5146021 (feat: added Transfer and Issue option in purpose) "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/erpnext/assets/doctype/asset_movement/asset_movement.py b/erpnext/assets/doctype/asset_movement/asset_movement.py index d51971c04e8..0c986d086ce 100644 --- a/erpnext/assets/doctype/asset_movement/asset_movement.py +++ b/erpnext/assets/doctype/asset_movement/asset_movement.py @@ -24,7 +24,7 @@ class AssetMovement(Document): amended_from: DF.Link | None assets: DF.Table[AssetMovementItem] company: DF.Link - purpose: DF.Literal["", "Issue", "Receipt", "Transfer"] + purpose: DF.Literal["", "Issue", "Receipt", "Transfer", "Transfer and Issue"] reference_doctype: DF.Link | None reference_name: DF.DynamicLink | None transaction_date: DF.Datetime From 0c07dfadfe300befc5bfb0a9cffa1da1cef210c9 Mon Sep 17 00:00:00 2001 From: Khushi Rawat <142375893+khushi8112@users.noreply.github.com> Date: Wed, 4 Jun 2025 17:34:54 +0530 Subject: [PATCH 03/35] fix: saperated validations for each purpose of validation (cherry picked from commit 07d1a0ed9c251054d7c129bb082071656ed9a095) # Conflicts: # erpnext/assets/doctype/asset_movement/asset_movement.py --- .../doctype/asset_movement/asset_movement.js | 11 ++- .../doctype/asset_movement/asset_movement.py | 91 ++++++++++++------- 2 files changed, 67 insertions(+), 35 deletions(-) diff --git a/erpnext/assets/doctype/asset_movement/asset_movement.js b/erpnext/assets/doctype/asset_movement/asset_movement.js index e445c90f308..f56c1e31f27 100644 --- a/erpnext/assets/doctype/asset_movement/asset_movement.js +++ b/erpnext/assets/doctype/asset_movement/asset_movement.js @@ -62,8 +62,8 @@ frappe.ui.form.on("Asset Movement", { fieldnames_to_be_altered = { target_location: { read_only: 0, reqd: 1 }, source_location: { read_only: 1, reqd: 0 }, - from_employee: { read_only: 0, reqd: 0 }, - to_employee: { read_only: 1, reqd: 0 }, + from_employee: { read_only: 1, reqd: 0 }, + to_employee: { read_only: 0, reqd: 0 }, }; } else if (frm.doc.purpose === "Issue") { fieldnames_to_be_altered = { @@ -72,6 +72,13 @@ frappe.ui.form.on("Asset Movement", { from_employee: { read_only: 1, reqd: 0 }, to_employee: { read_only: 0, reqd: 1 }, }; + } else if (frm.doc.purpose === "Transfer and Issue") { + fieldnames_to_be_altered = { + target_location: { read_only: 0, reqd: 1 }, + source_location: { read_only: 0, reqd: 1 }, + from_employee: { read_only: 0, reqd: 1 }, + to_employee: { read_only: 0, reqd: 1 }, + }; } if (fieldnames_to_be_altered) { Object.keys(fieldnames_to_be_altered).forEach((fieldname) => { diff --git a/erpnext/assets/doctype/asset_movement/asset_movement.py b/erpnext/assets/doctype/asset_movement/asset_movement.py index 0c986d086ce..9c5320d6c83 100644 --- a/erpnext/assets/doctype/asset_movement/asset_movement.py +++ b/erpnext/assets/doctype/asset_movement/asset_movement.py @@ -31,47 +31,42 @@ class AssetMovement(Document): # end: auto-generated types def validate(self): - self.validate_asset() - self.validate_location() - self.validate_employee() - - def validate_asset(self): for d in self.assets: - status, company = frappe.db.get_value("Asset", d.asset, ["status", "company"]) - if self.purpose == "Transfer" and status in ("Draft", "Scrapped", "Sold"): - frappe.throw(_("{0} asset cannot be transferred").format(status)) + self.validate_asset(d) + self.validate_movement(d) - if company != self.company: - frappe.throw(_("Asset {0} does not belong to company {1}").format(d.asset, self.company)) + def validate_asset(self, d): + status, company = frappe.db.get_value("Asset", d.asset, ["status", "company"]) + if self.purpose == "Transfer" and status in ("Draft", "Scrapped", "Sold"): + frappe.throw(_("{0} asset cannot be transferred").format(status)) - if not (d.source_location or d.target_location or d.from_employee or d.to_employee): - frappe.throw(_("Either location or employee must be required")) + if company != self.company: + frappe.throw(_("Asset {0} does not belong to company {1}").format(d.asset, self.company)) - def validate_location(self): - for d in self.assets: - if self.purpose in ["Transfer", "Issue"]: - current_location = frappe.db.get_value("Asset", d.asset, "location") - if d.source_location: - if current_location != d.source_location: - frappe.throw( - _("Asset {0} does not belongs to the location {1}").format( - d.asset, d.source_location - ) - ) - else: - d.source_location = current_location + def validate_movement(self, d): + if self.purpose == "Transfer and Issue": + self.validate_location_and_employee(d) + elif self.purpose in ["Receipt", "Transfer"]: + self.validate_location(d) + else: + self.validate_employee(d) - if self.purpose == "Issue": - if d.target_location: + def validate_location_and_employee(self, d): + self.validate_location(d) + self.validate_employee(d) + + def validate_location(self, d): + if self.purpose in ["Transfer", "Transfer and Issue"]: + current_location = frappe.db.get_value("Asset", d.asset, "location") + if d.source_location: + if current_location != d.source_location: frappe.throw( - _( - "Issuing cannot be done to a location. Please enter employee to issue the Asset {0} to" - ).format(d.asset), - title=_("Incorrect Movement Purpose"), + _("Asset {0} does not belongs to the location {1}").format(d.asset, d.source_location) ) - if not d.to_employee: - frappe.throw(_("Employee is required while issuing Asset {0}").format(d.asset)) + else: + d.source_location = current_location +<<<<<<< HEAD if self.purpose == "Transfer": if d.to_employee: frappe.throw( @@ -117,12 +112,42 @@ class AssetMovement(Document): frappe.throw( _("Asset {0} does not belongs to the custodian {1}").format(d.asset, d.from_employee) ) +======= + if not d.target_location: + frappe.throw(_("Target Location is required for transferring Asset {0}").format(d.asset)) + if d.source_location == d.target_location: + frappe.throw(_("Source and Target Location cannot be same")) +>>>>>>> 07d1a0ed9c (fix: saperated validations for each purpose of validation) + if self.purpose == "Receipt": + if not d.target_location: + frappe.throw(_("Target Location is required while receiving Asset {0}").format(d.asset)) if d.to_employee and frappe.db.get_value("Employee", d.to_employee, "company") != self.company: frappe.throw( _("Employee {0} does not belongs to the company {1}").format(d.to_employee, self.company) ) + def validate_employee(self, d): + if self.purpose == "Tranfer and Issue": + if not d.from_employee: + frappe.throw(_("From Employee is required while issuing Asset {0}").format(d.asset)) + + if d.from_employee: + current_custodian = frappe.db.get_value("Asset", d.asset, "custodian") + + if current_custodian != d.from_employee: + frappe.throw( + _("Asset {0} does not belongs to the custodian {1}").format(d.asset, d.from_employee) + ) + + if not d.to_employee: + frappe.throw(_("Employee is required while issuing Asset {0}").format(d.asset)) + + if d.to_employee and frappe.db.get_value("Employee", d.to_employee, "company") != self.company: + frappe.throw( + _("Employee {0} does not belongs to the company {1}").format(d.to_employee, self.company) + ) + def on_submit(self): self.set_latest_location_and_custodian_in_asset() From 1c3ac9c1fd03f93b4ac1890ac97ed3194d7e33b5 Mon Sep 17 00:00:00 2001 From: Khushi Rawat <142375893+khushi8112@users.noreply.github.com> Date: Wed, 4 Jun 2025 19:20:43 +0530 Subject: [PATCH 04/35] refactor: split set_latest_location_and_custodian_in_asset into smaller functions (cherry picked from commit 7e52cb2856cb316ed3daaeefa6258ee7f1cd8f4c) --- .../doctype/asset_movement/asset_movement.py | 100 ++++++++++-------- 1 file changed, 55 insertions(+), 45 deletions(-) diff --git a/erpnext/assets/doctype/asset_movement/asset_movement.py b/erpnext/assets/doctype/asset_movement/asset_movement.py index 9c5320d6c83..245ba22ab14 100644 --- a/erpnext/assets/doctype/asset_movement/asset_movement.py +++ b/erpnext/assets/doctype/asset_movement/asset_movement.py @@ -155,53 +155,63 @@ class AssetMovement(Document): self.set_latest_location_and_custodian_in_asset() def set_latest_location_and_custodian_in_asset(self): + for d in self.assets: + current_location, current_employee = self.get_latest_location_and_custodian(d.asset) + self.update_asset_location_and_custodian(d.asset, current_location, current_employee) + self.log_asset_activity(d.asset, current_location, current_employee) + + def get_latest_location_and_custodian(self, asset): current_location, current_employee = "", "" cond = "1=1" - for d in self.assets: - args = {"asset": d.asset, "company": self.company} + # latest entry corresponds to current document's location, employee when transaction date > previous dates + # In case of cancellation it corresponds to previous latest document's location, employee + args = {"asset": asset, "company": self.company} + latest_movement_entry = frappe.db.sql( + f""" + SELECT asm_item.target_location, asm_item.to_employee + FROM `tabAsset Movement Item` asm_item + JOIN `tabAsset Movement` asm ON asm_item.parent = asm.name + WHERE + asm_item.asset = %(asset)s AND + asm.company = %(company)s AND + asm.docstatus = 1 AND {cond} + ORDER BY asm.transaction_date DESC + LIMIT 1 + """, + args, + ) - # latest entry corresponds to current document's location, employee when transaction date > previous dates - # In case of cancellation it corresponds to previous latest document's location, employee - latest_movement_entry = frappe.db.sql( - f""" - SELECT asm_item.target_location, asm_item.to_employee - FROM `tabAsset Movement Item` asm_item, `tabAsset Movement` asm - WHERE - asm_item.parent=asm.name and - asm_item.asset=%(asset)s and - asm.company=%(company)s and - asm.docstatus=1 and {cond} - ORDER BY - asm.transaction_date desc limit 1 - """, - args, + if latest_movement_entry: + current_location = latest_movement_entry[0][0] + current_employee = latest_movement_entry[0][1] + + return current_location, current_employee + + def update_asset_location_and_custodian(self, asset_id, location, employee): + asset = frappe.get_doc("Asset", asset_id) + + if employee and employee != asset.custodian: + frappe.db.set_value("Asset", asset_id, "custodian", employee) + if location and location != asset.location: + frappe.db.set_value("Asset", asset_id, "location", location) + + def log_asset_activity(self, asset_id, location, employee): + if location and employee: + add_asset_activity( + asset_id, + _("Asset received at Location {0} and issued to Employee {1}").format( + get_link_to_form("Location", location), + get_link_to_form("Employee", employee), + ), + ) + elif location: + add_asset_activity( + asset_id, + _("Asset transferred to Location {0}").format(get_link_to_form("Location", location)), + ) + elif employee: + add_asset_activity( + asset_id, + _("Asset issued to Employee {0}").format(get_link_to_form("Employee", employee)), ) - - if latest_movement_entry: - current_location = latest_movement_entry[0][0] - current_employee = latest_movement_entry[0][1] - - frappe.db.set_value("Asset", d.asset, "location", current_location, update_modified=False) - frappe.db.set_value("Asset", d.asset, "custodian", current_employee, update_modified=False) - - if current_location and current_employee: - add_asset_activity( - d.asset, - _("Asset received at Location {0} and issued to Employee {1}").format( - get_link_to_form("Location", current_location), - get_link_to_form("Employee", current_employee), - ), - ) - elif current_location: - add_asset_activity( - d.asset, - _("Asset transferred to Location {0}").format( - get_link_to_form("Location", current_location) - ), - ) - elif current_employee: - add_asset_activity( - d.asset, - _("Asset issued to Employee {0}").format(get_link_to_form("Employee", current_employee)), - ) From bde63ed0e50786725759502010b39eb706ab3ed5 Mon Sep 17 00:00:00 2001 From: Khushi Rawat <142375893+khushi8112@users.noreply.github.com> Date: Thu, 5 Jun 2025 13:05:14 +0530 Subject: [PATCH 05/35] fix: failing test case (cherry picked from commit 7d3bec8ef8e1225027a7a4c9d850481bc82dae46) --- erpnext/assets/doctype/asset_movement/test_asset_movement.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/assets/doctype/asset_movement/test_asset_movement.py b/erpnext/assets/doctype/asset_movement/test_asset_movement.py index 52590d2ba86..07879acd1f0 100644 --- a/erpnext/assets/doctype/asset_movement/test_asset_movement.py +++ b/erpnext/assets/doctype/asset_movement/test_asset_movement.py @@ -88,7 +88,7 @@ class TestAssetMovement(unittest.TestCase): ) # after issuing, asset should belong to an employee not at a location - self.assertEqual(frappe.db.get_value("Asset", asset.name, "location"), None) + self.assertEqual(frappe.db.get_value("Asset", asset.name, "location"), "Test Location 2") self.assertEqual(frappe.db.get_value("Asset", asset.name, "custodian"), employee) create_asset_movement( From df938f24d4bee1f8e0b51a69d797a34e5bc18e4c Mon Sep 17 00:00:00 2001 From: Khushi Rawat <142375893+khushi8112@users.noreply.github.com> Date: Wed, 25 Jun 2025 14:47:00 +0530 Subject: [PATCH 06/35] chore: resolved conflicts --- .../doctype/asset_movement/asset_movement.py | 49 ------------------- 1 file changed, 49 deletions(-) diff --git a/erpnext/assets/doctype/asset_movement/asset_movement.py b/erpnext/assets/doctype/asset_movement/asset_movement.py index 245ba22ab14..db4e7510670 100644 --- a/erpnext/assets/doctype/asset_movement/asset_movement.py +++ b/erpnext/assets/doctype/asset_movement/asset_movement.py @@ -65,59 +65,10 @@ class AssetMovement(Document): ) else: d.source_location = current_location - -<<<<<<< HEAD - if self.purpose == "Transfer": - if d.to_employee: - frappe.throw( - _( - "Transferring cannot be done to an Employee. Please enter location where Asset {0} has to be transferred" - ).format(d.asset), - title=_("Incorrect Movement Purpose"), - ) - if not d.target_location: - frappe.throw( - _("Target Location is required while transferring Asset {0}").format(d.asset) - ) - if d.source_location == d.target_location: - frappe.throw(_("Source and Target Location cannot be same")) - - if self.purpose == "Receipt": - if not (d.source_location) and not (d.target_location or d.to_employee): - frappe.throw( - _("Target Location or To Employee is required while receiving Asset {0}").format( - d.asset - ) - ) - elif d.source_location: - if d.from_employee and not d.target_location: - frappe.throw( - _( - "Target Location is required while receiving Asset {0} from an employee" - ).format(d.asset) - ) - elif d.to_employee and d.target_location: - frappe.throw( - _( - "Asset {0} cannot be received at a location and given to an employee in a single movement" - ).format(d.asset) - ) - - def validate_employee(self): - for d in self.assets: - if d.from_employee: - current_custodian = frappe.db.get_value("Asset", d.asset, "custodian") - - if current_custodian != d.from_employee: - frappe.throw( - _("Asset {0} does not belongs to the custodian {1}").format(d.asset, d.from_employee) - ) -======= if not d.target_location: frappe.throw(_("Target Location is required for transferring Asset {0}").format(d.asset)) if d.source_location == d.target_location: frappe.throw(_("Source and Target Location cannot be same")) ->>>>>>> 07d1a0ed9c (fix: saperated validations for each purpose of validation) if self.purpose == "Receipt": if not d.target_location: From ddbdcbb026acbfc6cbbdbd0ce4506a4d809b8155 Mon Sep 17 00:00:00 2001 From: Khushi Rawat <142375893+khushi8112@users.noreply.github.com> Date: Wed, 25 Jun 2025 14:47:47 +0530 Subject: [PATCH 07/35] chore: resolved conflicts --- erpnext/assets/doctype/asset_movement/asset_movement.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/erpnext/assets/doctype/asset_movement/asset_movement.json b/erpnext/assets/doctype/asset_movement/asset_movement.json index 1bd1ad83ead..a656acf1265 100644 --- a/erpnext/assets/doctype/asset_movement/asset_movement.json +++ b/erpnext/assets/doctype/asset_movement/asset_movement.json @@ -97,11 +97,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], -<<<<<<< HEAD - "modified": "2023-06-28 16:54:26.571083", -======= "modified": "2025-05-30 17:01:55.864353", ->>>>>>> f5e5146021 (feat: added Transfer and Issue option in purpose) "modified_by": "Administrator", "module": "Assets", "name": "Asset Movement", @@ -154,12 +150,8 @@ "write": 1 } ], -<<<<<<< HEAD - "sort_field": "modified", -======= "row_format": "Dynamic", "sort_field": "creation", ->>>>>>> f5e5146021 (feat: added Transfer and Issue option in purpose) "sort_order": "DESC", "states": [] } From dc642fbc410ff3777fab61f2c8000b44aa570ce2 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 25 Jun 2025 15:04:17 +0530 Subject: [PATCH 08/35] chore: linters check --- .../doctype/asset_value_adjustment/asset_value_adjustment.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py index 1877f173168..2c1d7d9fcab 100644 --- a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py +++ b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py @@ -217,6 +217,7 @@ class AssetValueAdjustment(Document): salvage_value_adjustment = (difference_amount * row.salvage_value_percentage) / 100 return flt(salvage_value_adjustment if self.docstatus == 1 else -1 * salvage_value_adjustment) + @frappe.whitelist() def get_value_of_accounting_dimensions(asset_name): dimension_fields = [*frappe.get_list("Accounting Dimension", pluck="fieldname"), "cost_center"] From 5463a8b6cfe5f467b17c1a661c4426c29a36b67e Mon Sep 17 00:00:00 2001 From: 0xD0M1M0 <76812428+0xD0M1M0@users.noreply.github.com> Date: Wed, 25 Jun 2025 23:25:32 +0200 Subject: [PATCH 09/35] fix: customer_group import from lead to customer (#48266) In case customization happens and the lead has the field "customer_group", the get_mapped_doc function would fail and be overwritten by the default value. (cherry picked from commit 1b18105bce90b770264e741f8951708eb53b35e1) --- erpnext/crm/doctype/lead/lead.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index 44a919d8927..cf9a7f02f9d 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -323,7 +323,8 @@ def _make_customer(source_name, target_doc=None, ignore_permissions=False): target.customer_type = "Individual" target.customer_name = source.lead_name - target.customer_group = frappe.db.get_default("Customer Group") + if not target.customer_group: + target.customer_group = frappe.db.get_default("Customer Group") doclist = get_mapped_doc( "Lead", From 8f475056048aac4ad5e694b3229d39caac22619a Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 26 Jun 2025 18:25:53 +0530 Subject: [PATCH 10/35] fix: better integration of Pick List with Delivery Note (backport #47831) (#48158) * fix: better integration of Pick List with Delivery Note (#47831) Co-authored-by: priyanshshah2442 (cherry picked from commit 527cfe9c7d6bd7f4bb38f1299d29b7d045365435) # Conflicts: # erpnext/patches.txt # erpnext/stock/doctype/delivery_note_item/delivery_note_item.json # erpnext/stock/doctype/pick_list/pick_list.py # erpnext/stock/doctype/pick_list_item/pick_list_item.json * chore: resolve conflicts * fix: setting status correctly as per v15 utility * fix: get items from Pick List to DN even if not linked to Sales Order --------- Co-authored-by: Smit Vora Co-authored-by: Priyansh Shah <108476017+priyanshshah2442@users.noreply.github.com> --- erpnext/controllers/status_updater.py | 11 + erpnext/patches.txt | 1 + .../patches/v15_0/update_pick_list_fields.py | 28 ++ erpnext/public/js/utils.js | 2 +- .../doctype/sales_order/sales_order.py | 4 +- .../doctype/delivery_note/delivery_note.js | 49 +++ .../doctype/delivery_note/delivery_note.json | 10 - .../doctype/delivery_note/delivery_note.py | 24 +- .../delivery_note_item.json | 13 +- .../delivery_note_item/delivery_note_item.py | 1 + .../stock/doctype/packed_item/packed_item.py | 26 ++ erpnext/stock/doctype/pick_list/pick_list.js | 44 ++- .../stock/doctype/pick_list/pick_list.json | 42 ++- erpnext/stock/doctype/pick_list/pick_list.py | 278 ++++++++++++------ .../doctype/pick_list/pick_list_dashboard.py | 1 + .../stock/doctype/pick_list/pick_list_list.js | 1 + .../stock/doctype/pick_list/test_pick_list.py | 206 ++++++++++++- .../pick_list_item/pick_list_item.json | 14 +- .../doctype/pick_list_item/pick_list_item.py | 1 + 19 files changed, 602 insertions(+), 154 deletions(-) create mode 100644 erpnext/patches/v15_0/update_pick_list_fields.py diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index cd92a102234..f16553ad908 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -156,6 +156,17 @@ status_map = { ["Draft", None], ["Completed", "eval:self.docstatus == 1"], ], + "Pick List": [ + ["Draft", None], + ["Open", "eval:self.docstatus == 1"], + ["Completed", "stock_entry_exists"], + [ + "Partly Delivered", + "eval:self.purpose == 'Delivery' and self.delivery_status == 'Partly Delivered'", + ], + ["Completed", "eval:self.purpose == 'Delivery' and self.delivery_status == 'Fully Delivered'"], + ["Cancelled", "eval:self.docstatus == 2"], + ], } diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 13be80338d8..34140dc2c84 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -409,4 +409,5 @@ erpnext.patches.v15_0.set_cancelled_status_to_cancelled_pos_invoice erpnext.patches.v15_0.rename_group_by_to_categorize_by_in_custom_reports erpnext.patches.v14_0.update_full_name_in_contract erpnext.patches.v15_0.drop_sle_indexes +erpnext.patches.v15_0.update_pick_list_fields erpnext.patches.v15_0.update_pegged_currencies diff --git a/erpnext/patches/v15_0/update_pick_list_fields.py b/erpnext/patches/v15_0/update_pick_list_fields.py new file mode 100644 index 00000000000..9a7a1f5f463 --- /dev/null +++ b/erpnext/patches/v15_0/update_pick_list_fields.py @@ -0,0 +1,28 @@ +import frappe +from frappe.query_builder.functions import IfNull + + +def execute(): + update_delivery_note() + update_pick_list_items() + + +def update_delivery_note(): + DN = frappe.qb.DocType("Delivery Note") + DNI = frappe.qb.DocType("Delivery Note Item") + + frappe.qb.update(DNI).join(DN).on(DN.name == DNI.parent).set(DNI.against_pick_list, DN.pick_list).where( + IfNull(DN.pick_list, "") != "" + ).run() + + +def update_pick_list_items(): + PL = frappe.qb.DocType("Pick List") + PLI = frappe.qb.DocType("Pick List Item") + + pick_lists = frappe.qb.from_(PL).select(PL.name).where(PL.status == "Completed").run(pluck="name") + + if not pick_lists: + return + + frappe.qb.update(PLI).set(PLI.delivered_qty, PLI.picked_qty).where(PLI.parent.isin(pick_lists)).run() diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index e883d94c6a2..19a3f38d1e2 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -1004,7 +1004,7 @@ erpnext.utils.map_current_doc = function (opts) { if ( opts.allow_child_item_selection || - ["Purchase Receipt", "Delivery Note"].includes(opts.source_doctype) + ["Purchase Receipt", "Delivery Note", "Pick List"].includes(opts.source_doctype) ) { // args contains filtered child docnames opts.args = args; diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 66ffbba9e63..2f2f745bedb 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -1741,8 +1741,8 @@ def create_pick_list(source_name, target_doc=None): "doctype": "Pick List Item", "field_map": { "parent": "sales_order", - "name": "sales_order_item", - "parent_detail_docname": "product_bundle_item", + "parent_detail_docname": "sales_order_item", + "name": "product_bundle_item", }, "field_no_map": ["picked_qty"], "postprocess": update_packed_item_qty, diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.js b/erpnext/stock/doctype/delivery_note/delivery_note.js index 1f6816e3fed..440e104abb6 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note.js @@ -188,6 +188,55 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends ( ); } + if ( + !doc.is_return && + doc.status != "Closed" && + this.frm.has_perm("write") && + frappe.model.can_read("Pick List") && + this.frm.doc.docstatus === 0 + ) { + this.frm.add_custom_button( + __("Pick List"), + function () { + if (!me.frm.doc.customer) { + frappe.throw({ + title: __("Mandatory"), + message: __("Please Select a Customer"), + }); + } + erpnext.utils.map_current_doc({ + method: "erpnext.stock.doctype.pick_list.pick_list.create_dn_for_pick_lists", + source_doctype: "Pick List", + target: me.frm, + setters: [ + { + fieldname: "customer", + default: me.frm.doc.customer, + label: __("Customer"), + fieldtype: "Link", + options: "Customer", + reqd: 1, + read_only: 1, + }, + { + fieldname: "sales_order", + label: __("Sales Order"), + fieldtype: "Link", + options: "Sales Order", + link_filters: `[["Sales Order","customer","=","${me.frm.doc.customer}"],["Sales Order","docstatus","=","1"],["Sales Order","delivery_status","not in",["Closed","Fully Delivered"]]]`, + }, + ], + get_query_filters: { + company: me.frm.doc.company, + }, + get_query_method: "erpnext.stock.doctype.pick_list.pick_list.get_pick_list_query", + size: "extra-large", + }); + }, + __("Get Items From") + ); + } + if (!doc.is_return && doc.status != "Closed") { if (doc.docstatus == 1 && frappe.model.can_create("Shipment")) { this.frm.add_custom_button( diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json index 4a0580f0e94..e55b7f229dc 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.json +++ b/erpnext/stock/doctype/delivery_note/delivery_note.json @@ -38,7 +38,6 @@ "ignore_pricing_rule", "items_section", "scan_barcode", - "pick_list", "col_break_warehouse", "set_warehouse", "set_target_warehouse", @@ -1218,15 +1217,6 @@ "options": "Sales Team", "print_hide": 1 }, - { - "fieldname": "pick_list", - "fieldtype": "Link", - "hidden": 1, - "label": "Pick List", - "options": "Pick List", - "read_only": 1, - "search_index": 1 - }, { "default": "0", "fetch_from": "customer.is_internal_customer", diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 7b9ddb7a129..12182ae990c 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -174,6 +174,19 @@ class DeliveryNote(SellingController): "overflow_type": "delivery", "no_allowance": 1, }, + { + "source_dt": "Delivery Note Item", + "target_dt": "Pick List Item", + "join_field": "pick_list_item", + "target_field": "delivered_qty", + "target_parent_dt": "Pick List", + "target_parent_field": "per_delivered", + "target_ref_field": "picked_qty", + "source_field": "stock_qty", + "percent_join_field": "against_pick_list", + "status_field": "delivery_status", + "keyword": "Delivered", + }, ] if cint(self.is_return): self.status_updater.extend( @@ -326,18 +339,15 @@ class DeliveryNote(SellingController): def set_serial_and_batch_bundle_from_pick_list(self): from erpnext.stock.serial_batch_bundle import SerialBatchCreation - if not self.pick_list: - return - for item in self.items: - if item.use_serial_batch_fields: + if item.use_serial_batch_fields or not item.against_pick_list: continue if item.pick_list_item and not item.serial_and_batch_bundle: filters = { "item_code": item.item_code, "voucher_type": "Pick List", - "voucher_no": self.pick_list, + "voucher_no": item.against_pick_list, "voucher_detail_no": item.pick_list_item, } @@ -586,7 +596,9 @@ class DeliveryNote(SellingController): def update_pick_list_status(self): from erpnext.stock.doctype.pick_list.pick_list import update_pick_list_status - update_pick_list_status(self.pick_list) + pick_lists = {row.against_pick_list for row in self.items if row.against_pick_list} + for pick_list in pick_lists: + update_pick_list_status(pick_list) def check_next_docstatus(self): submit_rv = frappe.db.sql( diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json index e951aaf1e18..c8fcdb4c5a7 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json @@ -77,6 +77,7 @@ "against_sales_invoice", "si_detail", "dn_detail", + "against_pick_list", "pick_list_item", "section_break_40", "pick_serial_and_batch", @@ -935,13 +936,23 @@ { "fieldname": "column_break_fguf", "fieldtype": "Column Break" + }, + { + "fieldname": "against_pick_list", + "fieldtype": "Link", + "label": "Against Pick List", + "no_copy": 1, + "options": "Pick List", + "print_hide": 1, + "read_only": 1, + "search_index": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-02-05 14:28:33.322181", + "modified": "2025-05-31 18:51:32.651562", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note Item", diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py index 7fb0e24be0b..62a7691009e 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py @@ -16,6 +16,7 @@ class DeliveryNoteItem(Document): actual_batch_qty: DF.Float actual_qty: DF.Float + against_pick_list: DF.Link | None against_sales_invoice: DF.Link | None against_sales_order: DF.Link | None allow_zero_valuation_rate: DF.Check diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index e1a1155292d..eaeb04d568e 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -80,6 +80,10 @@ def make_packing_list(doc): update_packed_item_basic_data(item_row, pi_row, bundle_item, item_data) update_packed_item_stock_data(item_row, pi_row, bundle_item, item_data, doc) update_packed_item_price_data(pi_row, item_data, doc) + + if item_row.get("against_pick_list"): + update_packed_item_with_pick_list_info(item_row, pi_row) + update_packed_item_from_cancelled_doc(item_row, bundle_item, pi_row, doc) if set_price_from_children: # create/update bundle item wise price dict @@ -228,6 +232,28 @@ def update_packed_item_stock_data(main_item_row, pi_row, packing_item, item_data pi_row.use_serial_batch_fields = frappe.db.get_single_value("Stock Settings", "use_serial_batch_fields") +def update_packed_item_with_pick_list_info(main_item_row, pi_row): + pl_row = frappe.db.get_value( + "Pick List Item", + { + "item_code": pi_row.item_code, + "sales_order": main_item_row.get("against_sales_order"), + "sales_order_item": main_item_row.get("so_detail"), + "parent": main_item_row.against_pick_list, + }, + ["warehouse", "batch_no", "serial_no"], + as_dict=True, + order_by="qty desc", + ) + + if not pl_row: + return + + pi_row.warehouse = pl_row.warehouse + pi_row.batch_no = pl_row.batch_no + pi_row.serial_no = pl_row.serial_no + + def update_packed_item_price_data(pi_row, item_data, doc): "Set price as per price list or from the Item master." if pi_row.rate: diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js index 6a6bb226a9e..dea83560494 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.js +++ b/erpnext/stock/doctype/pick_list/pick_list.js @@ -98,34 +98,28 @@ frappe.ui.form.on("Pick List", { refresh: (frm) => { frm.trigger("add_get_items_button"); if (frm.doc.docstatus === 1) { - frappe - .xcall("erpnext.stock.doctype.pick_list.pick_list.target_document_exists", { - pick_list_name: frm.doc.name, - purpose: frm.doc.purpose, - }) - .then((target_document_exists) => { - frm.set_df_property("locations", "allow_on_submit", target_document_exists ? 0 : 1); + const status_completed = frm.doc.status === "Completed"; + frm.set_df_property("locations", "allow_on_submit", status_completed ? 0 : 1); - if (target_document_exists) return; + if (!status_completed) { + frm.add_custom_button(__("Update Current Stock"), () => + frm.trigger("update_pick_list_stock") + ); - frm.add_custom_button(__("Update Current Stock"), () => - frm.trigger("update_pick_list_stock") + if (frm.doc.purpose === "Delivery") { + frm.add_custom_button( + __("Create Delivery Note"), + () => frm.trigger("create_delivery_note"), + __("Create") ); - - if (frm.doc.purpose === "Delivery") { - frm.add_custom_button( - __("Delivery Note"), - () => frm.trigger("create_delivery_note"), - __("Create") - ); - } else { - frm.add_custom_button( - __("Stock Entry"), - () => frm.trigger("create_stock_entry"), - __("Create") - ); - } - }); + } else { + frm.add_custom_button( + __("Create Stock Entry"), + () => frm.trigger("create_stock_entry"), + __("Create") + ); + } + } if (frm.doc.purpose === "Delivery" && frm.doc.status === "Open") { if (frm.doc.__onload && frm.doc.__onload.has_unreserved_stock) { diff --git a/erpnext/stock/doctype/pick_list/pick_list.json b/erpnext/stock/doctype/pick_list/pick_list.json index a5a46ff9187..e6449476971 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.json +++ b/erpnext/stock/doctype/pick_list/pick_list.json @@ -30,7 +30,11 @@ "amended_from", "print_settings_section", "group_same_items", - "status" + "status_section", + "status", + "column_break_qyam", + "delivery_status", + "per_delivered" ], "fields": [ { @@ -181,7 +185,7 @@ "in_standard_filter": 1, "label": "Status", "no_copy": 1, - "options": "Draft\nOpen\nCompleted\nCancelled", + "options": "Draft\nOpen\nPartly Delivered\nCompleted\nCancelled", "print_hide": 1, "read_only": 1, "report_hide": 1, @@ -208,11 +212,42 @@ "fieldname": "ignore_pricing_rule", "fieldtype": "Check", "label": "Ignore Pricing Rule" + }, + { + "collapsible": 1, + "fieldname": "status_section", + "fieldtype": "Section Break", + "label": "Status", + "print_hide": 1 + }, + { + "fieldname": "delivery_status", + "fieldtype": "Select", + "hidden": 1, + "in_standard_filter": 1, + "label": "Delivery Status", + "no_copy": 1, + "options": "Not Delivered\nFully Delivered\nPartly Delivered", + "print_hide": 1 + }, + { + "depends_on": "eval:!doc.__islocal && doc.purpose === \"Delivery\"", + "description": "% of materials delivered against this Pick List", + "fieldname": "per_delivered", + "fieldtype": "Percent", + "label": "% Delivered", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "column_break_qyam", + "fieldtype": "Column Break" } ], "is_submittable": 1, "links": [], - "modified": "2024-08-14 13:20:42.168827", + "modified": "2025-05-31 19:18:30.860044", "modified_by": "Administrator", "module": "Stock", "name": "Pick List", @@ -280,6 +315,7 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "modified", "sort_order": "DESC", "states": [], diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 31bff657fd1..aae1d4786d0 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -7,8 +7,7 @@ from itertools import groupby import frappe from frappe import _, bold -from frappe.model.document import Document -from frappe.model.mapper import map_child_doc +from frappe.model.mapper import get_mapped_doc, map_child_doc from frappe.query_builder import Case from frappe.query_builder.custom import GROUP_CONCAT from frappe.query_builder.functions import Coalesce, Locate, Replace, Sum @@ -28,11 +27,12 @@ from erpnext.stock.serial_batch_bundle import ( get_batches_from_bundle, get_serial_nos_from_bundle, ) +from erpnext.utilities.transaction_base import TransactionBase # TODO: Prioritize SO or WO group warehouse -class PickList(Document): +class PickList(TransactionBase): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -48,6 +48,7 @@ class PickList(Document): consider_rejected_warehouses: DF.Check customer: DF.Link | None customer_name: DF.Data | None + delivery_status: DF.Literal["Not Delivered", "Fully Delivered", "Partly Delivered"] for_qty: DF.Float group_same_items: DF.Check ignore_pricing_rule: DF.Check @@ -55,12 +56,13 @@ class PickList(Document): material_request: DF.Link | None naming_series: DF.Literal["STO-PICK-.YYYY.-"] parent_warehouse: DF.Link | None + per_delivered: DF.Percent pick_manually: DF.Check prompt_qty: DF.Check purpose: DF.Literal["Material Transfer for Manufacture", "Material Transfer", "Delivery"] scan_barcode: DF.Data | None scan_mode: DF.Check - status: DF.Literal["Draft", "Open", "Completed", "Cancelled"] + status: DF.Literal["Draft", "Open", "Partly Delivered", "Completed", "Cancelled"] work_order: DF.Link | None # end: auto-generated types @@ -77,6 +79,7 @@ class PickList(Document): self.validate_for_qty() self.validate_stock_qty() self.check_serial_no_status() + self.validate_with_previous_doc() def before_save(self): self.update_status() @@ -150,6 +153,18 @@ class PickList(Document): title=_("Incorrect Warehouse"), ) + def validate_with_previous_doc(self): + super().validate_with_previous_doc( + { + "Sales Order": { + "ref_dn_field": "sales_order", + "compare_fields": [ + ["company", "="], + ], + }, + } + ) + def validate_sales_order_percentage(self): # set percentage picked in SO for location in self.get("locations"): @@ -326,19 +341,19 @@ class PickList(Document): doc.submit() def update_status(self, status=None, update_modified=True): - if not status: - if self.docstatus == 0: - status = "Draft" - elif self.docstatus == 1: - if target_document_exists(self.name, self.purpose): - status = "Completed" - else: - status = "Open" - elif self.docstatus == 2: - status = "Cancelled" - if status: - self.db_set("status", status) + self.db_set("status", status, update_modified=update_modified) + else: + self.set_status(update=True) + + def stock_entry_exists(self): + if self.docstatus != 1: + return False + + if self.purpose == "Delivery": + return False + + return stock_entry_exists(self.name) def update_reference_qty(self): packed_items = [] @@ -346,7 +361,7 @@ class PickList(Document): for item in self.locations: if item.product_bundle_item: - packed_items.append(item.sales_order_item) + packed_items.append(item.product_bundle_item) elif item.sales_order_item: so_items.append(item.sales_order_item) @@ -357,12 +372,12 @@ class PickList(Document): self.update_sales_order_item_qty(so_items) def update_packed_items_qty(self, packed_items): - picked_items = get_picked_items_qty(packed_items) + picked_items = get_picked_items_qty(packed_items, contains_packed_items=True) self.validate_picked_qty(picked_items) picked_qty = frappe._dict() for d in picked_items: - picked_qty[d.sales_order_item] = d.picked_qty + picked_qty[d.product_bundle_item] = d.picked_qty for packed_item in packed_items: frappe.db.set_value( @@ -575,7 +590,6 @@ class PickList(Document): # maintain count of each item (useful to limit get query) self.item_count_map.setdefault(item_code, 0) self.item_count_map[item_code] += flt(item.stock_qty, item.precision("stock_qty")) - return item_map.values() def validate_for_qty(self): @@ -739,9 +753,10 @@ class PickList(Document): for item in self.locations: if not item.product_bundle_item: continue - product_bundles[item.product_bundle_item] = frappe.db.get_value( + + product_bundles[item.sales_order_item] = frappe.db.get_value( "Sales Order Item", - item.product_bundle_item, + item.sales_order_item, "item_code", ) return product_bundles @@ -757,17 +772,16 @@ class PickList(Document): def _compute_picked_qty_for_bundle(self, bundle_row, bundle_items) -> int: """Compute how many full bundles can be created from picked items.""" precision = frappe.get_precision("Stock Ledger Entry", "qty_after_transaction") - - possible_bundles = [] + possible_bundles = {} for item in self.locations: - if item.product_bundle_item != bundle_row: + if item.sales_order_item != bundle_row: continue if qty_in_bundle := bundle_items.get(item.item_code): - possible_bundles.append(item.picked_qty / qty_in_bundle) - else: - possible_bundles.append(0) - return int(flt(min(possible_bundles), precision or 6)) + possible_bundles.setdefault(item.product_bundle_item, 0) + possible_bundles[item.product_bundle_item] += item.picked_qty / qty_in_bundle + + return int(flt(min(possible_bundles.values()), precision or 6)) if possible_bundles else 0 def has_unreserved_stock(self): if self.purpose == "Delivery": @@ -800,24 +814,35 @@ def update_pick_list_status(pick_list): doc.run_method("update_status") -def get_picked_items_qty(items) -> list[dict]: +def get_picked_items_qty(items, contains_packed_items=False) -> list[dict]: pi_item = frappe.qb.DocType("Pick List Item") - return ( + + query = ( frappe.qb.from_(pi_item) .select( pi_item.sales_order_item, + pi_item.product_bundle_item, pi_item.item_code, pi_item.sales_order, Sum(pi_item.stock_qty).as_("stock_qty"), Sum(pi_item.picked_qty).as_("picked_qty"), ) - .where((pi_item.docstatus == 1) & (pi_item.sales_order_item.isin(items))) - .groupby( + .where(pi_item.docstatus == 1) + .for_update() + ) + + if contains_packed_items: + query = query.groupby( + pi_item.product_bundle_item, + pi_item.sales_order, + ).where(pi_item.product_bundle_item.isin(items)) + else: + query = query.groupby( pi_item.sales_order_item, pi_item.sales_order, - ) - .for_update() - ).run(as_dict=True) + ).where(pi_item.sales_order_item.isin(items)) + + return query.run(as_dict=True) def validate_item_locations(pick_list): @@ -1188,13 +1213,17 @@ def create_delivery_note(source_name, target_doc=None): if not all(item.sales_order for item in pick_list.locations): delivery_note = create_dn_wo_so(pick_list) + delivery_note.flags.ignore_mandatory = True + delivery_note.save() frappe.msgprint(_("Delivery Note(s) created for the Pick List")) return delivery_note -def create_dn_wo_so(pick_list): - delivery_note = frappe.new_doc("Delivery Note") +def create_dn_wo_so(pick_list, delivery_note=None): + if not delivery_note: + delivery_note = frappe.new_doc("Delivery Note") + delivery_note.company = pick_list.company item_table_mapper_without_so = { @@ -1206,14 +1235,61 @@ def create_dn_wo_so(pick_list): }, } map_pl_locations(pick_list, item_table_mapper_without_so, delivery_note) - delivery_note.insert(ignore_mandatory=True) + + return delivery_note + + +@frappe.whitelist() +def create_dn_for_pick_lists(source_name, target_doc=None, kwargs=None): + """Get Items from Multiple Pick Lists and create a Delivery Note for filtered customer""" + pick_list = frappe.get_doc("Pick List", source_name) + validate_item_locations(pick_list) + + sales_order_arg = kwargs.get("sales_order") if kwargs else None + customer_arg = kwargs.get("customer") if kwargs else None + + if sales_order_arg: + sales_orders = {sales_order_arg} + else: + sales_orders = {row.sales_order for row in pick_list.locations if row.sales_order} + + if customer_arg: + sales_orders = frappe.get_all( + "Sales Order", + filters={"customer": customer_arg, "name": ["in", list(sales_orders)]}, + pluck="name", + ) + + delivery_note = create_dn_from_so(pick_list, sales_orders, delivery_note=target_doc) + + if not sales_order_arg and not all(item.sales_order for item in pick_list.locations): + if isinstance(delivery_note, str): + delivery_note = frappe.get_doc(frappe.parse_json(delivery_note)) + + delivery_note = create_dn_wo_so(pick_list, delivery_note) return delivery_note def create_dn_with_so(sales_dict, pick_list): + """Create Delivery Note for each customer (based on SO) in a Pick List.""" delivery_note = None + for customer in sales_dict: + delivery_note = create_dn_from_so(pick_list, sales_dict[customer], None) + if delivery_note: + delivery_note.flags.ignore_mandatory = True + # updates packed_items on save + # save as multiple customers are possible + delivery_note.save() + + return delivery_note + + +def create_dn_from_so(pick_list, sales_order_list, delivery_note=None): + if not sales_order_list: + return delivery_note + item_table_mapper = { "doctype": "Delivery Note Item", "field_map": { @@ -1224,20 +1300,17 @@ def create_dn_with_so(sales_dict, pick_list): "condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier != 1, } - for customer in sales_dict: - for so in sales_dict[customer]: - delivery_note = None - kwargs = {"skip_item_mapping": True, "ignore_pricing_rule": pick_list.ignore_pricing_rule} - delivery_note = create_delivery_note_from_sales_order(so, delivery_note, kwargs=kwargs) - break - if delivery_note: - # map all items of all sales orders of that customer - for so in sales_dict[customer]: - map_pl_locations(pick_list, item_table_mapper, delivery_note, so) - delivery_note.flags.ignore_mandatory = True - delivery_note.insert() - update_packed_item_details(pick_list, delivery_note) - delivery_note.save() + kwargs = {"skip_item_mapping": True, "ignore_pricing_rule": pick_list.ignore_pricing_rule} + + delivery_note = create_delivery_note_from_sales_order( + next(iter(sales_order_list)), delivery_note, kwargs=kwargs + ) + + if not delivery_note: + return + + for so in sales_order_list: + map_pl_locations(pick_list, item_table_mapper, delivery_note, so) return delivery_note @@ -1257,24 +1330,29 @@ def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None): dn_item = map_child_doc(source_doc, delivery_note, item_mapper) if dn_item: + dn_item.against_pick_list = pick_list.name dn_item.pick_list_item = location.name dn_item.warehouse = location.warehouse - dn_item.qty = flt(location.picked_qty) / (flt(location.conversion_factor) or 1) + dn_item.qty = flt(location.picked_qty - location.delivered_qty) / ( + flt(dn_item.conversion_factor) or 1 + ) dn_item.batch_no = location.batch_no dn_item.serial_no = location.serial_no dn_item.use_serial_batch_fields = location.use_serial_batch_fields update_delivery_note_item(source_doc, dn_item, delivery_note) - add_product_bundles_to_delivery_note(pick_list, delivery_note, item_mapper) + add_product_bundles_to_delivery_note(pick_list, delivery_note, item_mapper, sales_order) set_delivery_note_missing_values(delivery_note) - delivery_note.pick_list = pick_list.name delivery_note.company = pick_list.company - delivery_note.customer = frappe.get_value("Sales Order", sales_order, "customer") + if sales_order: + delivery_note.customer = frappe.get_value("Sales Order", sales_order, "customer") -def add_product_bundles_to_delivery_note(pick_list: "PickList", delivery_note, item_mapper) -> None: +def add_product_bundles_to_delivery_note( + pick_list: "PickList", delivery_note, item_mapper, sales_order=None +) -> None: """Add product bundles found in pick list to delivery note. When mapping pick list items, the bundle item itself isn't part of the @@ -1284,38 +1362,17 @@ def add_product_bundles_to_delivery_note(pick_list: "PickList", delivery_note, i for so_row, item_code in product_bundles.items(): sales_order_item = frappe.get_doc("Sales Order Item", so_row) + if sales_order and sales_order_item.parent != sales_order: + continue + dn_bundle_item = map_child_doc(sales_order_item, delivery_note, item_mapper) dn_bundle_item.qty = pick_list._compute_picked_qty_for_bundle( so_row, product_bundle_qty_map[item_code] ) + dn_bundle_item.against_pick_list = pick_list.name update_delivery_note_item(sales_order_item, dn_bundle_item, delivery_note) -def update_packed_item_details(pick_list: "PickList", delivery_note) -> None: - """Update stock details on packed items table of delivery note.""" - - def _find_so_row(packed_item): - for item in delivery_note.items: - if packed_item.parent_detail_docname == item.name: - return item.so_detail - - def _find_pick_list_location(bundle_row, packed_item): - if not bundle_row: - return - for loc in pick_list.locations: - if loc.product_bundle_item == bundle_row and loc.item_code == packed_item.item_code: - return loc - - for packed_item in delivery_note.packed_items: - so_row = _find_so_row(packed_item) - location = _find_pick_list_location(so_row, packed_item) - if not location: - continue - packed_item.warehouse = location.warehouse - packed_item.batch_no = location.batch_no - packed_item.serial_no = location.serial_no - - @frappe.whitelist() def create_stock_entry(pick_list): pick_list = frappe.get_doc(json.loads(pick_list)) @@ -1362,14 +1419,6 @@ def get_pending_work_orders(doctype, txt, searchfield, start, page_length, filte ).run(as_dict=as_dict) -@frappe.whitelist() -def target_document_exists(pick_list_name, purpose): - if purpose == "Delivery": - return frappe.db.exists("Delivery Note", {"pick_list": pick_list_name, "docstatus": 1}) - - return stock_entry_exists(pick_list_name) - - @frappe.whitelist() def get_item_details(item_code, uom=None): details = frappe.db.get_value("Item", item_code, ["stock_uom", "name"], as_dict=1) @@ -1490,3 +1539,50 @@ def get_rejected_warehouses(): ) return frappe.local.rejected_warehouses + + +@frappe.whitelist() +def get_pick_list_query(doctype, txt, searchfield, start, page_len, filters): + frappe.has_permission("Pick List", throw=True) + + if not filters.get("company"): + frappe.throw(_("Please select a Company")) + + PICK_LIST = frappe.qb.DocType("Pick List") + PICK_LIST_ITEM = frappe.qb.DocType("Pick List Item") + SALES_ORDER = frappe.qb.DocType("Sales Order") + + query = ( + frappe.qb.from_(PICK_LIST) + .join(PICK_LIST_ITEM) + .on(PICK_LIST.name == PICK_LIST_ITEM.parent) + .join(SALES_ORDER) + .on(PICK_LIST_ITEM.sales_order == SALES_ORDER.name) + .select( + PICK_LIST.name, + SALES_ORDER.customer, + Replace(GROUP_CONCAT(PICK_LIST_ITEM.sales_order).distinct(), ",", "
").as_("sales_order"), + ) + .where(PICK_LIST.docstatus == 1) + .where(PICK_LIST.status.isin(["Open", "Partly Delivered"])) + .where(PICK_LIST.company == filters.get("company")) + .where(SALES_ORDER.customer == filters.get("customer")) + .groupby(PICK_LIST.name) + ) + + if filters.get("sales_order"): + query = query.where(PICK_LIST_ITEM.sales_order == filters.get("sales_order")) + + if txt: + meta = frappe.get_meta("Pick List") + search_fields = meta.get_search_fields() + + txt = f"%{txt}%" + txt_condition = PICK_LIST[search_fields[-1]].like(txt) + + for field in search_fields[:-1]: + txt_condition |= PICK_LIST[field].like(txt) + + query = query.where(txt_condition) + + return query.run(as_dict=True) diff --git a/erpnext/stock/doctype/pick_list/pick_list_dashboard.py b/erpnext/stock/doctype/pick_list/pick_list_dashboard.py index 29571a54007..8900385c265 100644 --- a/erpnext/stock/doctype/pick_list/pick_list_dashboard.py +++ b/erpnext/stock/doctype/pick_list/pick_list_dashboard.py @@ -3,6 +3,7 @@ def get_data(): "fieldname": "pick_list", "non_standard_fieldnames": { "Stock Reservation Entry": "from_voucher_no", + "Delivery Note": "against_pick_list", }, "internal_links": { "Sales Order": ["locations", "sales_order"], diff --git a/erpnext/stock/doctype/pick_list/pick_list_list.js b/erpnext/stock/doctype/pick_list/pick_list_list.js index eca6eece785..a675c95f973 100644 --- a/erpnext/stock/doctype/pick_list/pick_list_list.js +++ b/erpnext/stock/doctype/pick_list/pick_list_list.js @@ -6,6 +6,7 @@ frappe.listview_settings["Pick List"] = { const status_colors = { Draft: "red", Open: "orange", + "Partly Delivered": "orange", Completed: "green", Cancelled: "red", }; diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index c3043bbf1b5..b190e30227f 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -5,11 +5,12 @@ import frappe from frappe import _dict from frappe.tests.utils import FrappeTestCase +from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle from erpnext.selling.doctype.sales_order.sales_order import create_pick_list from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import create_item, make_item from erpnext.stock.doctype.packed_item.test_packed_item import create_product_bundle -from erpnext.stock.doctype.pick_list.pick_list import create_delivery_note +from erpnext.stock.doctype.pick_list.pick_list import create_delivery_note, create_dn_for_pick_lists from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( get_batch_from_bundle, @@ -398,7 +399,13 @@ class TestPickList(FrappeTestCase): self.assertEqual(pick_list.locations[1].sales_order_item, sales_order.items[0].name) def test_pick_list_for_items_with_multiple_UOM(self): - item_code = make_item().name + item_code = make_item( + uoms=[ + {"uom": "Nos", "conversion_factor": 1}, + {"uom": "Hand", "conversion_factor": 5}, + {"uom": "Unit", "conversion_factor": 0.5}, + ] + ).name purchase_receipt = make_purchase_receipt(item_code=item_code, qty=10) purchase_receipt.submit() @@ -411,8 +418,7 @@ class TestPickList(FrappeTestCase): { "item_code": item_code, "qty": 1, - "conversion_factor": 5, - "stock_qty": 5, + "uom": "Hand", "delivery_date": frappe.utils.today(), "warehouse": "_Test Warehouse - _TC", }, @@ -426,6 +432,7 @@ class TestPickList(FrappeTestCase): ], } ).insert() + sales_order.submit() pick_list = frappe.get_doc( @@ -440,6 +447,7 @@ class TestPickList(FrappeTestCase): "item_code": item_code, "qty": 2, "stock_qty": 1, + "uom": "Unit", "conversion_factor": 0.5, "sales_order": sales_order.name, "sales_order_item": sales_order.items[0].name, @@ -461,7 +469,11 @@ class TestPickList(FrappeTestCase): delivery_note = create_delivery_note(pick_list.name) pick_list.load_from_db() - self.assertEqual(pick_list.locations[0].qty, delivery_note.items[0].qty) + # pick list stk_qty / dn conversion_factor = dn qty (1/5 = 0.2) + self.assertEqual( + pick_list.locations[0].picked_qty, + delivery_note.items[0].qty * delivery_note.items[0].conversion_factor, + ) self.assertEqual(pick_list.locations[1].qty, delivery_note.items[1].qty) self.assertEqual(sales_order.items[0].conversion_factor, delivery_note.items[0].conversion_factor) @@ -554,10 +566,10 @@ class TestPickList(FrappeTestCase): "company": "_Test Company", "items_based_on": "Sales Order", "purpose": "Delivery", - "picker": "P001", + "customer": "_Test Customer", "locations": [ { - "item_code": "_Test Item ", + "item_code": "_Test Item", "qty": 1, "stock_qty": 1, "conversion_factor": 1, @@ -580,32 +592,34 @@ class TestPickList(FrappeTestCase): create_delivery_note(pick_list.name) for dn in frappe.get_all( "Delivery Note", - filters={"pick_list": pick_list.name, "customer": "_Test Customer"}, + filters={"against_pick_list": pick_list.name, "customer": "_Test Customer"}, fields={"name"}, ): for dn_item in frappe.get_doc("Delivery Note", dn.name).get("items"): self.assertEqual(dn_item.item_code, "_Test Item") self.assertEqual(dn_item.against_sales_order, sales_order_1.name) - self.assertEqual(dn_item.pick_list_item, pick_list.locations[dn_item.idx - 1].name) + self.assertEqual(dn_item.against_pick_list, pick_list.name) + self.assertEqual(dn_item.pick_list_item, pick_list.locations[0].name) for dn in frappe.get_all( "Delivery Note", - filters={"pick_list": pick_list.name, "customer": "_Test Customer 1"}, + filters={"against_pick_list": pick_list.name, "customer": "_Test Customer 1"}, fields={"name"}, ): for dn_item in frappe.get_doc("Delivery Note", dn.name).get("items"): self.assertEqual(dn_item.item_code, "_Test Item 2") self.assertEqual(dn_item.against_sales_order, sales_order_2.name) + self.assertEqual(dn_item.against_pick_list, pick_list.name) + self.assertEqual(dn_item.pick_list_item, pick_list.locations[1].name) # test DN creation without so pick_list_1 = frappe.get_doc( { "doctype": "Pick List", "company": "_Test Company", "purpose": "Delivery", - "picker": "P001", "locations": [ { - "item_code": "_Test Item ", + "item_code": "_Test Item", "qty": 1, "stock_qty": 1, "conversion_factor": 1, @@ -622,7 +636,9 @@ class TestPickList(FrappeTestCase): pick_list_1.set_item_locations() pick_list_1.submit() create_delivery_note(pick_list_1.name) - for dn in frappe.get_all("Delivery Note", filters={"pick_list": pick_list_1.name}, fields={"name"}): + for dn in frappe.get_all( + "Delivery Note", filters={"against_pick_list": pick_list_1.name}, fields={"name"} + ): for dn_item in frappe.get_doc("Delivery Note", dn.name).get("items"): if dn_item.item_code == "_Test Item": self.assertEqual(dn_item.qty, 1) @@ -759,7 +775,6 @@ class TestPickList(FrappeTestCase): quantities = [5, 2] bundle, components = create_product_bundle(quantities, warehouse=warehouse) bundle_items = dict(zip(components, quantities, strict=False)) - so = make_sales_order(item_code=bundle, qty=3, rate=42) pl = create_pick_list(so.name) @@ -1307,3 +1322,166 @@ class TestPickList(FrappeTestCase): for loc in pl.locations: self.assertEqual(loc.batch_no, batch2) + + def test_multiple_pick_lists_delivery_note(self): + from erpnext.stock.doctype.pick_list.pick_list import create_dn_for_pick_lists + + item_code = make_item().name + warehouse = "_Test Warehouse - _TC" + + stock_entry = make_stock_entry(item=item_code, to_warehouse=warehouse, qty=500, basic_rate=100) + + def create_pick_list(qty): + pick_list = frappe.get_doc( + { + "doctype": "Pick List", + "company": "_Test Company", + "customer": "_Test Customer", + "purpose": "Delivery", + "locations": [ + { + "item_code": item_code, + "warehouse": warehouse, + "qty": qty, + "stock_qty": qty, + "picked_qty": 0, + "sales_order": sales_order.name, + "sales_order_item": sales_order.items[0].name, + }, + ], + } + ) + pick_list.submit() + return pick_list + + sales_order = make_sales_order(item_code=item_code, qty=50, rate=100) + pick_list_1 = create_pick_list(10) + pick_list_2 = create_pick_list(20) + + delivery_note = create_dn_for_pick_lists(pick_list_1.name) + delivery_note = create_dn_for_pick_lists(pick_list_2.name, delivery_note) + delivery_note.items[0].qty = 5 + delivery_note.submit() + + sales_order.reload() + pick_list_1.reload() + pick_list_2.reload() + + self.assertEqual(sales_order.items[0].picked_qty, 30) + self.assertEqual(pick_list_1.locations[0].delivered_qty, delivery_note.items[0].qty) + self.assertEqual(pick_list_1.status, "Partly Delivered") + self.assertEqual(pick_list_2.status, "Completed") + + pick_list_1.cancel() + pick_list_2.cancel() + delivery_note.cancel() + sales_order.reload() + sales_order.cancel() + stock_entry.cancel() + + def test_packed_item_in_pick_list(self): + warehouse_1 = "RJ Warehouse - _TC" + warehouse_2 = "_Test Warehouse 2 - _TC" + item_1 = make_item(properties={"is_stock_item": 0}).name + item_2 = make_item().name + item_3 = make_item().name + + make_product_bundle(item_1, items=[item_2, item_3]) + + stock_entry_1 = make_stock_entry(item=item_2, to_warehouse=warehouse_1, qty=10, basic_rate=100) + stock_entry_2 = make_stock_entry(item=item_3, to_warehouse=warehouse_1, qty=4, basic_rate=100) + stock_entry_3 = make_stock_entry(item=item_3, to_warehouse=warehouse_2, qty=6, basic_rate=100) + + sales_order = make_sales_order(item_code=item_1, qty=10, rate=100) + + pick_list = create_pick_list(sales_order.name) + pick_list.submit() + self.assertEqual(len(pick_list.locations), 3) + delivery_note = create_delivery_note(pick_list.name) + + self.assertEqual(delivery_note.items[0].qty, 10) + self.assertEqual(delivery_note.packed_items[0].warehouse, warehouse_1) + self.assertEqual(delivery_note.packed_items[1].warehouse, warehouse_2) + + pick_list.cancel() + sales_order.cancel() + stock_entry_1.cancel() + stock_entry_2.cancel() + stock_entry_3.cancel() + + def test_packed_item_multiple_times_in_so(self): + frappe.db.delete("Item Price") + warehouse_1 = "RJ Warehouse - _TC" + warehouse_2 = "_Test Warehouse 2 - _TC" + warehouse = "_Test Warehouse - _TC" + item_1 = make_item(properties={"is_stock_item": 0}).name + item_2 = make_item().name + item_3 = make_item().name + + make_product_bundle(item_1, items=[item_2, item_3]) + + stock_entry_1 = make_stock_entry(item=item_2, to_warehouse=warehouse_1, qty=20, basic_rate=100) + stock_entry_2 = make_stock_entry(item=item_3, to_warehouse=warehouse_1, qty=8, basic_rate=100) + stock_entry_3 = make_stock_entry(item=item_3, to_warehouse=warehouse_2, qty=12, basic_rate=100) + + sales_order = make_sales_order( + item_list=[ + {"item_code": item_1, "qty": 8, "rate": 100, "warehouse": warehouse}, + {"item_code": item_1, "qty": 12, "rate": 100, "warehouse": warehouse}, + ] + ) + + pick_list = create_pick_list(sales_order.name) + pick_list.submit() + self.assertEqual(len(pick_list.locations), 4) + delivery_note = create_delivery_note(pick_list.name) + + self.assertEqual(delivery_note.items[0].qty, 8) + self.assertEqual(delivery_note.items[1].qty, 12) + + self.assertEqual(delivery_note.packed_items[0].qty, 8) + self.assertEqual(delivery_note.packed_items[2].qty, 12) + + self.assertEqual(delivery_note.packed_items[0].warehouse, warehouse_1) + self.assertEqual(delivery_note.packed_items[1].warehouse, warehouse_1) + self.assertEqual(delivery_note.packed_items[2].warehouse, warehouse_1) + self.assertEqual(delivery_note.packed_items[3].warehouse, warehouse_2) + + pick_list.cancel() + sales_order.cancel() + stock_entry_1.cancel() + stock_entry_2.cancel() + stock_entry_3.cancel() + + def test_pick_list_with_and_without_so(self): + warehouse = "_Test Warehouse - _TC" + item = make_item().name + + sales_order = make_sales_order(item_code=item, qty=20, rate=100) + stock_entry = make_stock_entry(item=item, to_warehouse=warehouse, qty=500, basic_rate=100) + + pick_list = create_pick_list(sales_order.name) + pick_list.append( + "locations", + { + "item_code": item, + "qty": 10, + "stock_qty": 10, + "warehouse": warehouse, + "picked_qty": 0, + }, + ) + pick_list.submit() + + delivery_note = create_dn_for_pick_lists(pick_list.name) + + self.assertEqual(delivery_note.items[0].against_pick_list, pick_list.name) + self.assertEqual(delivery_note.items[0].against_sales_order, sales_order.name) + self.assertEqual(delivery_note.items[0].qty, 20) + + self.assertEqual(delivery_note.items[1].against_pick_list, pick_list.name) + self.assertEqual(delivery_note.items[1].qty, 10) + + pick_list.cancel() + sales_order.cancel() + stock_entry.cancel() diff --git a/erpnext/stock/doctype/pick_list_item/pick_list_item.json b/erpnext/stock/doctype/pick_list_item/pick_list_item.json index d33252aa3ff..e7af1c6a005 100644 --- a/erpnext/stock/doctype/pick_list_item/pick_list_item.json +++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.json @@ -21,6 +21,7 @@ "uom", "conversion_factor", "stock_uom", + "delivered_qty", "serial_no_and_batch_section", "pick_serial_and_batch", "serial_and_batch_bundle", @@ -237,17 +238,28 @@ { "fieldname": "column_break_belw", "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "delivered_qty", + "fieldtype": "Float", + "label": "Delivered Qty (in Stock UOM)", + "no_copy": 1, + "print_hide": 1, + "read_only": 1, + "report_hide": 1 } ], "istable": 1, "links": [], - "modified": "2024-05-07 15:32:42.905446", + "modified": "2025-05-31 19:57:43.531298", "modified_by": "Administrator", "module": "Stock", "name": "Pick List Item", "owner": "Administrator", "permissions": [], "quick_entry": 1, + "row_format": "Dynamic", "sort_field": "modified", "sort_order": "DESC", "states": [], diff --git a/erpnext/stock/doctype/pick_list_item/pick_list_item.py b/erpnext/stock/doctype/pick_list_item/pick_list_item.py index f3f6298a305..af23a424949 100644 --- a/erpnext/stock/doctype/pick_list_item/pick_list_item.py +++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.py @@ -17,6 +17,7 @@ class PickListItem(Document): batch_no: DF.Link | None conversion_factor: DF.Float + delivered_qty: DF.Float description: DF.Text | None item_code: DF.Link item_group: DF.Data | None From b6e09531d79bad68b7621d571ddcb639e0f65dae Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 26 Jun 2025 23:20:38 +0530 Subject: [PATCH 11/35] fix: customer section on pos item cart (backport #48284) (#48285) * fix: customer section on pos item cart (#48284) * fix: customer recent transactions * fix: pos customer section display customer_name instead of customer name (cherry picked from commit e1d9f863c6673b9100fbf530dc8976b92b399fd1) # Conflicts: # erpnext/public/scss/point-of-sale.scss # erpnext/selling/page/point_of_sale/point_of_sale.py # erpnext/selling/page/point_of_sale/pos_item_cart.js * chore: resolve conflict * chore: resolve conflict * chore: resolve conflict --------- Co-authored-by: Diptanil Saha --- erpnext/public/scss/point-of-sale.scss | 51 +++++++++++++++++-- .../page/point_of_sale/pos_item_cart.js | 27 ++++++---- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/erpnext/public/scss/point-of-sale.scss b/erpnext/public/scss/point-of-sale.scss index d5215549756..f51e83ffe31 100644 --- a/erpnext/public/scss/point-of-sale.scss +++ b/erpnext/public/scss/point-of-sale.scss @@ -284,13 +284,24 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: var(--margin-md); column-gap: var(--padding-sm); - row-gap: var(--padding-xs); + row-gap: var(--padding-sm); } - > .transactions-label { - @extend .label; + > .transactions-section { + display: flex; + justify-content: space-between; + align-items: center; margin-top: var(--margin-md); margin-bottom: var(--margin-sm); + + > .recent-transactions { + @extend .label; + } + + > .last-transaction { + font-weight: 400; + font-size: var(--text-sm); + } } } @@ -299,8 +310,8 @@ overflow-x: hidden; overflow-y: scroll; margin-right: -12px; - padding-right: 12px; margin-left: -10px; + scrollbar-width: thin; > .no-transactions-placeholder { height: 100%; @@ -611,6 +622,11 @@ background-color: var(--gray-50); } + &.invoice-selected { + background-color: var(--control-bg); + } + + > .invoice-name-customer, > .invoice-name-date { display: flex; flex-direction: column; @@ -630,6 +646,21 @@ } } + > .invoice-name-date { + > .invoice-name { + font-size: var(--text-md); + display: flex; + align-items: center; + font-weight: 700; + } + + > .invoice-date { + @extend .nowrap; + font-size: var(--text-sm); + } + } + + > .invoice-total-date, > .invoice-total-status { display: flex; flex-direction: column; @@ -650,6 +681,18 @@ justify-content: right; } } + + > .invoice-total-status { + > .invoice-total { + margin-right: 8px; + } + + > .invoice-status { + display: flex; + align-items: center; + justify-content: right; + } + } } > .item-details-container { diff --git a/erpnext/selling/page/point_of_sale/pos_item_cart.js b/erpnext/selling/page/point_of_sale/pos_item_cart.js index d67ad067c26..6f3c1d9ccb6 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_cart.js +++ b/erpnext/selling/page/point_of_sale/pos_item_cart.js @@ -342,7 +342,13 @@ erpnext.PointOfSale.ItemCart = class { if (customer) { return new Promise((resolve) => { frappe.db - .get_value("Customer", customer, ["email_id", "mobile_no", "image", "loyalty_program"]) + .get_value("Customer", customer, [ + "email_id", + "customer_name", + "mobile_no", + "image", + "loyalty_program", + ]) .then(({ message }) => { const { loyalty_program } = message; // if loyalty program then fetch loyalty points too @@ -439,7 +445,7 @@ erpnext.PointOfSale.ItemCart = class { update_customer_section() { const me = this; - const { customer, email_id = "", mobile_no = "", image } = this.customer_info || {}; + const { customer, customer_name, email_id = "", mobile_no = "", image } = this.customer_info || {}; if (customer) { this.$customer_section.html( @@ -447,7 +453,7 @@ erpnext.PointOfSale.ItemCart = class {
${this.get_customer_image()}
-
${customer}
+
${customer_name}
${get_customer_description()}
@@ -867,7 +873,7 @@ erpnext.PointOfSale.ItemCart = class { toggle_customer_info(show) { if (show) { - const { customer } = this.customer_info || {}; + const { customer, customer_name } = this.customer_info || {}; this.$cart_container.css("display", "none"); this.$customer_section.css({ @@ -886,8 +892,8 @@ erpnext.PointOfSale.ItemCart = class {
${this.get_customer_image()}
-
${customer}
-
+
${customer_name}
+
${customer}
@@ -896,7 +902,10 @@ erpnext.PointOfSale.ItemCart = class {
-
${__("Recent Transactions")}
` +
+
${__("Recent Transactions")}
+
+
` ); // transactions need to be in diff div from sticky elem for scrolling this.$customer_section.append(`
`); @@ -1005,7 +1014,7 @@ erpnext.PointOfSale.ItemCart = class { const elapsed_time = moment(res[0].posting_date + " " + res[0].posting_time).fromNow(); this.$customer_section - .find(".customer-desc") + .find(".last-transaction") .html(`${__("Last transacted")} ${__(elapsed_time)}`); res.forEach((invoice) => { @@ -1027,7 +1036,7 @@ erpnext.PointOfSale.ItemCart = class {
- ${format_currency(invoice.grand_total, invoice.currency, 0) || 0} + ${format_currency(invoice.grand_total, invoice.currency, frappe.sys_defaults.currency_precision) || 0}
From 894bb703f625de203b49474907ade97418e72901 Mon Sep 17 00:00:00 2001 From: 0xD0M1M0 <76812428+0xD0M1M0@users.noreply.github.com> Date: Thu, 26 Jun 2025 20:23:35 +0200 Subject: [PATCH 12/35] chore: improve some german translations (#48283) --- erpnext/translations/de.csv | 46 ++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/erpnext/translations/de.csv b/erpnext/translations/de.csv index d19d3c5779f..58ac3e34b13 100644 --- a/erpnext/translations/de.csv +++ b/erpnext/translations/de.csv @@ -1346,8 +1346,8 @@ Level,Ebene, Liability,Verbindlichkeit, Limit Crossed,Grenze überschritten, Link to Material Request,Verknüpfung zur Materialanforderung, -List of all share transactions,Liste aller Aktientransaktionen, -List of available Shareholders with folio numbers,Liste der verfügbaren Aktionäre mit Folio-Nummern, +List of all share transactions,Liste aller Anteilstransaktionen, +List of available Shareholders with folio numbers,Liste der verfügbaren Anteilseigner mit Folio-Nummern, Loading Payment System,Zahlungssystem wird geladen, Loan,Darlehen, Loan Start Date and Loan Period are mandatory to save the Invoice Discounting,"Das Ausleihbeginndatum und die Ausleihdauer sind obligatorisch, um die Rechnungsdiskontierung zu speichern", @@ -1564,7 +1564,7 @@ No items listed,Keine Artikel aufgeführt, No items to be received are overdue,Keine zu übergebenden Artikel sind überfällig, No material request created,Es wurde keine Materialanforderung erstellt, No of Interactions,Anzahl der Interaktionen, -No of Shares,Anzahl der Aktien, +No of Shares,Anzahl der Anteile, No pending Material Requests found to link for the given items.,"Es wurden keine ausstehenden Materialanforderungen gefunden, die für die angegebenen Artikel verknüpft sind.", No products found,Keine Produkte gefunden, No products found.,Keine Produkte gefunden, @@ -2103,7 +2103,7 @@ Rate,Preis, Rate:,Bewertung:, Rating,Wertung, Raw Material,Rohmaterial, -Raw Materials,Rohes Material, +Raw Materials,Rohmaterial, Raw Materials cannot be blank.,Rohmaterial kann nicht leer sein, Re-open,Wiedereröffnen, Read blog,Blog lesen, @@ -2488,11 +2488,11 @@ Setup default values for POS Invoices,Standardwerte für POS-Rechnungen einricht Setup mode of POS (Online / Offline),Einrichtungsmodus des POS (Online / Offline), Setup your Institute in ERPNext,Richten Sie Ihr Institut in ERPNext ein, Share Balance,Anteilsbestand, -Share Ledger,Aktienbuch, -Share Management,Aktienverwaltung, -Share Transfer,Weitergabe übertragen, -Share Type,Art der Freigabe, -Shareholder,Aktionär, +Share Ledger,Verzeichnis der Anteilseigner, +Share Management,Anteilsverwaltung, +Share Transfer,Anteilsübertragung, +Share Type,Art des Anteils, +Shareholder,Anteilseigner, Ship To State,Versende nach Land, Shipments,Lieferungen, Shipping Address,Lieferadresse, @@ -2724,24 +2724,24 @@ The Term End Date cannot be later than the Year End Date of the Academic Year to The Term Start Date cannot be earlier than the Year Start Date of the Academic Year to which the term is linked (Academic Year {}). Please correct the dates and try again.,Der Begriff Startdatum kann nicht früher als das Jahr Anfang des Akademischen Jahres an dem der Begriff verknüpft ist (Akademisches Jahr {}). Bitte korrigieren Sie die Daten und versuchen Sie es erneut., The Year End Date cannot be earlier than the Year Start Date. Please correct the dates and try again.,Das Jahr Enddatum kann nicht früher als das Jahr Startdatum. Bitte korrigieren Sie die Daten und versuchen Sie es erneut., The amount of {0} set in this payment request is different from the calculated amount of all payment plans: {1}. Make sure this is correct before submitting the document.,"Der in dieser Zahlungsanforderung festgelegte Betrag von {0} unterscheidet sich von dem berechneten Betrag aller Zahlungspläne: {1}. Stellen Sie sicher, dass dies korrekt ist, bevor Sie das Dokument einreichen.", -The field From Shareholder cannot be blank,Das Feld Von Aktionär darf nicht leer sein, -The field To Shareholder cannot be blank,Das Feld An Aktionär darf nicht leer sein, -The fields From Shareholder and To Shareholder cannot be blank,Die Felder Von Aktionär und An Anteilinhaber dürfen nicht leer sein, +The field From Shareholder cannot be blank,Das Feld Von Anteilseigner darf nicht leer sein, +The field To Shareholder cannot be blank,Das Feld An Anteilseigner darf nicht leer sein, +The fields From Shareholder and To Shareholder cannot be blank,Die Felder Von Anteilseigner und An Anteilseigner dürfen nicht leer sein, The folio numbers are not matching,Die Folionummern stimmen nicht überein, The holiday on {0} is not between From Date and To Date,Der Urlaub am {0} ist nicht zwischen dem Von-Datum und dem Bis-Datum, The name of the institute for which you are setting up this system.,"Der Name des Instituts, für die Sie setzen dieses System.", The name of your company for which you are setting up this system.,"Firma des Unternehmens, für das dieses System eingerichtet wird.", -The number of shares and the share numbers are inconsistent,Die Anzahl der Aktien und die Aktienanzahl sind inkonsistent, +The number of shares and the share numbers are inconsistent,Die Anzahl der Anteile und die Anteilsanzahl sind inkonsistent, The payment gateway account in plan {0} is different from the payment gateway account in this payment request,Das Zahlungsgatewaykonto in Plan {0} unterscheidet sich von dem Zahlungsgatewaykonto in dieser Zahlungsanforderung, The selected BOMs are not for the same item,Die ausgewählten Stücklisten sind nicht für den gleichen Artikel, The selected item cannot have Batch,Der ausgewählte Artikel kann keine Charge haben, The seller and the buyer cannot be the same,Der Verkäufer und der Käufer können nicht identisch sein, -The shareholder does not belong to this company,Der Aktionär gehört nicht zu diesem Unternehmen, -The shares already exist,Die Aktien sind bereits vorhanden, -The shares don't exist with the {0},Die Freigaben existieren nicht mit der {0}, +The shareholder does not belong to this company,Der Anteilseigner gehört nicht zu diesem Unternehmen, +The shares already exist,Die Anteile sind bereits vorhanden, +The shares don't exist with the {0},Die Anteile existieren nicht mit der {0}, "The task has been enqueued as a background job. In case there is any issue on processing in background, the system will add a comment about the error on this Stock Reconciliation and revert to the Draft stage","Die Aufgabe wurde als Hintergrundjob in die Warteschlange gestellt. Falls bei der Verarbeitung im Hintergrund Probleme auftreten, fügt das System einen Kommentar zum Fehler in dieser Bestandsabstimmung hinzu und kehrt zum Entwurfsstadium zurück", "Then Pricing Rules are filtered out based on Customer, Customer Group, Territory, Supplier, Supplier Type, Campaign, Sales Partner etc.","Dann werden Preisregeln bezogen auf Kunde, Kundengruppe, Region, Lieferant, Lieferantentyp, Kampagne, Vertriebspartner usw. ausgefiltert", -"There are inconsistencies between the rate, no of shares and the amount calculated","Es gibt Unstimmigkeiten zwischen dem Kurs, der Anzahl der Aktien und dem berechneten Betrag", +"There are inconsistencies between the rate, no of shares and the amount calculated","Es gibt Unstimmigkeiten zwischen dem Kurs, der Anzahl der Anteile und dem berechneten Betrag", There can be multiple tiered collection factor based on the total spent. But the conversion factor for redemption will always be same for all the tier.,Abhängig von der Gesamtausgabenanzahl kann es einen mehrstufigen Sammelfaktor geben. Der Umrechnungsfaktor für die Einlösung wird jedoch für alle Stufen immer gleich sein., There can only be 1 Account per Company in {0} {1},Es kann nur EIN Konto pro Unternehmen in {0} {1} geben, "There can only be one Shipping Rule Condition with 0 or blank value for ""To Value""","Es kann nur eine Versandbedingung mit dem Wert ""0"" oder ""leer"" für ""Bis-Wert"" geben", @@ -3648,7 +3648,7 @@ Reset,Zurücksetzen, Reset Service Level Agreement,Service Level Agreement zurücksetzen, Resetting Service Level Agreement.,Service Level Agreement zurücksetzen., Return amount cannot be greater unclaimed amount,Der Rückgabebetrag kann nicht höher sein als der nicht beanspruchte Betrag, -Review,Rezension, +Review,Review, Room,Zimmer, Room Type,Zimmertyp, Row # ,Zeile #, @@ -4808,9 +4808,9 @@ To No,Zu Nein, Is Company,Ist Unternehmen, Current State,Aktuellen Zustand, Purchased,Gekauft, -From Shareholder,Vom Aktionär, +From Shareholder,Vom Anteilseigner, From Folio No,Aus Folio Nr, -To Shareholder,An den Aktionär, +To Shareholder,An Anteilseigner, To Folio No,Zu Folio Nein, Equity/Liability Account,Eigenkapital / Verbindlichkeitskonto, Asset Account,Anlagenkonto, @@ -4819,7 +4819,7 @@ ACC-SH-.YYYY.-,ACC-SH-.JJJJ.-, Folio no.,Folio Nr., Address and Contacts,Adresse und Kontaktinformationen, Contact List,Kontaktliste, -Hidden list maintaining the list of contacts linked to Shareholder,"Versteckte Liste, die die Liste der mit dem Aktionär verknüpften Kontakte enthält", +Hidden list maintaining the list of contacts linked to Shareholder,"Versteckte Liste, die die Liste der mit dem Anteilseigner verknüpften Kontakte enthält", Specify conditions to calculate shipping amount,Bedingungen zur Berechnung der Versandkosten angeben, Shipping Rule Label,Bezeichnung der Versandregel, example: Next Day Shipping,Beispiel: Versand am nächsten Tag, @@ -4853,7 +4853,7 @@ Discounts,Rabatte, Additional DIscount Percentage,Zusätzlicher prozentualer Rabatt, Additional DIscount Amount,Zusätzlicher Rabatt, Subscription Invoice,Abonnementrechnung, -Subscription Plan,Abonnement, +Subscription Plan,Abonnementplan, Cost,Kosten, Billing Interval,Abrechnungsintervall, Billing Interval Count,Abrechnungsintervall Anzahl, @@ -6257,7 +6257,7 @@ Blanket Order Item,Rahmenauftragsposition, Ordered Quantity,Bestellte Menge, Item to be manufactured or repacked,Zu fertigender oder umzupackender Artikel, Quantity of item obtained after manufacturing / repacking from given quantities of raw materials,Menge eines Artikels nach der Herstellung/dem Umpacken auf Basis vorgegebener Mengen von Rohmaterial, -Set rate of sub-assembly item based on BOM,Setzen Sie die Menge der Unterbaugruppe auf der Grundlage der Stückliste, +Set rate of sub-assembly item based on BOM,Einzelpreis für Artikel der Unterbaugruppe auf Basis deren Stückliste festlegen, Allow Alternative Item,Alternative Artikel zulassen, Item UOM,Artikelmaßeinheit, Conversion Rate,Wechselkurs, From af55ce0f6c1469977ed4812aa1b2ea8327878905 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 26 Jun 2025 20:49:06 +0200 Subject: [PATCH 13/35] fix: use label "State/Province" for translatability (backport #48273) (#48286) Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> fix: use label "State/Province" for translatability (#48273) --- erpnext/crm/doctype/lead/lead.json | 6 ++++-- erpnext/crm/doctype/opportunity/opportunity.json | 6 ++++-- erpnext/crm/report/lead_details/lead_details.py | 2 +- erpnext/public/js/utils/contact_address_quick_entry.js | 2 +- erpnext/stock/doctype/warehouse/warehouse.json | 6 ++++-- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/erpnext/crm/doctype/lead/lead.json b/erpnext/crm/doctype/lead/lead.json index 038f6bff2eb..d883ef692d5 100644 --- a/erpnext/crm/doctype/lead/lead.json +++ b/erpnext/crm/doctype/lead/lead.json @@ -497,7 +497,7 @@ { "fieldname": "state", "fieldtype": "Data", - "label": "State" + "label": "State/Province" }, { "fieldname": "country", @@ -512,11 +512,12 @@ "show_dashboard": 1 } ], + "grid_page_length": 50, "icon": "fa fa-user", "idx": 5, "image_field": "image", "links": [], - "modified": "2025-01-31 13:40:08.094759", + "modified": "2025-06-26 11:02:01.158901", "modified_by": "Administrator", "module": "CRM", "name": "Lead", @@ -575,6 +576,7 @@ "role": "Sales User" } ], + "row_format": "Dynamic", "search_fields": "lead_name,lead_owner,status", "sender_field": "email_id", "sender_name_field": "lead_name", diff --git a/erpnext/crm/doctype/opportunity/opportunity.json b/erpnext/crm/doctype/opportunity/opportunity.json index 75373398b09..f56286a636c 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.json +++ b/erpnext/crm/doctype/opportunity/opportunity.json @@ -613,7 +613,7 @@ { "fieldname": "state", "fieldtype": "Data", - "label": "State" + "label": "State/Province" }, { "fieldname": "country", @@ -622,10 +622,11 @@ "options": "Country" } ], + "grid_page_length": 50, "icon": "fa fa-info-sign", "idx": 195, "links": [], - "modified": "2024-08-20 04:12:29.095761", + "modified": "2025-06-26 11:16:13.665866", "modified_by": "Administrator", "module": "CRM", "name": "Opportunity", @@ -657,6 +658,7 @@ "write": 1 } ], + "row_format": "Dynamic", "search_fields": "status,transaction_date,party_name,opportunity_type,territory,company", "sender_field": "contact_email", "show_name_in_global_search": 1, diff --git a/erpnext/crm/report/lead_details/lead_details.py b/erpnext/crm/report/lead_details/lead_details.py index 608be6ec912..c3b9a484de9 100644 --- a/erpnext/crm/report/lead_details/lead_details.py +++ b/erpnext/crm/report/lead_details/lead_details.py @@ -58,7 +58,7 @@ def get_columns(): {"label": _("Address"), "fieldname": "address", "fieldtype": "Data", "width": 130}, {"label": _("Postal Code"), "fieldname": "pincode", "fieldtype": "Data", "width": 90}, {"label": _("City"), "fieldname": "city", "fieldtype": "Data", "width": 100}, - {"label": _("State"), "fieldname": "state", "fieldtype": "Data", "width": 100}, + {"label": _("State/Province"), "fieldname": "state", "fieldtype": "Data", "width": 100}, { "label": _("Country"), "fieldname": "country", diff --git a/erpnext/public/js/utils/contact_address_quick_entry.js b/erpnext/public/js/utils/contact_address_quick_entry.js index 2f61dee1994..129b713c6f3 100644 --- a/erpnext/public/js/utils/contact_address_quick_entry.js +++ b/erpnext/public/js/utils/contact_address_quick_entry.js @@ -81,7 +81,7 @@ frappe.ui.form.ContactAddressQuickEntryForm = class ContactAddressQuickEntryForm fieldtype: "Data", }, { - label: __("State"), + label: __("State/Province"), fieldname: "state", fieldtype: "Data", }, diff --git a/erpnext/stock/doctype/warehouse/warehouse.json b/erpnext/stock/doctype/warehouse/warehouse.json index f2d157b0ae4..39b81dc68dd 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.json +++ b/erpnext/stock/doctype/warehouse/warehouse.json @@ -174,7 +174,7 @@ { "fieldname": "state", "fieldtype": "Data", - "label": "State", + "label": "State/Province", "oldfieldname": "state", "oldfieldtype": "Select" }, @@ -259,11 +259,12 @@ "label": "Is Rejected Warehouse" } ], + "grid_page_length": 50, "icon": "fa fa-building", "idx": 1, "is_tree": 1, "links": [], - "modified": "2024-08-14 16:08:15.733597", + "modified": "2025-06-26 11:19:04.673115", "modified_by": "Administrator", "module": "Stock", "name": "Warehouse", @@ -316,6 +317,7 @@ "role": "Manufacturing User" } ], + "row_format": "Dynamic", "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", From a0db227a7af8feb2fa00e4e8c90317ace83408b3 Mon Sep 17 00:00:00 2001 From: ljain112 Date: Fri, 27 Jun 2025 14:12:14 +0530 Subject: [PATCH 14/35] perf: use set_value for updating bank clearance_date --- erpnext/accounts/doctype/bank_clearance/bank_clearance.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py index 55ddfa698b7..6d1040a02ad 100644 --- a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py +++ b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py @@ -118,8 +118,9 @@ class BankClearance(Document): else: # using db_set to trigger notification - payment_entry = frappe.get_doc(d.payment_document, d.payment_entry) - payment_entry.db_set("clearance_date", d.clearance_date) + frappe.db.set_value( + d.payment_document, d.payment_entry, "clearance_date", d.clearance_date + ) clearance_date_updated = True From 7de15b74d411236f0244e71c11e79f220df9c5a6 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Fri, 27 Jun 2025 14:50:49 +0530 Subject: [PATCH 15/35] fix: option to pick serial / batch for asset repair (cherry picked from commit ae77c609fff563223d6730413b354e4c340c0589) # Conflicts: # erpnext/assets/doctype/asset_repair/asset_repair.py # erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json --- .../doctype/asset_repair/asset_repair.js | 35 +++++++++++++++++++ .../doctype/asset_repair/asset_repair.py | 13 +++++++ .../asset_repair_consumed_item.json | 20 +++++++++-- erpnext/public/js/controllers/transaction.js | 2 +- .../stock/doctype/stock_entry/stock_entry.py | 22 ++++++++++++ erpnext/stock/serial_batch_bundle.py | 22 ++++++++---- 6 files changed, 104 insertions(+), 10 deletions(-) diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.js b/erpnext/assets/doctype/asset_repair/asset_repair.js index 67ce6e6f7ef..e4c55d4363d 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.js +++ b/erpnext/assets/doctype/asset_repair/asset_repair.js @@ -3,6 +3,8 @@ frappe.ui.form.on("Asset Repair", { setup: function (frm) { + frm.ignore_doctypes_on_cancel_all = ["Serial and Batch Bundle"]; + frm.fields_dict.cost_center.get_query = function (doc) { return { filters: { @@ -167,4 +169,37 @@ frappe.ui.form.on("Asset Repair Consumed Item", { var row = locals[cdt][cdn]; frappe.model.set_value(cdt, cdn, "total_value", row.consumed_quantity * row.valuation_rate); }, + + pick_serial_and_batch(frm, cdt, cdn) { + let item = locals[cdt][cdn]; + let doc = frm.doc; + + frappe.db.get_value("Item", item.item_code, ["has_batch_no", "has_serial_no"]).then((r) => { + if (r.message && (r.message.has_batch_no || r.message.has_serial_no)) { + item.has_serial_no = r.message.has_serial_no; + item.has_batch_no = r.message.has_batch_no; + item.qty = item.consumed_quantity; + item.type_of_transaction = item.consumed_quantity > 0 ? "Outward" : "Inward"; + + item.title = item.has_serial_no ? __("Select Serial No") : __("Select Batch No"); + + if (item.has_serial_no && item.has_batch_no) { + item.title = __("Select Serial and Batch"); + } + frm.doc.posting_date = frappe.datetime.get_today(); + frm.doc.posting_time = frappe.datetime.now_time(); + + new erpnext.SerialBatchPackageSelector(frm, item, (r) => { + if (r) { + frappe.model.set_value(item.doctype, item.name, { + serial_and_batch_bundle: r.name, + use_serial_batch_fields: 0, + valuation_rate: r.avg_rate, + consumed_quantity: Math.abs(r.total_qty), + }); + } + }); + } + }); + }, }); diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py index 3938ae06b50..3b73da133df 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -96,6 +96,7 @@ class AssetRepair(AccountsController): if self.get("stock_consumption") or self.get("capitalize_repair_cost"): self.asset_doc.flags.increase_in_asset_value_due_to_repair = True +<<<<<<< HEAD self.increase_asset_value() total_repair_cost = self.get_total_value_of_stock_consumed() @@ -132,6 +133,16 @@ class AssetRepair(AccountsController): ) def before_cancel(self): +======= + def cancel_sabb(self): + for row in self.stock_items: + if sabb := row.serial_and_batch_bundle: + row.db_set("serial_and_batch_bundle", None) + doc = frappe.get_doc("Serial and Batch Bundle", sabb) + doc.cancel() + + def on_cancel(self): +>>>>>>> ae77c609ff (fix: option to pick serial / batch for asset repair) self.asset_doc = frappe.get_doc("Asset", self.asset) self.asset_doc.flags.increase_in_asset_value_due_to_repair = False @@ -172,6 +183,8 @@ class AssetRepair(AccountsController): ), ) + self.cancel_sabb() + def after_delete(self): frappe.get_doc("Asset", self.asset).set_status() diff --git a/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json b/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json index c4c13ce413b..76ff7f5e399 100644 --- a/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json +++ b/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json @@ -11,6 +11,8 @@ "consumed_quantity", "total_value", "serial_no", + "column_break_xzfr", + "pick_serial_and_batch", "serial_and_batch_bundle" ], "fields": [ @@ -61,19 +63,33 @@ "label": "Warehouse", "options": "Warehouse", "reqd": 1 + }, + { + "fieldname": "pick_serial_and_batch", + "fieldtype": "Button", + "label": "Pick Serial / Batch" + }, + { + "fieldname": "column_break_xzfr", + "fieldtype": "Column Break" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-06-13 12:01:47.147333", + "modified": "2025-06-27 14:52:56.311166", "modified_by": "Administrator", "module": "Assets", "name": "Asset Repair Consumed Item", "owner": "Administrator", "permissions": [], +<<<<<<< HEAD "sort_field": "modified", +======= + "row_format": "Dynamic", + "sort_field": "creation", +>>>>>>> ae77c609ff (fix: option to pick serial / batch for asset repair) "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index bb0c69b3a94..3f6f9ca5e20 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -8,7 +8,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe let me = this; this.set_fields_onload_for_line_item(); - this.frm.ignore_doctypes_on_cancel_all = ['Serial and Batch Bundle']; + this.frm.ignore_doctypes_on_cancel_all = ["Serial and Batch Bundle"]; frappe.flags.hide_serial_batch_dialog = true; frappe.ui.form.on(this.frm.doctype + " Item", "rate", function(frm, cdt, cdn) { diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 45afd1a0ad4..d204a421f68 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -263,6 +263,7 @@ class StockEntry(StockController): self.set_material_request_transfer_status("Completed") def on_cancel(self): + self.delink_asset_repair_sabb() self.validate_closed_subcontracting_order() self.update_subcontract_order_supplied_items() self.update_subcontracting_order_status() @@ -364,6 +365,27 @@ class StockEntry(StockController): ): frappe.delete_doc("Stock Entry", d.name) + def delink_asset_repair_sabb(self): + if not self.asset_repair: + return + + for row in self.items: + if row.serial_and_batch_bundle: + voucher_detail_no = frappe.db.get_value( + "Asset Repair Consumed Item", + {"parent": self.asset_repair, "serial_and_batch_bundle": row.serial_and_batch_bundle}, + "name", + ) + + doc = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle) + doc.db_set( + { + "voucher_type": "Asset Repair", + "voucher_no": self.asset_repair, + "voucher_detail_no": voucher_detail_no, + } + ) + def set_transfer_qty(self): self.validate_qty_is_not_zero() for item in self.get("items"): diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 99ad11326ea..8d9634db965 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -257,7 +257,7 @@ class SerialBatchBundle: frappe.throw(_(msg)) def delink_serial_and_batch_bundle(self): - if self.is_pos_transaction(): + if self.is_pos_or_asset_repair_transaction(): return update_values = { @@ -306,21 +306,29 @@ class SerialBatchBundle: self.cancel_serial_and_batch_bundle() def cancel_serial_and_batch_bundle(self): - if self.is_pos_transaction(): + if self.is_pos_or_asset_repair_transaction(): return doc = frappe.get_cached_doc("Serial and Batch Bundle", self.sle.serial_and_batch_bundle) if doc.docstatus == 1: doc.cancel() - def is_pos_transaction(self): + def is_pos_or_asset_repair_transaction(self): + voucher_type = frappe.get_cached_value( + "Serial and Batch Bundle", self.sle.serial_and_batch_bundle, "voucher_type" + ) + if ( self.sle.voucher_type == "Sales Invoice" and self.sle.serial_and_batch_bundle - and frappe.get_cached_value( - "Serial and Batch Bundle", self.sle.serial_and_batch_bundle, "voucher_type" - ) - == "POS Invoice" + and voucher_type == "POS Invoice" + ): + return True + + if ( + self.sle.voucher_type == "Stock Entry" + and self.sle.serial_and_batch_bundle + and voucher_type == "Asset Repair" ): return True From 0e2bca4b34d502ddd80223503a768021d1f1eb76 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Fri, 27 Jun 2025 16:07:08 +0530 Subject: [PATCH 16/35] fix: not able to save material request (cherry picked from commit c5e36eb3238f8e9dc6d078ad7ce21665e2b09268) --- erpnext/controllers/accounts_controller.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 73809ad2057..cf0707d9efa 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1134,6 +1134,10 @@ class AccountsController(TransactionBase): return True def set_taxes_and_charges(self): + if self.doctype == "Material Request": + # Material Request does not have taxes + return + if self.get("taxes") or self.get("is_pos"): return From a1eab1db74b93f018b41911706885c499aaae9a5 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Sun, 29 Jun 2025 22:46:39 +0530 Subject: [PATCH 17/35] fix: validate asset before repair (cherry picked from commit c6baa34812765b1db365842ec24803c86b37f5b7) # Conflicts: # erpnext/assets/doctype/asset_repair/asset_repair.json --- .../assets/doctype/asset_repair/asset_repair.json | 12 +++++++++++- erpnext/assets/doctype/asset_repair/asset_repair.py | 9 +++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.json b/erpnext/assets/doctype/asset_repair/asset_repair.json index c98f5a8d7f4..5d7ce569fbb 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.json +++ b/erpnext/assets/doctype/asset_repair/asset_repair.json @@ -138,6 +138,7 @@ "fieldtype": "Link", "in_list_view": 1, "label": "Asset", + "link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Fully Depreciated\",\"Sold\",\"Scrapped\",null]]]", "options": "Asset", "reqd": 1 }, @@ -248,7 +249,11 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], +<<<<<<< HEAD "modified": "2024-06-13 16:14:14.398356", +======= + "modified": "2025-06-29 22:30:00.589597", +>>>>>>> c6baa34812 (fix: validate asset before repair) "modified_by": "Administrator", "module": "Assets", "name": "Asset Repair", @@ -286,10 +291,15 @@ "write": 1 } ], +<<<<<<< HEAD "sort_field": "modified", +======= + "row_format": "Dynamic", + "sort_field": "creation", +>>>>>>> c6baa34812 (fix: validate asset before repair) "sort_order": "DESC", "states": [], "title_field": "asset_name", "track_changes": 1, "track_seen": 1 -} \ No newline at end of file +} diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py index 3938ae06b50..9fbcb0a7d50 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -53,6 +53,7 @@ class AssetRepair(AccountsController): def validate(self): self.asset_doc = frappe.get_doc("Asset", self.asset) + self.validate_asset() self.validate_dates() self.update_status() @@ -60,6 +61,14 @@ class AssetRepair(AccountsController): self.set_stock_items_cost() self.calculate_total_repair_cost() + def validate_asset(self): + if self.asset_doc.status in ("Sold", "Fully Depreciated", "Scrapped"): + frappe.throw( + _("Asset {0} is in {1} status and cannot be repaired.").format( + get_link_to_form("Asset", self.asset), self.asset_doc.status + ) + ) + def validate_dates(self): if self.completion_date and (self.failure_date > self.completion_date): frappe.throw( From 6f24c02121916a651c21a221a94d13158ab4b71b Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Mon, 30 Jun 2025 00:29:23 +0530 Subject: [PATCH 18/35] test: asset status validation (cherry picked from commit cfe04a2aafd3be9aa2b4afefac58d7f50f64aa30) # Conflicts: # erpnext/assets/doctype/asset_repair/test_asset_repair.py --- .../doctype/asset_repair/test_asset_repair.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/erpnext/assets/doctype/asset_repair/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py index 44d08869a63..427525225d4 100644 --- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py @@ -4,11 +4,17 @@ import unittest import frappe +<<<<<<< HEAD from frappe.utils import flt, nowdate, nowtime, today +======= +from frappe.tests import IntegrationTestCase +from frappe.utils import add_months, flt, get_first_day, nowdate, nowtime, today +>>>>>>> cfe04a2aaf (test: asset status validation) from erpnext.assets.doctype.asset.asset import ( get_asset_account, get_asset_value_after_depreciation, + make_sales_invoice, ) from erpnext.assets.doctype.asset.test_asset import ( create_asset, @@ -33,6 +39,33 @@ class TestAssetRepair(unittest.TestCase): create_item("_Test Stock Item") frappe.db.sql("delete from `tabTax Rule`") + def test_asset_status(self): + date = nowdate() + purchase_date = add_months(get_first_day(date), -2) + + asset = create_asset( + calculate_depreciation=1, + available_for_use_date=purchase_date, + purchase_date=purchase_date, + expected_value_after_useful_life=10000, + total_number_of_depreciations=10, + frequency_of_depreciation=1, + submit=1, + ) + + si = make_sales_invoice(asset=asset.name, item_code="Macbook Pro", company="_Test Company") + si.customer = "_Test Customer" + si.due_date = date + si.get("items")[0].rate = 25000 + si.insert() + si.submit() + + asset.reload() + self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Sold") + asset_repair = frappe.new_doc("Asset Repair") + asset_repair.update({"company": "_Test Company", "asset": asset.name, "asset_name": asset.asset_name}) + self.assertRaises(frappe.ValidationError, asset_repair.save) + def test_update_status(self): asset = create_asset(submit=1) initial_status = asset.status From f31b0085027f8aa524125490899c54893305c7ed Mon Sep 17 00:00:00 2001 From: Khushi Rawat <142375893+khushi8112@users.noreply.github.com> Date: Mon, 30 Jun 2025 01:03:49 +0530 Subject: [PATCH 19/35] chore: resolved conflicts --- erpnext/assets/doctype/asset_repair/asset_repair.json | 9 --------- 1 file changed, 9 deletions(-) diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.json b/erpnext/assets/doctype/asset_repair/asset_repair.json index 5d7ce569fbb..a28afa280de 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.json +++ b/erpnext/assets/doctype/asset_repair/asset_repair.json @@ -249,11 +249,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], -<<<<<<< HEAD - "modified": "2024-06-13 16:14:14.398356", -======= "modified": "2025-06-29 22:30:00.589597", ->>>>>>> c6baa34812 (fix: validate asset before repair) "modified_by": "Administrator", "module": "Assets", "name": "Asset Repair", @@ -291,12 +287,7 @@ "write": 1 } ], -<<<<<<< HEAD "sort_field": "modified", -======= - "row_format": "Dynamic", - "sort_field": "creation", ->>>>>>> c6baa34812 (fix: validate asset before repair) "sort_order": "DESC", "states": [], "title_field": "asset_name", From 2b37287b1954b71b0a0a13fbb7362c6a250c6485 Mon Sep 17 00:00:00 2001 From: Khushi Rawat <142375893+khushi8112@users.noreply.github.com> Date: Mon, 30 Jun 2025 01:04:54 +0530 Subject: [PATCH 20/35] chore: resolved conflicts --- erpnext/assets/doctype/asset_repair/test_asset_repair.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/erpnext/assets/doctype/asset_repair/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py index 427525225d4..5c0a18baccb 100644 --- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py @@ -4,12 +4,7 @@ import unittest import frappe -<<<<<<< HEAD -from frappe.utils import flt, nowdate, nowtime, today -======= -from frappe.tests import IntegrationTestCase from frappe.utils import add_months, flt, get_first_day, nowdate, nowtime, today ->>>>>>> cfe04a2aaf (test: asset status validation) from erpnext.assets.doctype.asset.asset import ( get_asset_account, From cfc8c610fa793777b4f1c438abd32ea74db53548 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Sun, 29 Jun 2025 21:45:57 +0530 Subject: [PATCH 21/35] fix: accounting entries for standalone credit notes (cherry picked from commit 52177cffcde045fc7f65ac110b61c21540a4400c) --- .../purchase_invoice/purchase_invoice.py | 9 ++------- erpnext/stock/stock_ledger.py | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index fdfb50a6f42..3050f3ac266 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -1336,17 +1336,12 @@ class PurchaseInvoice(BuyingController): warehouse_debit_amount = stock_amount - elif self.is_return and self.update_stock and self.is_internal_supplier and warehouse_debit_amount: + elif self.is_return and self.update_stock and (self.is_internal_supplier or not self.return_against): net_rate = item.base_net_amount if item.sales_incoming_rate: # for internal transfer net_rate = item.qty * item.sales_incoming_rate - stock_amount = ( - net_rate - + item.item_tax_amount - + flt(item.landed_cost_voucher_amount) - + flt(item.get("amount_difference_with_purchase_invoice")) - ) + stock_amount = net_rate + item.item_tax_amount + flt(item.landed_cost_voucher_amount) if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision): cost_of_goods_sold_account = self.get_company_default("default_expense_account") diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 7f5984fb0b7..f742f52daee 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1254,12 +1254,19 @@ class update_entries_after: def update_rate_on_purchase_receipt(self, sle, outgoing_rate): if frappe.db.exists(sle.voucher_type + " Item", sle.voucher_detail_no): - if sle.voucher_type in ["Purchase Receipt", "Purchase Invoice"] and frappe.get_cached_value( - sle.voucher_type, sle.voucher_no, "is_internal_supplier" - ): - frappe.db.set_value( - f"{sle.voucher_type} Item", sle.voucher_detail_no, "valuation_rate", sle.outgoing_rate + if sle.voucher_type in ["Purchase Receipt", "Purchase Invoice"]: + details = frappe.get_cached_value( + sle.voucher_type, + sle.voucher_no, + ["is_internal_supplier", "is_return", "return_against"], + as_dict=True, ) + if details.is_internal_supplier or (details.is_return and not details.return_against): + rate = outgoing_rate if details.is_return else sle.outgoing_rate + + frappe.db.set_value( + f"{sle.voucher_type} Item", sle.voucher_detail_no, "valuation_rate", rate + ) else: frappe.db.set_value( "Purchase Receipt Item Supplied", sle.voucher_detail_no, "rate", outgoing_rate From 9b8fffd1d4457e47b00d06c624b0440de112967d Mon Sep 17 00:00:00 2001 From: ravibharathi656 Date: Thu, 26 Jun 2025 18:09:03 +0530 Subject: [PATCH 22/35] fix: use company default currency in amount_eligible_for_commission (cherry picked from commit 7c7b392789a5ffe79826857fd409057a6af1958b) --- erpnext/accounts/doctype/sales_invoice/sales_invoice.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index 0c19e79ccd4..33df4882af9 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -2024,6 +2024,7 @@ "fieldname": "amount_eligible_for_commission", "fieldtype": "Currency", "label": "Amount Eligible for Commission", + "options": "Company:company:default_currency", "read_only": 1 }, { @@ -2188,7 +2189,7 @@ "link_fieldname": "consolidated_invoice" } ], - "modified": "2025-03-17 19:32:31.809658", + "modified": "2025-06-26 14:06:56.773552", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice", From ab61b46a01efd0acb074716cd53f542d4a04848f Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 30 Jun 2025 10:09:59 +0530 Subject: [PATCH 23/35] chore: fix conflicts --- .../asset_repair_consumed_item.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json b/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json index 76ff7f5e399..c502b65ab7f 100644 --- a/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json +++ b/erpnext/assets/doctype/asset_repair_consumed_item/asset_repair_consumed_item.json @@ -83,12 +83,7 @@ "name": "Asset Repair Consumed Item", "owner": "Administrator", "permissions": [], -<<<<<<< HEAD "sort_field": "modified", -======= - "row_format": "Dynamic", - "sort_field": "creation", ->>>>>>> ae77c609ff (fix: option to pick serial / batch for asset repair) "sort_order": "DESC", "states": [], "track_changes": 1 From 352642096e37f2035b3ba28d5a1a72f381531796 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 30 Jun 2025 10:12:54 +0530 Subject: [PATCH 24/35] chore: fix conflicts --- erpnext/assets/doctype/asset_repair/asset_repair.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py index 3b73da133df..5cd9d522494 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -96,7 +96,6 @@ class AssetRepair(AccountsController): if self.get("stock_consumption") or self.get("capitalize_repair_cost"): self.asset_doc.flags.increase_in_asset_value_due_to_repair = True -<<<<<<< HEAD self.increase_asset_value() total_repair_cost = self.get_total_value_of_stock_consumed() @@ -132,17 +131,15 @@ class AssetRepair(AccountsController): ), ) - def before_cancel(self): -======= + def cancel_sabb(self): for row in self.stock_items: if sabb := row.serial_and_batch_bundle: row.db_set("serial_and_batch_bundle", None) doc = frappe.get_doc("Serial and Batch Bundle", sabb) doc.cancel() - - def on_cancel(self): ->>>>>>> ae77c609ff (fix: option to pick serial / batch for asset repair) + + def before_cancel(self): self.asset_doc = frappe.get_doc("Asset", self.asset) self.asset_doc.flags.increase_in_asset_value_due_to_repair = False From ac587b9c94c60e9ac436ba0da3b7b3a6fad47803 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 30 Jun 2025 10:55:56 +0530 Subject: [PATCH 25/35] chore: fix conflicts --- erpnext/assets/doctype/asset_repair/asset_repair.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py index 5cd9d522494..c50b70adeca 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -131,14 +131,13 @@ class AssetRepair(AccountsController): ), ) - def cancel_sabb(self): for row in self.stock_items: if sabb := row.serial_and_batch_bundle: row.db_set("serial_and_batch_bundle", None) doc = frappe.get_doc("Serial and Batch Bundle", sabb) doc.cancel() - + def before_cancel(self): self.asset_doc = frappe.get_doc("Asset", self.asset) From ce7dbf309004ca9e24e985bd0c7ab72801d2357f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 24 Jun 2025 17:40:41 +0530 Subject: [PATCH 26/35] refactor: bom stock report (cherry picked from commit ee4e0c646d5117d7ebf461f125b90232711db95e) --- .../bom_stock_report/bom_stock_report.py | 52 +++++++++---------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py index 96a6822cd11..5fe4d63ccbf 100644 --- a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py +++ b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py @@ -1,12 +1,10 @@ # Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt - import frappe from frappe import _ from frappe.query_builder.functions import Floor, Sum from frappe.utils import cint -from pypika.terms import ExistsCriterion def execute(filters=None): @@ -20,8 +18,7 @@ def execute(filters=None): def get_columns(): - """return columns""" - columns = [ + return [ _("Item") + ":Link/Item:150", _("Item Name") + "::240", _("Description") + "::300", @@ -32,55 +29,54 @@ def get_columns(): _("Enough Parts to Build") + ":Float:200", ] - return columns - def get_bom_stock(filters): qty_to_produce = filters.get("qty_to_produce") if cint(qty_to_produce) <= 0: frappe.throw(_("Quantity to Produce should be greater than zero.")) - if filters.get("show_exploded_view"): - bom_item_table = "BOM Explosion Item" - else: - bom_item_table = "BOM Item" + bom_item_table = "BOM Explosion Item" if filters.get("show_exploded_view") else "BOM Item" - warehouse_details = frappe.db.get_value("Warehouse", filters.get("warehouse"), ["lft", "rgt"], as_dict=1) + warehouse = filters.get("warehouse") + warehouse_details = frappe.db.get_value("Warehouse", warehouse, ["lft", "rgt"], as_dict=1) BOM = frappe.qb.DocType("BOM") BOM_ITEM = frappe.qb.DocType(bom_item_table) BIN = frappe.qb.DocType("Bin") WH = frappe.qb.DocType("Warehouse") - CONDITIONS = () if warehouse_details: - CONDITIONS = ExistsCriterion( - frappe.qb.from_(WH) - .select(WH.name) - .where( - (WH.lft >= warehouse_details.lft) - & (WH.rgt <= warehouse_details.rgt) - & (BIN.warehouse == WH.name) - ) + bin_subquery = ( + frappe.qb.from_(BIN) + .join(WH) + .on(BIN.warehouse == WH.name) + .select(BIN.item_code, Sum(BIN.actual_qty).as_("actual_qty")) + .where((WH.lft >= warehouse_details.lft) & (WH.rgt <= warehouse_details.rgt)) + .groupby(BIN.item_code) ) else: - CONDITIONS = BIN.warehouse == filters.get("warehouse") + bin_subquery = ( + frappe.qb.from_(BIN) + .select(BIN.item_code, Sum(BIN.actual_qty).as_("actual_qty")) + .where(BIN.warehouse == warehouse) + .groupby(BIN.item_code) + ) QUERY = ( frappe.qb.from_(BOM) - .inner_join(BOM_ITEM) + .join(BOM_ITEM) .on(BOM.name == BOM_ITEM.parent) - .left_join(BIN) - .on((BOM_ITEM.item_code == BIN.item_code) & (CONDITIONS)) + .left_join(bin_subquery) + .on(BOM_ITEM.item_code == bin_subquery.item_code) .select( BOM_ITEM.item_code, BOM_ITEM.item_name, BOM_ITEM.description, - BOM_ITEM.stock_qty, + Sum(BOM_ITEM.stock_qty), BOM_ITEM.stock_uom, - BOM_ITEM.stock_qty * qty_to_produce / BOM.quantity, - BIN.actual_qty.as_("actual_qty"), - Sum(Floor(BIN.actual_qty / (BOM_ITEM.stock_qty * qty_to_produce / BOM.quantity))), + (Sum(BOM_ITEM.stock_qty) * qty_to_produce) / BOM.quantity, + bin_subquery.actual_qty, + Floor(bin_subquery.actual_qty / ((Sum(BOM_ITEM.stock_qty) * qty_to_produce) / BOM.quantity)), ) .where((BOM_ITEM.parent == filters.get("bom")) & (BOM_ITEM.parenttype == "BOM")) .groupby(BOM_ITEM.item_code) From f479675ce655fa32dcf44570e3c50a8ceb936a2e Mon Sep 17 00:00:00 2001 From: "Abdallah A. Zaqout" <26047413+zaqoutabed@users.noreply.github.com> Date: Sat, 28 Jun 2025 14:32:24 +0300 Subject: [PATCH 27/35] chore: fix translation message (cherry picked from commit bc002937ada89e3fbdeb3ed957ddfae25b63e0b8) --- erpnext/controllers/trends.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/controllers/trends.py b/erpnext/controllers/trends.py index f5046bb4c67..81d5621de0e 100644 --- a/erpnext/controllers/trends.py +++ b/erpnext/controllers/trends.py @@ -47,7 +47,7 @@ def get_columns(filters, trans): def validate_filters(filters): for f in ["Fiscal Year", "Based On", "Period", "Company"]: if not filters.get(f.lower().replace(" ", "_")): - frappe.throw(_("{0} is mandatory").format(f)) + frappe.throw(_("{0} is mandatory").format(_(f))) if not frappe.db.exists("Fiscal Year", filters.get("fiscal_year")): frappe.throw(_("Fiscal Year {0} Does Not Exist").format(filters.get("fiscal_year"))) From f1062c61f62164b11a94a689e3e47731ff4480ae Mon Sep 17 00:00:00 2001 From: Florian HENRY Date: Fri, 27 Jun 2025 12:54:28 +0200 Subject: [PATCH 28/35] fix: default UOMs by new stock Entry created by Stock Level section button (cherry picked from commit e7da4992f3167814a121911978bc00a940b6ed13) --- erpnext/stock/dashboard/item_dashboard.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/dashboard/item_dashboard.js b/erpnext/stock/dashboard/item_dashboard.js index 6fc9e6666a2..17f65ce270c 100644 --- a/erpnext/stock/dashboard/item_dashboard.js +++ b/erpnext/stock/dashboard/item_dashboard.js @@ -51,7 +51,7 @@ erpnext.stock.ItemDashboard = class ItemDashboard { let stock_uom = unescape(element.attr("data-stock-uom")); if (disable_quick_entry) { - open_stock_entry(item, warehouse, entry_type); + open_stock_entry(item, warehouse, entry_type, stock_uom); } else { if (action === "Add") { let rate = unescape($(this).attr("data-rate")); @@ -66,7 +66,7 @@ erpnext.stock.ItemDashboard = class ItemDashboard { } } - function open_stock_entry(item, warehouse, entry_type) { + function open_stock_entry(item, warehouse, entry_type, stock_uom) { frappe.model.with_doctype("Stock Entry", function () { var doc = frappe.model.get_new_doc("Stock Entry"); if (entry_type) { @@ -75,6 +75,9 @@ erpnext.stock.ItemDashboard = class ItemDashboard { var row = frappe.model.add_child(doc, "items"); row.item_code = item; + row.uom = stock_uom; + row.stock_uom = stock_uom; + row.conversion_factor = 1; if (entry_type === "Material Transfer") { row.s_warehouse = warehouse; From bf78f6173c3f28e93f8a08fca4eb0bceb39bc789 Mon Sep 17 00:00:00 2001 From: iamkhanraheel Date: Sat, 21 Jun 2025 01:14:26 +0530 Subject: [PATCH 29/35] fix: disassemble qty calculation & max calculation to be allowed to create it (cherry picked from commit 3e4d16062619b5934bff0d697ac59cf1beb3eead) # Conflicts: # erpnext/manufacturing/doctype/work_order/work_order.json # erpnext/stock/doctype/stock_entry/stock_entry.py --- .../doctype/work_order/work_order.js | 2 +- .../doctype/work_order/work_order.json | 32 ++++++++ .../doctype/work_order/work_order.py | 15 +++- .../stock/doctype/stock_entry/stock_entry.py | 75 +++++++++++++++++-- 4 files changed, 115 insertions(+), 9 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 04a87b00260..3db6d165328 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -803,7 +803,7 @@ erpnext.work_order = { get_max_transferable_qty: (frm, purpose) => { let max = 0; if (purpose === "Disassemble") { - return flt(frm.doc.produced_qty); + return flt(frm.doc.produced_qty - frm.doc.disassembled_qty); } if (frm.doc.skip_transfer) { diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 8231e924cb0..06fe1977b52 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -20,6 +20,7 @@ "qty", "material_transferred_for_manufacturing", "produced_qty", + "disassembled_qty", "process_loss_qty", "project", "section_break_ndpq", @@ -585,7 +586,34 @@ }, { "fieldname": "section_break_ndpq", +<<<<<<< HEAD "fieldtype": "Section Break" +======= + "fieldtype": "Section Break", + "label": "Required Items" + }, + { + "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 + }, + { + "default": "0", + "fieldname": "reserve_stock", + "fieldtype": "Check", + "label": " Reserve Stock" + }, + { + "depends_on": "eval:doc.docstatus==1", + "fieldname": "disassembled_qty", + "fieldtype": "Float", + "label": "Disassembled Qty", + "no_copy": 1, + "read_only": 1 +>>>>>>> 3e4d160626 (fix: disassemble qty calculation & max calculation to be allowed to create it) } ], "icon": "fa fa-cogs", @@ -593,7 +621,11 @@ "image_field": "image", "is_submittable": 1, "links": [], +<<<<<<< HEAD "modified": "2024-02-11 15:47:13.454422", +======= + "modified": "2025-06-21 00:55:45.916224", +>>>>>>> 3e4d160626 (fix: disassemble qty calculation & max calculation to be allowed to create it) "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 796e9461bee..176e955ee72 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -88,6 +88,7 @@ class WorkOrder(Document): company: DF.Link corrective_operation_cost: DF.Currency description: DF.SmallText | None + disassembled_qty: DF.Float expected_delivery_date: DF.Date | None fg_warehouse: DF.Link from_wip_warehouse: DF.Check @@ -406,6 +407,18 @@ class WorkOrder(Document): self.set_produced_qty_for_sub_assembly_item() self.update_production_plan_status() + def update_disassembled_qty(self, qty, is_cancel=False): + if is_cancel: + self.disassembled_qty = max(0, self.disassembled_qty - qty) + else: + if self.docstatus == 1: + self.disassembled_qty += qty + + if not is_cancel and self.disassembled_qty > self.produced_qty: + frappe.throw(_("Cannot disassemble more than produced quantity.")) + + self.db_set("disassembled_qty", self.disassembled_qty) + def get_transferred_or_manufactured_qty(self, purpose): table = frappe.qb.DocType("Stock Entry") query = frappe.qb.from_(table).where( @@ -1475,7 +1488,7 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None): stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse stock_entry.set_stock_entry_type() - stock_entry.get_items() + stock_entry.get_items(qty, work_order.production_item) if purpose != "Disassemble": stock_entry.set_serial_no_batch_for_finished_good() diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 45afd1a0ad4..a75f5d30b21 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -27,6 +27,7 @@ from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals from erpnext.manufacturing.doctype.bom.bom import ( add_additional_cost, + get_bom_items_as_dict, get_op_cost_from_sub_assemblies, get_scrap_items_from_sub_assemblies, validate_bom_no, @@ -243,6 +244,11 @@ class StockEntry(StockController): def on_submit(self): self.validate_closed_subcontracting_order() self.make_bundle_using_old_serial_batch_fields() +<<<<<<< HEAD +======= + self.update_work_order() + self.update_disassembled_order() +>>>>>>> 3e4d160626 (fix: disassemble qty calculation & max calculation to be allowed to create it) self.update_stock_ledger() self.update_work_order() self.validate_subcontract_order() @@ -271,6 +277,7 @@ class StockEntry(StockController): self.validate_work_order_status() self.update_work_order() + self.update_disassembled_order(is_cancel=True) self.update_stock_ledger() self.ignore_linked_doctypes = ( @@ -1617,6 +1624,50 @@ class StockEntry(StockController): if not pro_doc.operations: pro_doc.set_actual_dates() +<<<<<<< HEAD +======= + def update_disassembled_order(self, is_cancel=False): + if not self.work_order: + return + if self.purpose == "Disassemble" and self.fg_completed_qty: + pro_doc = frappe.get_doc("Work Order", self.work_order) + pro_doc.run_method("update_disassembled_qty", self.fg_completed_qty, is_cancel) + + def make_stock_reserve_for_wip_and_fg(self): + if self.is_stock_reserve_for_work_order(): + pro_doc = frappe.get_doc("Work Order", self.work_order) + if ( + self.purpose == "Manufacture" + and not pro_doc.sales_order + and not pro_doc.production_plan_sub_assembly_item + ): + return + + pro_doc.set_reserved_qty_for_wip_and_fg(self) + + def cancel_stock_reserve_for_wip_and_fg(self): + if self.is_stock_reserve_for_work_order(): + pro_doc = frappe.get_doc("Work Order", self.work_order) + if ( + self.purpose == "Manufacture" + and not pro_doc.sales_order + and not pro_doc.production_plan_sub_assembly_item + ): + return + + pro_doc.cancel_reserved_qty_for_wip_and_fg(self) + + def is_stock_reserve_for_work_order(self): + if ( + self.work_order + and self.stock_entry_type in ["Material Transfer for Manufacture", "Manufacture"] + and frappe.get_cached_value("Work Order", self.work_order, "reserve_stock") + ): + return True + + return False + +>>>>>>> 3e4d160626 (fix: disassemble qty calculation & max calculation to be allowed to create it) @frappe.whitelist() def get_item_details(self, args=None, for_update=False): item = frappe.qb.DocType("Item") @@ -1759,7 +1810,7 @@ class StockEntry(StockController): }, ) - def get_items_for_disassembly(self): + def get_items_for_disassembly(self, disassemble_qty, production_item): """Get items for Disassembly Order""" if not self.work_order: @@ -1767,9 +1818,9 @@ class StockEntry(StockController): items = self.get_items_from_manufacture_entry() - s_warehouse = "" - if self.work_order: - s_warehouse = frappe.db.get_value("Work Order", self.work_order, "fg_warehouse") + s_warehouse = frappe.db.get_value("Work Order", self.work_order, "fg_warehouse") + + items_dict = get_bom_items_as_dict(self.bom_no, self.company, disassemble_qty) for row in items: child_row = self.append("items", {}) @@ -1777,6 +1828,15 @@ class StockEntry(StockController): if value is not None: child_row.set(field, value) + # update qty and amount from BOM items + bom_items = items_dict.get(row.item_code) + if bom_items: + child_row.qty = bom_items.get("qty", child_row.qty) + child_row.amount = bom_items.get("amount", child_row.amount) + + if row.item_code == production_item: + child_row.qty = disassemble_qty + child_row.s_warehouse = (self.from_warehouse or s_warehouse) if row.is_finished_item else "" child_row.t_warehouse = self.to_warehouse if not row.is_finished_item else "" child_row.is_finished_item = 0 if row.is_finished_item else 1 @@ -1809,12 +1869,13 @@ class StockEntry(StockController): ) @frappe.whitelist() - def get_items(self): + def get_items(self, qty, production_item): self.set("items", []) self.validate_work_order() + # print(qty, 'qty\n\n') - if self.purpose == "Disassemble": - return self.get_items_for_disassembly() + if self.purpose == "Disassemble" and qty is not None: + return self.get_items_for_disassembly(qty, production_item) if not self.posting_date or not self.posting_time: frappe.throw(_("Posting date and posting time is mandatory")) From c69bb746ce44021f2cd68b084d8103b096884d9b Mon Sep 17 00:00:00 2001 From: iamkhanraheel Date: Sun, 22 Jun 2025 21:33:02 +0530 Subject: [PATCH 30/35] fix: func parameters (cherry picked from commit ce6ace4b8ab5d774b62ebbe65ef06cb71ddf40a3) --- erpnext/stock/doctype/stock_entry/stock_entry.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index a75f5d30b21..8b323c0c33b 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1869,10 +1869,9 @@ class StockEntry(StockController): ) @frappe.whitelist() - def get_items(self, qty, production_item): + def get_items(self, qty=None, production_item=None): self.set("items", []) self.validate_work_order() - # print(qty, 'qty\n\n') if self.purpose == "Disassemble" and qty is not None: return self.get_items_for_disassembly(qty, production_item) From 61f4547860c7dd5e817082d57d2903801e1346e2 Mon Sep 17 00:00:00 2001 From: iamkhanraheel Date: Thu, 26 Jun 2025 17:21:34 +0530 Subject: [PATCH 31/35] test: added test case for disassembly order (cherry picked from commit aee26c35508306ad64b7e4cebe3218edf7f42fda) --- .../doctype/work_order/test_work_order.py | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index cd57c7c24f8..5106ded95e8 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2368,6 +2368,105 @@ class TestWorkOrder(FrappeTestCase): stock_entry.submit() + def test_disassembly_order_with_qty_behavior(self): + # Create raw material and FG item + raw_item = make_item("Test Raw for Disassembly", {"is_stock_item": 1}).name + fg_item = make_item("Test FG for Disassembly", {"is_stock_item": 1}).name + bom = make_bom(item=fg_item, quantity=10, raw_materials=[raw_item], rm_qty=5) + + # Create and submit a Work Order for 10 qty + wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started") + + # create material receipt stock entry for raw material + from erpnext.stock.doctype.stock_entry.test_stock_entry import ( + make_stock_entry as make_stock_entry_test_record, + ) + + make_stock_entry_test_record( + item_code=raw_item, + purpose="Material Receipt", + target=wo.wip_warehouse, + qty=10, + basic_rate=100, + ) + make_stock_entry_test_record( + item_code=raw_item, + purpose="Material Receipt", + target=wo.fg_warehouse, + qty=10, + basic_rate=100, + ) + + # create material transfer for manufacture stock entry + se_for_material_tranfer_mfr = frappe.get_doc( + make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty) + ) + se_for_material_tranfer_mfr.items[0].s_warehouse = wo.wip_warehouse + se_for_material_tranfer_mfr.save() + se_for_material_tranfer_mfr.submit() + + se_for_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty)) + se_for_manufacture.submit() + + # Simulate a disassembly stock entry + disassemble_qty = 4 + stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty)) + stock_entry.append( + "items", + { + "item_code": fg_item, + "qty": disassemble_qty, + "s_warehouse": wo.fg_warehouse, + }, + ) + + for bom_item in bom.items: + stock_entry.append( + "items", + { + "item_code": bom_item.item_code, + "qty": (bom_item.qty / bom.quantity) * disassemble_qty, + "t_warehouse": wo.source_warehouse, + }, + ) + + wo.reload() + stock_entry.save() + stock_entry.submit() + + # Assert FG item is present with correct qty + finished_good_entry = next((item for item in stock_entry.items if item.item_code == fg_item), None) + self.assertIsNotNone(finished_good_entry, "Finished good item missing from stock entry") + self.assertEqual( + finished_good_entry.qty, + disassemble_qty, + f"Expected FG qty {disassemble_qty}, found {finished_good_entry.qty}", + ) + + # Assert raw materials + for item in stock_entry.items: + if item.item_code == fg_item: + continue + bom_item = next((i for i in bom.items if i.item_code == item.item_code), None) + if bom_item: + expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty + self.assertAlmostEqual( + item.qty, + expected_qty, + places=3, + msg=f"Raw item {item.item_code} qty mismatch: expected {expected_qty}, got {item.qty}", + ) + else: + self.fail(f"Unexpected item {item.item_code} found in stock entry") + + wo.reload() + # Assert disassembled_qty field updated in Work Order + self.assertEqual( + wo.disassembled_qty, + disassemble_qty, + f"Work Order disassembled_qty mismatch: expected {disassemble_qty}, got {wo.disassembled_qty}", + ) + def test_components_alternate_item_for_bom_based_manufacture_entry(self): frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM") frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1) @@ -3118,6 +3217,7 @@ def make_wo_order_test_record(**args): wo_order.transfer_material_against = args.transfer_material_against or "Work Order" wo_order.from_wip_warehouse = args.from_wip_warehouse or 0 wo_order.batch_size = args.batch_size or 0 + wo_order.status = args.status or "Draft" if args.source_warehouse: wo_order.source_warehouse = args.source_warehouse From abfe3c83656029a422adf85845d5694f2e366eaa Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 30 Jun 2025 12:45:04 +0530 Subject: [PATCH 32/35] chore: fix conflicts --- .../doctype/work_order/work_order.json | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 06fe1977b52..f1735ab64b5 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -586,25 +586,7 @@ }, { "fieldname": "section_break_ndpq", -<<<<<<< HEAD "fieldtype": "Section Break" -======= - "fieldtype": "Section Break", - "label": "Required Items" - }, - { - "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 - }, - { - "default": "0", - "fieldname": "reserve_stock", - "fieldtype": "Check", - "label": " Reserve Stock" }, { "depends_on": "eval:doc.docstatus==1", @@ -613,7 +595,6 @@ "label": "Disassembled Qty", "no_copy": 1, "read_only": 1 ->>>>>>> 3e4d160626 (fix: disassemble qty calculation & max calculation to be allowed to create it) } ], "icon": "fa fa-cogs", @@ -621,11 +602,7 @@ "image_field": "image", "is_submittable": 1, "links": [], -<<<<<<< HEAD - "modified": "2024-02-11 15:47:13.454422", -======= "modified": "2025-06-21 00:55:45.916224", ->>>>>>> 3e4d160626 (fix: disassemble qty calculation & max calculation to be allowed to create it) "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", @@ -661,4 +638,4 @@ "title_field": "production_item", "track_changes": 1, "track_seen": 1 -} \ No newline at end of file +} From c404faaa6d05eab9f5bfabecebcc0358a3fc6c2e Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 30 Jun 2025 12:46:52 +0530 Subject: [PATCH 33/35] chore: fix issue --- .../stock/doctype/stock_entry/stock_entry.py | 41 ------------------- 1 file changed, 41 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 8b323c0c33b..974acc3f701 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -244,11 +244,7 @@ class StockEntry(StockController): def on_submit(self): self.validate_closed_subcontracting_order() self.make_bundle_using_old_serial_batch_fields() -<<<<<<< HEAD -======= - self.update_work_order() self.update_disassembled_order() ->>>>>>> 3e4d160626 (fix: disassemble qty calculation & max calculation to be allowed to create it) self.update_stock_ledger() self.update_work_order() self.validate_subcontract_order() @@ -1624,8 +1620,6 @@ class StockEntry(StockController): if not pro_doc.operations: pro_doc.set_actual_dates() -<<<<<<< HEAD -======= def update_disassembled_order(self, is_cancel=False): if not self.work_order: return @@ -1633,41 +1627,6 @@ class StockEntry(StockController): pro_doc = frappe.get_doc("Work Order", self.work_order) pro_doc.run_method("update_disassembled_qty", self.fg_completed_qty, is_cancel) - def make_stock_reserve_for_wip_and_fg(self): - if self.is_stock_reserve_for_work_order(): - pro_doc = frappe.get_doc("Work Order", self.work_order) - if ( - self.purpose == "Manufacture" - and not pro_doc.sales_order - and not pro_doc.production_plan_sub_assembly_item - ): - return - - pro_doc.set_reserved_qty_for_wip_and_fg(self) - - def cancel_stock_reserve_for_wip_and_fg(self): - if self.is_stock_reserve_for_work_order(): - pro_doc = frappe.get_doc("Work Order", self.work_order) - if ( - self.purpose == "Manufacture" - and not pro_doc.sales_order - and not pro_doc.production_plan_sub_assembly_item - ): - return - - pro_doc.cancel_reserved_qty_for_wip_and_fg(self) - - def is_stock_reserve_for_work_order(self): - if ( - self.work_order - and self.stock_entry_type in ["Material Transfer for Manufacture", "Manufacture"] - and frappe.get_cached_value("Work Order", self.work_order, "reserve_stock") - ): - return True - - return False - ->>>>>>> 3e4d160626 (fix: disassemble qty calculation & max calculation to be allowed to create it) @frappe.whitelist() def get_item_details(self, args=None, for_update=False): item = frappe.qb.DocType("Item") From ab20b965ca3406fd297181747becc4b288fe32b7 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 14:51:47 +0530 Subject: [PATCH 34/35] fix(pos invoice): search using customer name (backport #48279) (#48323) * fix(pos invoice): search using customer name (cherry picked from commit 20fd071c4eb5b743cd49af2a0fcd9ce38b84cad5) # Conflicts: # erpnext/selling/page/point_of_sale/point_of_sale.py * refactor: use or_filters for customer and customer_name (cherry picked from commit 6a401bcfbbe341d4c0ef36ac4d6661a3f1d381c8) # Conflicts: # erpnext/selling/page/point_of_sale/point_of_sale.py * refactor: refactored for version 15 --------- Co-authored-by: ravibharathi656 Co-authored-by: diptanilsaha --- erpnext/selling/page/point_of_sale/point_of_sale.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.py b/erpnext/selling/page/point_of_sale/point_of_sale.py index bbab86ef29d..89e001897dd 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.py +++ b/erpnext/selling/page/point_of_sale/point_of_sale.py @@ -344,7 +344,11 @@ def get_past_order_list(search_term, status, limit=20): if search_term and status: invoices_by_customer = frappe.db.get_list( "POS Invoice", - filters={"customer": ["like", f"%{search_term}%"], "status": status}, + filters={"status": status}, + or_filters={ + "customer_name": ["like", f"%{search_term}%"], + "customer": ["like", f"%{search_term}%"], + }, fields=fields, page_length=limit, ) From ff362843cb037eef7c6728fb876d0b6d6fba3793 Mon Sep 17 00:00:00 2001 From: ravibharathi656 Date: Tue, 24 Jun 2025 18:48:22 +0530 Subject: [PATCH 35/35] fix: use gain_loss_posting_date instead of today (cherry picked from commit 0585bc5aef8539e86b7faf46e3965939f30c732c) --- .../doctype/payment_reconciliation/payment_reconciliation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index 72aa4905900..27001e9ab14 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -826,7 +826,7 @@ def reconcile_dr_cr_note(dr_cr_notes, company, active_dimensions=None): create_gain_loss_journal( company, - today(), + inv.difference_posting_date, inv.party_type, inv.party, inv.account,