diff --git a/.github/workflows/release_notes.yml b/.github/workflows/release_notes.yml index e765a66f691..90621be132d 100644 --- a/.github/workflows/release_notes.yml +++ b/.github/workflows/release_notes.yml @@ -29,7 +29,11 @@ jobs: steps: - name: Update notes run: | - NEW_NOTES=$(gh api --method POST -H "Accept: application/vnd.github+json" /repos/frappe/erpnext/releases/generate-notes -f tag_name=$RELEASE_TAG | jq -r '.body' | sed -E '/^\* (chore|ci|test|docs|style)/d' ) + NEW_NOTES=$(gh api --method POST -H "Accept: application/vnd.github+json" /repos/frappe/erpnext/releases/generate-notes -f tag_name=$RELEASE_TAG \ + | jq -r '.body' \ + | sed -E '/^\* (chore|ci|test|docs|style)/d' \ + | sed -E 's/by @mergify //' + ) RELEASE_ID=$(gh api -H "Accept: application/vnd.github+json" /repos/frappe/erpnext/releases/tags/$RELEASE_TAG | jq -r '.id') gh api --method PATCH -H "Accept: application/vnd.github+json" /repos/frappe/erpnext/releases/$RELEASE_ID -f body="$NEW_NOTES" diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 33c561ded71..524bbfcc4b1 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -205,6 +205,7 @@ class StockController(AccountsController): "company": self.company, "is_rejected": 1 if row.get("rejected_warehouse") else 0, "use_serial_batch_fields": row.use_serial_batch_fields, + "via_landed_cost_voucher": via_landed_cost_voucher, "do_not_submit": True if not via_landed_cost_voucher else False, } diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 4a74b4aa9c2..56f7afee005 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -5,7 +5,7 @@ import copy import json import frappe -from frappe import _ +from frappe import _, bold from frappe.model.document import Document from frappe.query_builder import Interval from frappe.query_builder.functions import Count, CurDate, UnixTimestamp @@ -469,6 +469,13 @@ class Item(Document): def validate_warehouse_for_reorder(self): """Validate Reorder level table for duplicate and conditional mandatory""" warehouse_material_request_type: list[tuple[str, str]] = [] + + _warehouse_before_save = frappe._dict() + if not self.is_new() and self._doc_before_save: + _warehouse_before_save = { + d.name: d.warehouse for d in self._doc_before_save.get("reorder_levels") or [] + } + for d in self.get("reorder_levels"): if not d.warehouse_group: d.warehouse_group = d.warehouse @@ -485,6 +492,19 @@ class Item(Document): if d.warehouse_reorder_level and not d.warehouse_reorder_qty: frappe.throw(_("Row #{0}: Please set reorder quantity").format(d.idx)) + if d.warehouse_group and d.warehouse: + if _warehouse_before_save.get(d.name) == d.warehouse: + continue + + child_warehouses = get_child_warehouses(d.warehouse_group) + if d.warehouse not in child_warehouses: + frappe.throw( + _( + "Row #{0}: The warehouse {1} is not a child warehouse of a group warehouse {2}" + ).format(d.idx, bold(d.warehouse), bold(d.warehouse_group)), + title=_("Incorrect Check in (group) Warehouse for Reorder"), + ) + def stock_ledger_created(self): if not hasattr(self, "_stock_ledger_created"): self._stock_ledger_created = len( @@ -1360,3 +1380,10 @@ def get_asset_naming_series(): from erpnext.assets.doctype.asset.asset import get_asset_naming_series return get_asset_naming_series() + + +@frappe.request_cache +def get_child_warehouses(warehouse): + from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses + + return get_child_warehouses(warehouse) diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index cea4caad59f..0b8a0d3e890 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -889,6 +889,27 @@ class TestItem(FrappeTestCase): self.assertEqual(data[0].description, item.description) self.assertTrue("description" in data[0]) + def test_group_warehouse_for_reorder_item(self): + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + item_doc = make_item("_Test Group Warehouse For Reorder Item", {"is_stock_item": 1}) + warehouse = create_warehouse("_Test Warehouse - _TC") + warehouse_doc = frappe.get_doc("Warehouse", warehouse) + warehouse_doc.db_set("parent_warehouse", "") + + item_doc.append( + "reorder_levels", + { + "warehouse": warehouse, + "warehouse_reorder_level": 10, + "warehouse_reorder_qty": 100, + "material_request_type": "Purchase", + "warehouse_group": "_Test Warehouse Group - _TC", + }, + ) + + self.assertRaises(frappe.ValidationError, item_doc.save) + def set_item_variant_settings(fields): doc = frappe.get_doc("Item Variant Settings") diff --git a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py index 13b7f97b7c4..39f9ecb915d 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py @@ -946,6 +946,128 @@ class TestLandedCostVoucher(FrappeTestCase): frappe.db.get_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "avg_rate"), ) + def test_do_not_validate_against_landed_cost_voucher_for_serial_for_legacy_pr(self): + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import get_auto_batch_nos + + frappe.flags.ignore_serial_batch_bundle_validation = True + frappe.flags.use_serial_and_batch_fields = True + sn_item = "Test Don't Validate Against LCV For Serial NO for Legacy PR" + sn_item_doc = make_item( + sn_item, + { + "has_serial_no": 1, + "serial_no_series": "SN-ALCVTDVLCVSNO-.####", + "is_stock_item": 1, + }, + ) + + serial_nos = [ + "SN-ALCVTDVLCVSNO-0001", + "SN-ALCVTDVLCVSNO-0002", + "SN-ALCVTDVLCVSNO-0003", + "SN-ALCVTDVLCVSNO-0004", + "SN-ALCVTDVLCVSNO-0005", + ] + + for sn in serial_nos: + if not frappe.db.exists("Serial No", sn): + sn_doc = frappe.get_doc( + { + "doctype": "Serial No", + "item_code": sn_item, + "serial_no": sn, + } + ) + sn_doc.insert() + + warehouse = "_Test Warehouse - _TC" + company = frappe.db.get_value("Warehouse", warehouse, "company") + + pr = make_purchase_receipt( + company=company, + warehouse=warehouse, + item_code=sn_item, + qty=5, + rate=100, + uom=sn_item_doc.stock_uom, + stock_uom=sn_item_doc.stock_uom, + ) + + pr.reload() + + for sn in serial_nos: + sn_doc = frappe.get_doc("Serial No", sn) + sn_doc.db_set( + { + "warehouse": warehouse, + "status": "Active", + } + ) + + for row in pr.items: + if row.item_code == sn_item: + row.db_set("serial_no", ", ".join(serial_nos)) + + stock_ledger_entries = frappe.get_all("Stock Ledger Entry", filters={"voucher_no": pr.name}) + for sle in stock_ledger_entries: + doc = frappe.get_doc("Stock Ledger Entry", sle.name) + if doc.item_code == sn_item: + doc.db_set("serial_no", ", ".join(serial_nos)) + + dn = create_delivery_note( + company=company, + warehouse=warehouse, + item_code=sn_item, + qty=5, + rate=100, + uom=sn_item_doc.stock_uom, + stock_uom=sn_item_doc.stock_uom, + ) + + stock_ledger_entries = frappe.get_all("Stock Ledger Entry", filters={"voucher_no": dn.name}) + for sle in stock_ledger_entries: + doc = frappe.get_doc("Stock Ledger Entry", sle.name) + if doc.item_code == sn_item: + doc.db_set("serial_no", ", ".join(serial_nos)) + + frappe.flags.ignore_serial_batch_bundle_validation = False + frappe.flags.use_serial_and_batch_fields = False + + lcv = make_landed_cost_voucher( + company=pr.company, + receipt_document_type="Purchase Receipt", + receipt_document=pr.name, + charges=20, + distribute_charges_based_on="Qty", + do_not_save=True, + ) + + lcv.get_items_from_purchase_receipts() + lcv.save() + lcv.submit() + + pr.reload() + + for row in pr.items: + self.assertEqual(row.valuation_rate, 104) + self.assertTrue(row.serial_and_batch_bundle) + self.assertEqual( + row.valuation_rate, + frappe.db.get_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "avg_rate"), + ) + + lcv.cancel() + pr.reload() + + for row in pr.items: + self.assertEqual(row.valuation_rate, 100) + self.assertTrue(row.serial_and_batch_bundle) + self.assertEqual( + row.valuation_rate, + frappe.db.get_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "avg_rate"), + ) + def make_landed_cost_voucher(**args): args = frappe._dict(args) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index a1a8a85e745..4c9fc881986 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -428,6 +428,9 @@ class SerialandBatchBundle(Document): self.throw_error_message(f"The {self.voucher_type} # {self.voucher_no} should be submit first.") def check_future_entries_exists(self): + if self.flags and self.flags.via_landed_cost_voucher: + return + if not self.has_serial_no: return diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 573d7280ca1..21a96c41cb4 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -840,6 +840,9 @@ class SerialBatchCreation: self.set_auto_serial_batch_entries_for_inward() self.add_serial_nos_for_batch_item() + if hasattr(self, "via_landed_cost_voucher") and self.via_landed_cost_voucher: + doc.flags.via_landed_cost_voucher = self.via_landed_cost_voucher + self.set_serial_batch_entries(doc) if not doc.get("entries"): return frappe._dict({})