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 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, 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/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", 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.json b/erpnext/assets/doctype/asset_movement/asset_movement.json index 5382f9e75f2..a656acf1265 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,11 @@ "fieldtype": "Column Break" } ], + "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-06-28 16:54:26.571083", + "modified": "2025-05-30 17:01:55.864353", "modified_by": "Administrator", "module": "Assets", "name": "Asset Movement", @@ -149,7 +150,8 @@ "write": 1 } ], - "sort_field": "modified", + "row_format": "Dynamic", + "sort_field": "creation", "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..db4e7510670 100644 --- a/erpnext/assets/doctype/asset_movement/asset_movement.py +++ b/erpnext/assets/doctype/asset_movement/asset_movement.py @@ -24,105 +24,81 @@ 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 # 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"), - ) - if not d.to_employee: - frappe.throw(_("Employee is required while issuing Asset {0}").format(d.asset)) - - 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) + _("Asset {0} does not belongs to the location {1}").format(d.asset, d.source_location) ) + else: + d.source_location = current_location + 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")) + 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() @@ -130,53 +106,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)), - ) 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( 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.json b/erpnext/assets/doctype/asset_repair/asset_repair.json index c98f5a8d7f4..a28afa280de 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,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2024-06-13 16:14:14.398356", + "modified": "2025-06-29 22:30:00.589597", "modified_by": "Administrator", "module": "Assets", "name": "Asset Repair", @@ -292,4 +293,4 @@ "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..2ac2803fd53 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( @@ -131,6 +140,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) @@ -172,6 +188,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/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py index 44d08869a63..5c0a18baccb 100644 --- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py @@ -4,11 +4,12 @@ import unittest import frappe -from frappe.utils import flt, nowdate, nowtime, today +from frappe.utils import add_months, flt, get_first_day, nowdate, nowtime, today 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 +34,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 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..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 @@ -11,6 +11,8 @@ "consumed_quantity", "total_value", "serial_no", + "column_break_xzfr", + "pick_serial_and_batch", "serial_and_batch_bundle" ], "fields": [ @@ -61,12 +63,21 @@ "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", @@ -76,4 +87,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} 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..2c1d7d9fcab 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,11 @@ 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) 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/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"))) 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/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", 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/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 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..f1735ab64b5 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", @@ -586,6 +587,14 @@ { "fieldname": "section_break_ndpq", "fieldtype": "Section Break" + }, + { + "depends_on": "eval:doc.docstatus==1", + "fieldname": "disassembled_qty", + "fieldtype": "Float", + "label": "Disassembled Qty", + "no_copy": 1, + "read_only": 1 } ], "icon": "fa fa-cogs", @@ -593,7 +602,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2024-02-11 15:47:13.454422", + "modified": "2025-06-21 00:55:45.916224", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", @@ -629,4 +638,4 @@ "title_field": "production_item", "track_changes": 1, "track_seen": 1 -} \ No newline at end of file +} 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/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) 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/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/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/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/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/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/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, ) 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}
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; 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 diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 45afd1a0ad4..4c3ffc157c6 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,7 @@ class StockEntry(StockController): def on_submit(self): self.validate_closed_subcontracting_order() self.make_bundle_using_old_serial_batch_fields() + self.update_disassembled_order() self.update_stock_ledger() self.update_work_order() self.validate_subcontract_order() @@ -263,6 +265,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() @@ -271,6 +274,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 = ( @@ -364,6 +368,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"): @@ -1617,6 +1642,13 @@ class StockEntry(StockController): if not pro_doc.operations: pro_doc.set_actual_dates() + 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) + @frappe.whitelist() def get_item_details(self, args=None, for_update=False): item = frappe.qb.DocType("Item") @@ -1759,7 +1791,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 +1799,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 +1809,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 +1850,12 @@ class StockEntry(StockController): ) @frappe.whitelist() - def get_items(self): + def get_items(self, qty=None, production_item=None): self.set("items", []) self.validate_work_order() - 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")) 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", 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 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 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,