|
@@ -193,6 +207,72 @@ frappe.ui.form.on("Production Plan", {
set_field_options("projected_qty_formula", projected_qty_formula);
},
+ has_unreserved_stock(frm, table) {
+ let has_unreserved_stock = frm.doc[table].some(
+ (item) => flt(item.qty) > flt(item.stock_reserved_qty)
+ );
+
+ return has_unreserved_stock;
+ },
+
+ has_reserved_stock(frm, table) {
+ let has_reserved_stock = frm.doc[table].some((item) => flt(item.stock_reserved_qty) > 0);
+
+ return has_reserved_stock;
+ },
+
+ setup_stock_reservation_for_sub_assembly(frm) {
+ if (frm.doc.docstatus === 1 && frm.doc.reserve_stock) {
+ if (frm.events.has_unreserved_stock(frm, "sub_assembly_items")) {
+ frm.add_custom_button(
+ __("Reserve for Sub-assembly"),
+ () => erpnext.stock_reservation.make_entries(frm, "sub_assembly_items"),
+ __("Stock Reservation")
+ );
+ }
+
+ if (frm.events.has_reserved_stock(frm, "sub_assembly_items")) {
+ frm.add_custom_button(
+ __("Unreserve for Sub-assembly"),
+ () => erpnext.stock_reservation.unreserve_stock(frm),
+ __("Stock Reservation")
+ );
+
+ frm.add_custom_button(
+ __("Reserved Stock for Sub-assembly"),
+ () => erpnext.stock_reservation.show_reserved_stock(frm, "sub_assembly_items"),
+ __("Stock Reservation")
+ );
+ }
+ }
+ },
+
+ setup_stock_reservation_for_raw_materials(frm) {
+ if (frm.doc.docstatus === 1 && frm.doc.reserve_stock) {
+ if (frm.events.has_unreserved_stock(frm, "mr_items")) {
+ frm.add_custom_button(
+ __("Reserve for Raw Materials"),
+ () => erpnext.stock_reservation.make_entries(frm, "mr_items"),
+ __("Stock Reservation")
+ );
+ }
+
+ if (frm.events.has_reserved_stock(frm, "mr_items")) {
+ frm.add_custom_button(
+ __("Unreserve for Raw Materials"),
+ () => erpnext.stock_reservation.unreserve_stock(frm),
+ __("Stock Reservation")
+ );
+
+ frm.add_custom_button(
+ __("Reserved Stock for Raw Materials"),
+ () => erpnext.stock_reservation.show_reserved_stock(frm, "mr_items"),
+ __("Stock Reservation")
+ );
+ }
+ }
+ },
+
close_open_production_plan(frm, close = false) {
frappe.call({
method: "set_status",
diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.json b/erpnext/manufacturing/doctype/production_plan/production_plan.json
index 01ab5fc7301..d733cb27845 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.json
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.json
@@ -11,6 +11,7 @@
"get_items_from",
"column_break1",
"posting_date",
+ "reserve_stock",
"filters",
"item_code",
"customer",
@@ -23,6 +24,7 @@
"from_delivery_date",
"to_delivery_date",
"sales_orders_detail",
+ "combine_items",
"get_sales_orders",
"sales_orders",
"material_request_detail",
@@ -30,16 +32,14 @@
"material_requests",
"select_items_to_manufacture_section",
"get_items",
- "combine_items",
"po_items",
"section_break_25",
"prod_plan_references",
"section_break_24",
- "combine_sub_items",
"sub_assembly_warehouse",
- "section_break_ucc4",
- "skip_available_sub_assembly_item",
"column_break_igxl",
+ "skip_available_sub_assembly_item",
+ "combine_sub_items",
"get_sub_assembly_items",
"section_break_g4ip",
"sub_assembly_items",
@@ -215,7 +215,8 @@
{
"fieldname": "material_request_planning",
"fieldtype": "Section Break",
- "label": "Material Request Planning"
+ "hide_border": 1,
+ "label": "For Raw Materials"
},
{
"default": "1",
@@ -231,10 +232,10 @@
},
{
"default": "1",
- "description": "If enabled, the system will consider items with a shortfall in quantity. \n \nQty = Reqd Qty (BOM) - Projected Qty", + "description": "If enabled, formula for Required Qty: \nRequired Qty (BOM) - Projected Qty. This helps avoid over-ordering.", "fieldname": "ignore_existing_ordered_qty", "fieldtype": "Check", - "label": "Skip Available Raw Materials" + "label": "Consider Projected Qty in Calculation (RM)" }, { "fieldname": "column_break_25", @@ -314,7 +315,7 @@ { "fieldname": "for_warehouse", "fieldtype": "Link", - "label": "Raw Materials Warehouse", + "label": "For Warehouse", "options": "Warehouse" }, { @@ -375,11 +376,13 @@ "label": "Get Sub Assembly Items" }, { + "depends_on": "eval:doc.get_items_from == 'Sales Order'", "fieldname": "from_delivery_date", "fieldtype": "Date", "label": "From Delivery Date" }, { + "depends_on": "eval:doc.get_items_from == 'Sales Order'", "fieldname": "to_delivery_date", "fieldtype": "Date", "label": "To Delivery Date" @@ -404,15 +407,10 @@ }, { "default": "1", - "description": "If enabled, the system will consider items with a shortfall in quantity. \n \nQty = Reqd Qty (BOM) - Projected Qty", + "description": "If enabled, formula for Qty to Order: \nRequired Qty (BOM) - Projected Qty. This helps avoid over-ordering.", "fieldname": "skip_available_sub_assembly_item", "fieldtype": "Check", - "label": "Skip Available Sub Assembly Items" - }, - { - "fieldname": "section_break_ucc4", - "fieldtype": "Column Break", - "hide_border": 1 + "label": "Consider Projected Qty in Calculation" }, { "fieldname": "section_break_g4ip", @@ -423,7 +421,7 @@ "fieldtype": "Column Break" }, { - "description": "When a parent warehouse is chosen, the system conducts stock checks against the associated child warehouses", + "description": "When a parent warehouse is chosen, the system conducts Project Qty checks against the associated child warehouses", "fieldname": "sub_assembly_warehouse", "fieldtype": "Link", "label": "Sub Assembly Warehouse", @@ -435,6 +433,12 @@ "fieldname": "consider_minimum_order_qty", "fieldtype": "Check", "label": "Consider Minimum Order Qty" + }, + { + "default": "0", + "fieldname": "reserve_stock", + "fieldtype": "Check", + "label": "Reserve Stock" } ], "grid_page_length": 50, @@ -442,7 +446,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-04-08 17:24:09.394056", + "modified": "2025-05-09 18:55:45.500257", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan", diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 8ba9afe9fc6..899e28e0a1e 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -9,6 +9,8 @@ from collections import defaultdict import frappe from frappe import _, msgprint from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc +from frappe.query_builder import Case from frappe.query_builder.functions import IfNull, Sum from frappe.utils import ( add_days, @@ -20,6 +22,7 @@ from frappe.utils import ( getdate, now_datetime, nowdate, + parse_json, ) from frappe.utils.csvutils import build_csv_response from pypika.terms import ExistsCriterion @@ -28,6 +31,7 @@ from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_childr from erpnext.manufacturing.doctype.bom.bom import validate_bom_no from erpnext.manufacturing.doctype.work_order.work_order import get_item_details from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults +from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation from erpnext.stock.get_item_details import get_conversion_factor from erpnext.stock.utils import get_or_make_bin from erpnext.utilities.transaction_base import validate_uom_is_integer @@ -84,6 +88,7 @@ class ProductionPlan(Document): posting_date: DF.Date prod_plan_references: DF.Table[ProductionPlanItemReference] project: DF.Link | None + reserve_stock: DF.Check sales_order_status: DF.Literal["", "To Deliver and Bill", "To Bill", "To Deliver"] sales_orders: DF.Table[ProductionPlanSalesOrder] skip_available_sub_assembly_item: DF.Check @@ -108,6 +113,12 @@ class ProductionPlan(Document): warehouses: DF.TableMultiSelect[ProductionPlanMaterialRequestWarehouse] # end: auto-generated types + def onload(self): + self.set_onload( + "enable_stock_reservation", + frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"), + ) + def validate(self): self.set_pending_qty_in_row_without_reference() self.calculate_total_planned_qty() @@ -116,6 +127,11 @@ class ProductionPlan(Document): validate_uom_is_integer(self, "stock_uom", "planned_qty") self.validate_sales_orders() self.validate_material_request_type() + self.enable_auto_reserve_stock() + + def enable_auto_reserve_stock(self): + if self.is_new() and frappe.db.get_single_value("Stock Settings", "auto_reserve_stock"): + self.reserve_stock = 1 def validate_material_request_type(self): for row in self.get("mr_items"): @@ -552,12 +568,20 @@ class ProductionPlan(Document): def on_submit(self): self.update_bin_qty() self.update_sales_order() + self.update_stock_reservation() def on_cancel(self): self.db_set("status", "Cancelled") self.delete_draft_work_order() self.update_bin_qty() self.update_sales_order() + self.update_stock_reservation() + + def update_stock_reservation(self): + if not self.reserve_stock: + return + + make_stock_reservation_entries(self) def update_sales_order(self): sales_orders = [row.sales_order for row in self.po_items if row.sales_order] @@ -851,6 +875,7 @@ class ProductionPlan(Document): wo = frappe.new_doc("Work Order") wo.update(item) + wo.reserve_stock = self.reserve_stock wo.planned_start_date = item.get("planned_start_date") or item.get("schedule_date") if item.get("warehouse"): @@ -1369,29 +1394,28 @@ def get_material_request_items( get_conversion_factor(row.item_code, item_details.purchase_uom).get("conversion_factor") or 1.0 ) - if required_qty > 0: - return { - "item_code": row.item_code, - "item_name": row.item_name, - "quantity": required_qty / conversion_factor, - "conversion_factor": conversion_factor, - "required_bom_qty": total_qty, - "stock_uom": row.get("stock_uom"), - "warehouse": warehouse - or row.get("source_warehouse") - or row.get("default_warehouse") - or item_group_defaults.get("default_warehouse"), - "safety_stock": row.safety_stock, - "actual_qty": bin_dict.get("actual_qty", 0), - "projected_qty": bin_dict.get("projected_qty", 0), - "ordered_qty": bin_dict.get("ordered_qty", 0), - "reserved_qty_for_production": bin_dict.get("reserved_qty_for_production", 0), - "min_order_qty": row["min_order_qty"], - "material_request_type": row.get("default_material_request_type"), - "sales_order": sales_order, - "description": row.get("description"), - "uom": row.get("purchase_uom") or row.get("stock_uom"), - } + return { + "item_code": row.item_code, + "item_name": row.item_name, + "quantity": required_qty / conversion_factor, + "conversion_factor": conversion_factor, + "required_bom_qty": total_qty, + "stock_uom": row.get("stock_uom"), + "warehouse": warehouse + or row.get("source_warehouse") + or row.get("default_warehouse") + or item_group_defaults.get("default_warehouse"), + "safety_stock": row.safety_stock, + "actual_qty": bin_dict.get("actual_qty", 0), + "projected_qty": bin_dict.get("projected_qty", 0), + "ordered_qty": bin_dict.get("ordered_qty", 0), + "reserved_qty_for_production": bin_dict.get("reserved_qty_for_production", 0), + "min_order_qty": row["min_order_qty"], + "material_request_type": row.get("default_material_request_type"), + "sales_order": sales_order, + "description": row.get("description"), + "uom": row.get("purchase_uom") or row.get("stock_uom"), + } def get_sales_orders(self): @@ -1792,6 +1816,7 @@ def get_sub_assembly_items( if d.expandable: parent_item_code = frappe.get_cached_value("BOM", bom_no, "item") stock_qty = (d.stock_qty / d.parent_bom_qty) * flt(to_produce_qty) + required_qty = stock_qty if skip_available_sub_assembly_item and d.item_code not in sub_assembly_items: bin_details.setdefault(d.item_code, get_bin_details(d, company, for_warehouse=warehouse)) @@ -1804,43 +1829,47 @@ def get_sub_assembly_items( continue else: stock_qty = stock_qty - _bin_dict.projected_qty + sub_assembly_items.append(d.item_code) elif warehouse: bin_details.setdefault(d.item_code, get_bin_details(d, company, for_warehouse=warehouse)) - if stock_qty > 0: - bom_data.append( - frappe._dict( - { - "actual_qty": bin_details[d.item_code][0].get("actual_qty", 0) - if bin_details.get(d.item_code) - else 0, - "parent_item_code": parent_item_code, - "description": d.description, - "production_item": d.item_code, - "item_name": d.item_name, - "stock_uom": d.stock_uom, - "uom": d.stock_uom, - "bom_no": d.value, - "is_sub_contracted_item": d.is_sub_contracted_item, - "bom_level": indent, - "indent": indent, - "stock_qty": stock_qty, - } - ) + bom_data.append( + frappe._dict( + { + "actual_qty": bin_details[d.item_code][0].get("actual_qty", 0) + if bin_details.get(d.item_code) + else 0, + "parent_item_code": parent_item_code, + "description": d.description, + "production_item": d.item_code, + "item_name": d.item_name, + "stock_uom": d.stock_uom, + "uom": d.stock_uom, + "bom_no": d.value, + "is_sub_contracted_item": d.is_sub_contracted_item, + "bom_level": indent, + "indent": indent, + "stock_qty": stock_qty, + "required_qty": required_qty, + "projected_qty": bin_details[d.item_code][0].get("projected_qty", 0) + if bin_details.get(d.item_code) + else 0, + } ) + ) - if d.value: - get_sub_assembly_items( - sub_assembly_items, - bin_details, - d.value, - bom_data, - stock_qty, - company, - warehouse, - indent=indent + 1, - skip_available_sub_assembly_item=skip_available_sub_assembly_item, - ) + if d.value: + get_sub_assembly_items( + sub_assembly_items, + bin_details, + d.value, + bom_data, + stock_qty, + company, + warehouse, + indent=indent + 1, + skip_available_sub_assembly_item=skip_available_sub_assembly_item, + ) def set_default_warehouses(row, default_warehouses): @@ -2033,7 +2062,12 @@ def get_reserved_qty_for_sub_assembly(item_code, warehouse): frappe.qb.from_(table) .inner_join(child) .on(table.name == child.parent) - .select(Sum(child.qty - IfNull(child.wo_produced_qty, 0))) + .select( + Sum( + Case().when(child.qty > 0, child.qty).else_(child.required_qty) + - IfNull(child.wo_produced_qty, 0) + ) + ) .where( (table.docstatus == 1) & (child.production_item == item_code) @@ -2049,3 +2083,39 @@ def get_reserved_qty_for_sub_assembly(item_code, warehouse): qty = flt(query[0][0]) return qty if qty > 0 else 0.0 + + +@frappe.whitelist() +def make_stock_reservation_entries(doc, items=None, table_name=None, notify=False): + if isinstance(doc, str): + doc = parse_json(doc) + doc = frappe.get_doc("Work Order", doc.get("name")) + + if items and isinstance(items, str): + items = parse_json(items) + + mapper = { + "sub_assembly_items": { + "table_name": "sub_assembly_items", + "qty_field": "required_qty", + "warehouse_field": "fg_warehouse", + }, + "mr_items": { + "table_name": "mr_items", + "qty_field": "required_bom_qty", + "warehouse_field": "warehouse", + }, + } + + for child_table_name in mapper: + if table_name and table_name != child_table_name: + continue + + sre = StockReservation(doc, items=items, kwargs=mapper[child_table_name], notify=notify) + if doc.docstatus == 1: + sre.make_stock_reservation_entries() + frappe.msgprint(_("Stock Reservation Entries Created"), alert=True) + elif doc.docstatus == 2: + sre.cancel_stock_reservation_entries() + + doc.reload() diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan_dashboard.py b/erpnext/manufacturing/doctype/production_plan/production_plan_dashboard.py index 6fc28a30971..71ca6843e2b 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan_dashboard.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan_dashboard.py @@ -4,8 +4,12 @@ from frappe import _ def get_data(): return { "fieldname": "production_plan", + "non_standard_fieldnames": { + "Stock Reservation Entry": "voucher_no", + }, "transactions": [ {"label": _("Transactions"), "items": ["Work Order", "Material Request"]}, {"label": _("Subcontract"), "items": ["Purchase Order"]}, + {"label": _("Reservation"), "items": ["Stock Reservation Entry"]}, ], } diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index ab0abb4d48c..15342f9d6be 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -16,10 +16,15 @@ from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note 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.serial_and_batch_bundle.test_serial_and_batch_bundle import ( + get_batch_from_bundle, + get_serial_nos_from_bundle, +) from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( create_stock_reconciliation, ) +from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation class UnitTestProductionPlan(UnitTestCase): @@ -146,7 +151,7 @@ class TestProductionPlan(IntegrationTestCase): item_code="Raw Material Item 2", target="_Test Warehouse - _TC", qty=1, rate=120 ) - pln = create_production_plan(item_code="Test Production Item 1", ignore_existing_ordered_qty=1) + pln = create_production_plan(item_code="Test Production Item 1", ignore_existing_ordered_qty=0) self.assertTrue(len(pln.mr_items)) self.assertTrue(flt(pln.mr_items[0].quantity), 1.0) @@ -182,11 +187,17 @@ class TestProductionPlan(IntegrationTestCase): pln = create_production_plan( item_code="Test Production Item 1", use_multi_level_bom=0, ignore_existing_ordered_qty=1 ) - self.assertFalse(len(pln.mr_items)) + items = [] + for row in pln.mr_items: + if row.quantity > 0: + items.append(row.item_code) + + self.assertFalse(len(items)) + + pln.cancel() sr1.cancel() sr2.cancel() - pln.cancel() def test_production_plan_sales_orders(self): "Test if previously fulfilled SO (with WO) is pulled into Prod Plan." @@ -1910,6 +1921,417 @@ class TestProductionPlan(IntegrationTestCase): self.assertEqual(mr_items[0].get("quantity"), 80) self.assertEqual(mr_items[1].get("quantity"), 70) + def test_stock_reservation_against_production_plan(self): + from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + from erpnext.stock.doctype.material_request.material_request import make_purchase_order + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1) + + bom_tree = { + "Finished Good For SR": { + "Sub Assembly For SR 1": {"Raw Material For SR 1": {}}, + "Sub Assembly For SR 2": {"Raw Material For SR 2": {}}, + "Sub Assembly For SR 3": {"Raw Material For SR 3": {}}, + } + } + parent_bom = create_nested_bom(bom_tree, prefix="") + + warehouse = "_Test Warehouse - _TC" + + for item_code in [ + "Sub Assembly For SR 1", + "Sub Assembly For SR 2", + "Sub Assembly For SR 3", + "Raw Material For SR 1", + "Raw Material For SR 2", + "Raw Material For SR 3", + ]: + make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=100) + + plan = create_production_plan( + item_code=parent_bom.item, + planned_qty=15, + skip_available_sub_assembly_item=1, + ignore_existing_ordered_qty=1, + do_not_submit=1, + warehouse=warehouse, + sub_assembly_warehouse=warehouse, + for_warehouse=warehouse, + reserve_stock=1, + ) + + plan.get_sub_assembly_items() + plan.set("mr_items", []) + mr_items = get_items_for_material_requests(plan.as_dict()) + for d in mr_items: + plan.append("mr_items", d) + + plan.save() + + self.assertTrue(len(plan.sub_assembly_items) == 3) + for row in plan.sub_assembly_items: + self.assertEqual(row.required_qty, 15.0) + self.assertEqual(row.qty, 10.0) + + self.assertTrue(len(plan.mr_items) == 3) + for row in plan.mr_items: + self.assertEqual(row.required_bom_qty, 10.0) + self.assertEqual(row.quantity, 5.0) + + plan.submit() + + sre = StockReservation(plan) + reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) + self.assertTrue(len(reserved_entries) == 6) + + for row in reserved_entries: + self.assertEqual(row.reserved_qty, 5.0) + + plan.submit_material_request = 1 + plan.make_material_request() + plan.make_work_order() + + material_requests = frappe.get_all( + "Material Request", filters={"production_plan": plan.name}, pluck="name" + ) + + self.assertTrue(len(material_requests) > 0) + for mr_name in list(set(material_requests)): + po = make_purchase_order(mr_name) + po.supplier = "_Test Supplier" + po.submit() + + pr = make_purchase_receipt(po.name) + pr.submit() + + sre = StockReservation(plan) + reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) + self.assertTrue(len(reserved_entries) == 9) + + work_orders = frappe.get_all("Work Order", filters={"production_plan": plan.name}, pluck="name") + for wo_name in list(set(work_orders)): + wo_doc = frappe.get_doc("Work Order", wo_name) + self.assertEqual(wo_doc.reserve_stock, 1) + + wo_doc.source_warehouse = warehouse + wo_doc.wip_warehouse = warehouse + wo_doc.fg_warehouse = warehouse + wo_doc.submit() + + sre = StockReservation(wo_doc) + reserved_entries = sre.get_reserved_entries("Work Order", wo_doc.name) + if wo_doc.production_item == "Finished Good For SR": + self.assertEqual(len(reserved_entries), 3) + else: + # For raw materials 2 stock reservation entries + # 5 qty was present already in stock and 5 added from new PO + self.assertEqual(len(reserved_entries), 2) + + sre = StockReservation(plan) + reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) + self.assertTrue(len(reserved_entries) == 0) + frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0) + + def test_stock_reservation_of_serial_nos_against_production_plan(self): + from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + from erpnext.stock.doctype.material_request.material_request import make_purchase_order + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1) + + bom_tree = { + "Finished Good For SR": { + "SN Sub Assembly For SR 1": {"SN Raw Material For SR 1": {}}, + "SN Sub Assembly For SR 2": {"SN Raw Material For SR 2": {}}, + "SN Sub Assembly For SR 3": {"SN Raw Material For SR 3": {}}, + } + } + parent_bom = create_nested_bom(bom_tree, prefix="") + + warehouse = "_Test Warehouse - _TC" + + for item_code in [ + "SN Sub Assembly For SR 1", + "SN Sub Assembly For SR 2", + "SN Sub Assembly For SR 3", + "SN Raw Material For SR 1", + "SN Raw Material For SR 2", + "SN Raw Material For SR 3", + ]: + doc = frappe.get_doc("Item", item_code) + doc.has_serial_no = 1 + doc.serial_no_series = f"SNN-{item_code}.-.#####" + doc.save() + + make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=100) + + plan = create_production_plan( + item_code=parent_bom.item, + planned_qty=15, + skip_available_sub_assembly_item=1, + ignore_existing_ordered_qty=1, + do_not_submit=1, + warehouse=warehouse, + sub_assembly_warehouse=warehouse, + for_warehouse=warehouse, + reserve_stock=1, + ) + + plan.get_sub_assembly_items() + plan.set("mr_items", []) + mr_items = get_items_for_material_requests(plan.as_dict()) + for d in mr_items: + plan.append("mr_items", d) + + plan.save() + + self.assertTrue(len(plan.sub_assembly_items) == 3) + for row in plan.sub_assembly_items: + self.assertEqual(row.required_qty, 15.0) + self.assertEqual(row.qty, 10.0) + + self.assertTrue(len(plan.mr_items) == 3) + for row in plan.mr_items: + self.assertEqual(row.required_bom_qty, 10.0) + self.assertEqual(row.quantity, 5.0) + + plan.submit() + + sre = StockReservation(plan) + reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) + self.assertTrue(len(reserved_entries) == 6) + + for row in reserved_entries: + self.assertEqual(row.reserved_qty, 5.0) + + plan.submit_material_request = 1 + plan.make_material_request() + plan.make_work_order() + + material_requests = frappe.get_all( + "Material Request", filters={"production_plan": plan.name}, pluck="name" + ) + + additional_serial_nos = [] + + for item_code in [ + "SN Sub Assembly For SR 1", + "SN Sub Assembly For SR 2", + "SN Sub Assembly For SR 3", + "SN Raw Material For SR 1", + "SN Raw Material For SR 2", + "SN Raw Material For SR 3", + ]: + se = make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=100) + additional_serial_nos.extend(get_serial_nos_from_bundle(se.items[0].serial_and_batch_bundle)) + + self.assertTrue(additional_serial_nos) + + self.assertTrue(len(material_requests) > 0) + for mr_name in list(set(material_requests)): + po = make_purchase_order(mr_name) + po.supplier = "_Test Supplier" + po.submit() + + pr = make_purchase_receipt(po.name) + pr.submit() + + sre = StockReservation(plan) + reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) + self.assertTrue(len(reserved_entries) == 9) + serial_nos_res_for_pp = frappe.get_all( + "Serial and Batch Entry", + filters={"parent": ("in", [x.name for x in reserved_entries]), "docstatus": 1}, + pluck="serial_no", + ) + + work_orders = frappe.get_all("Work Order", filters={"production_plan": plan.name}, pluck="name") + for wo_name in list(set(work_orders)): + wo_doc = frappe.get_doc("Work Order", wo_name) + self.assertEqual(wo_doc.reserve_stock, 1) + + wo_doc.source_warehouse = warehouse + wo_doc.wip_warehouse = warehouse + wo_doc.fg_warehouse = warehouse + wo_doc.submit() + + sre = StockReservation(wo_doc) + reserved_entries = sre.get_reserved_entries("Work Order", wo_doc.name) + serial_nos_res_for_wo = frappe.get_all( + "Serial and Batch Entry", + filters={"parent": ("in", [x.name for x in reserved_entries]), "docstatus": 1}, + pluck="serial_no", + ) + + for serial_no in serial_nos_res_for_wo: + self.assertTrue(serial_no in serial_nos_res_for_pp) + self.assertFalse(serial_no in additional_serial_nos) + + if wo_doc.production_item == "Finished Good For SR": + self.assertEqual(len(reserved_entries), 3) + else: + # For raw materials 2 stock reservation entries + # 5 qty was present already in stock and 5 added from new PO + self.assertEqual(len(reserved_entries), 2) + + sre = StockReservation(plan) + reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) + self.assertTrue(len(reserved_entries) == 0) + frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0) + + def test_stock_reservation_of_batch_nos_against_production_plan(self): + from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + from erpnext.stock.doctype.material_request.material_request import make_purchase_order + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1) + + bom_tree = { + "Finished Good For SR": { + "Batch Sub Assembly For SR 1": {"Batch Raw Material For SR 1": {}}, + "Batch Sub Assembly For SR 2": {"Batch Raw Material For SR 2": {}}, + "Batch Sub Assembly For SR 3": {"Batch Raw Material For SR 3": {}}, + } + } + parent_bom = create_nested_bom(bom_tree, prefix="") + + warehouse = "_Test Warehouse - _TC" + + for item_code in [ + "Batch Sub Assembly For SR 1", + "Batch Sub Assembly For SR 2", + "Batch Sub Assembly For SR 3", + "Batch Raw Material For SR 1", + "Batch Raw Material For SR 2", + "Batch Raw Material For SR 3", + ]: + doc = frappe.get_doc("Item", item_code) + doc.has_batch_no = 1 + doc.create_new_batch = 1 + doc.batch_number_series = f"BCH-{item_code}.-.#####" + doc.save() + + make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=100) + + plan = create_production_plan( + item_code=parent_bom.item, + planned_qty=15, + skip_available_sub_assembly_item=1, + ignore_existing_ordered_qty=1, + do_not_submit=1, + warehouse=warehouse, + sub_assembly_warehouse=warehouse, + for_warehouse=warehouse, + reserve_stock=1, + ) + + plan.get_sub_assembly_items() + plan.set("mr_items", []) + mr_items = get_items_for_material_requests(plan.as_dict()) + for d in mr_items: + plan.append("mr_items", d) + + plan.save() + + self.assertTrue(len(plan.sub_assembly_items) == 3) + for row in plan.sub_assembly_items: + self.assertEqual(row.required_qty, 15.0) + self.assertEqual(row.qty, 10.0) + + self.assertTrue(len(plan.mr_items) == 3) + for row in plan.mr_items: + self.assertEqual(row.required_bom_qty, 10.0) + self.assertEqual(row.quantity, 5.0) + + plan.submit() + + sre = StockReservation(plan) + reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) + self.assertTrue(len(reserved_entries) == 6) + + for row in reserved_entries: + self.assertEqual(row.reserved_qty, 5.0) + + plan.submit_material_request = 1 + plan.make_material_request() + plan.make_work_order() + + material_requests = frappe.get_all( + "Material Request", filters={"production_plan": plan.name}, pluck="name" + ) + + additional_batches = [] + + for item_code in [ + "Batch Sub Assembly For SR 1", + "Batch Sub Assembly For SR 2", + "Batch Sub Assembly For SR 3", + "Batch Raw Material For SR 1", + "Batch Raw Material For SR 2", + "Batch Raw Material For SR 3", + ]: + se = make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=100) + batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle) + additional_batches.append(batch_no) + + self.assertTrue(additional_batches) + + self.assertTrue(len(material_requests) > 0) + for mr_name in list(set(material_requests)): + po = make_purchase_order(mr_name) + po.supplier = "_Test Supplier" + po.submit() + + pr = make_purchase_receipt(po.name) + pr.submit() + + sre = StockReservation(plan) + reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) + self.assertTrue(len(reserved_entries) == 9) + batches_reserved_for_pp = frappe.get_all( + "Serial and Batch Entry", + filters={"parent": ("in", [x.name for x in reserved_entries]), "docstatus": 1}, + pluck="batch_no", + ) + + work_orders = frappe.get_all("Work Order", filters={"production_plan": plan.name}, pluck="name") + for wo_name in list(set(work_orders)): + wo_doc = frappe.get_doc("Work Order", wo_name) + self.assertEqual(wo_doc.reserve_stock, 1) + + wo_doc.source_warehouse = warehouse + wo_doc.wip_warehouse = warehouse + wo_doc.fg_warehouse = warehouse + wo_doc.submit() + + sre = StockReservation(wo_doc) + reserved_entries = sre.get_reserved_entries("Work Order", wo_doc.name) + batches_reserved_for_wo = frappe.get_all( + "Serial and Batch Entry", + filters={"parent": ("in", [x.name for x in reserved_entries]), "docstatus": 1}, + pluck="batch_no", + ) + + for batch_no in batches_reserved_for_wo: + self.assertTrue(batch_no in batches_reserved_for_pp) + self.assertFalse(batch_no in additional_batches) + + if wo_doc.production_item == "Finished Good For SR": + self.assertEqual(len(reserved_entries), 3) + else: + # For raw materials 2 stock reservation entries + # 5 qty was present already in stock and 5 added from new PO + self.assertEqual(len(reserved_entries), 2) + + sre = StockReservation(plan) + reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) + self.assertTrue(len(reserved_entries) == 0) + frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0) + def create_production_plan(**args): """ @@ -1931,6 +2353,7 @@ def create_production_plan(**args): "get_items_from": "Sales Order", "skip_available_sub_assembly_item": args.skip_available_sub_assembly_item or 0, "sub_assembly_warehouse": args.sub_assembly_warehouse, + "reserve_stock": args.reserve_stock or 0, } ) diff --git a/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json b/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json index c5330d74772..38fe30cc9de 100644 --- a/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json +++ b/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json @@ -26,7 +26,7 @@ }, { "fieldname": "qty", - "fieldtype": "Data", + "fieldtype": "Float", "in_list_view": 1, "label": "Qty" }, @@ -37,17 +37,19 @@ "label": "Item Reference" } ], + "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:10:20.410593", + "modified": "2025-05-07 17:47:36.244083", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan Item Reference", "owner": "Administrator", "permissions": [], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.py b/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.py index d95bf8c006f..d72402201d3 100644 --- a/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.py +++ b/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.py @@ -19,7 +19,7 @@ class ProductionPlanItemReference(Document): parent: DF.Data parentfield: DF.Data parenttype: DF.Data - qty: DF.Data | None + qty: DF.Float sales_order: DF.Link | None sales_order_item: DF.Data | None # end: auto-generated types diff --git a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json index 5b87373f243..6a5d7dcb3d2 100644 --- a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json +++ b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json @@ -9,29 +9,32 @@ "item_name", "fg_warehouse", "parent_item_code", - "schedule_date", "column_break_3", - "qty", "bom_no", "bom_level", "type_of_manufacturing", + "section_break_4rxf", + "required_qty", + "column_break_xfhm", + "projected_qty", + "qty", + "subcontracting_section", "supplier", - "work_order_details_section", - "wo_produced_qty", "purchase_order", + "work_order_details_section", "production_plan_item", + "wo_produced_qty", + "stock_reserved_qty", "column_break_7", "received_qty", "indent", "section_break_19", + "schedule_date", "uom", "stock_uom", - "column_break_22", - "description", - "section_break_4rxf", "actual_qty", - "column_break_xfhm", - "projected_qty" + "column_break_22", + "description" ], "fields": [ { @@ -49,18 +52,18 @@ "depends_on": "eval:doc.type_of_manufacturing == \"In House\"", "fieldname": "work_order_details_section", "fieldtype": "Section Break", - "label": "Reference" + "label": "Manufacturing" }, { "fieldname": "column_break_7", "fieldtype": "Column Break" }, { - "columns": 1, + "columns": 2, "fieldname": "qty", "fieldtype": "Float", "in_list_view": 1, - "label": "Required Qty", + "label": "Qty to Order", "read_only": 1 }, { @@ -152,7 +155,6 @@ "columns": 2, "fieldname": "fg_warehouse", "fieldtype": "Link", - "in_list_view": 1, "label": "Target Warehouse", "options": "Warehouse" }, @@ -167,22 +169,24 @@ { "fieldname": "supplier", "fieldtype": "Link", + "in_list_view": 1, "label": "Supplier", - "mandatory_depends_on": "eval:doc.type_of_manufacturing == 'Subcontract'", + "mandatory_depends_on": "eval:doc.type_of_manufacturing == 'Subcontract' && doc.qty > 0", "options": "Supplier" }, { - "columns": 1, + "columns": 2, "fieldname": "schedule_date", "fieldtype": "Datetime", - "in_list_view": 1, "label": "Schedule Date" }, { "fieldname": "section_break_4rxf", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Quantity" }, { + "columns": 2, "fieldname": "actual_qty", "fieldtype": "Float", "label": "Actual Qty", @@ -194,8 +198,10 @@ "fieldtype": "Column Break" }, { + "columns": 2, "fieldname": "projected_qty", "fieldtype": "Float", + "in_list_view": 1, "label": "Projected Qty", "no_copy": 1, "read_only": 1 @@ -205,20 +211,42 @@ "fieldtype": "Float", "label": "Produced Qty", "read_only": 1 + }, + { + "columns": 2, + "fieldname": "required_qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Required Qty" + }, + { + "fieldname": "subcontracting_section", + "fieldtype": "Section Break", + "label": "Subcontracting" + }, + { + "fieldname": "stock_reserved_qty", + "fieldtype": "Float", + "label": "Stock Reserved Qty", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 } ], + "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-01-01 17:50:32.273610", + "modified": "2025-05-01 14:28:35.979941", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan Sub Assembly Item", "owner": "Administrator", "permissions": [], "quick_entry": 1, + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.py b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.py index ad1d655de8b..41e5bf28b56 100644 --- a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.py +++ b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.py @@ -32,7 +32,9 @@ class ProductionPlanSubAssemblyItem(Document): purchase_order: DF.Link | None qty: DF.Float received_qty: DF.Float + required_qty: DF.Float schedule_date: DF.Datetime | None + stock_reserved_qty: DF.Float stock_uom: DF.Link | None supplier: DF.Link | None type_of_manufacturing: DF.Literal["In House", "Subcontract", "Material Request"] diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 3171bbd2741..7b7a670e6f0 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -43,11 +43,6 @@ "skip_transfer", "from_wip_warehouse", "update_consumed_material_cost_in_project", - "serial_no_and_batch_for_finished_good_section", - "has_serial_no", - "has_batch_no", - "column_break_18", - "batch_size", "time", "planned_start_date", "planned_end_date", @@ -63,6 +58,11 @@ "column_break_24", "corrective_operation_cost", "total_operating_cost", + "serial_no_and_batch_for_finished_good_section", + "has_serial_no", + "has_batch_no", + "column_break_18", + "batch_size", "more_info", "description", "stock_uom", @@ -505,7 +505,7 @@ "depends_on": "eval:!doc.__islocal", "fieldname": "serial_no_and_batch_for_finished_good_section", "fieldtype": "Section Break", - "label": "Serial No and Batch for Finished Good" + "label": "Finished Good Serial / Batch" }, { "fieldname": "column_break_17", @@ -594,12 +594,13 @@ "label": " Reserve Stock" } ], + "grid_page_length": 50, "icon": "fa fa-cogs", "idx": 1, "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2024-09-23 16:56:00.483027", + "modified": "2025-04-25 11:46:38.739588", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", @@ -629,10 +630,11 @@ "role": "Stock User" } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "ASC", "states": [], "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 5bbda3a0986..0aaaf78f50c 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -10,7 +10,7 @@ from frappe import _ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc from frappe.query_builder import Case -from frappe.query_builder.functions import Sum +from frappe.query_builder.functions import IfNull, Sum from frappe.utils import ( cint, date_diff, @@ -1397,7 +1397,11 @@ class WorkOrder(Document): def set_reserved_qty_for_wip_and_fg(self, stock_entry): items = frappe._dict() - if stock_entry.purpose == "Manufacture" and self.sales_order: + + stock_entry.reload() + if stock_entry.purpose == "Manufacture" and ( + self.sales_order or self.production_plan_sub_assembly_item + ): items = self.get_finished_goods_for_reservation(stock_entry) elif stock_entry.purpose == "Material Transfer for Manufacture": items = self.get_list_of_materials_for_reservation(stock_entry) @@ -1438,37 +1442,97 @@ class WorkOrder(Document): def get_finished_goods_for_reservation(self, stock_entry): items = frappe._dict() - so_details = self.get_so_details() - qty = so_details.stock_qty - so_details.stock_reserved_qty - if not qty: - return items + if self.production_plan_sub_assembly_item: + # Reserve the sub-assembly item for the final product for the work order. + item_details = self.get_wo_details() + else: + # Reserve the final product for the sales order. + item_details = self.get_so_details() - for row in stock_entry.items: - if not row.t_warehouse or not row.is_finished_item: + for item in item_details: + qty_to_reserve = flt(item.stock_qty) - flt(item.stock_reserved_qty + item.delivered_qty) + if qty_to_reserve <= 0: continue - if qty > row.transfer_qty: - qty = row.transfer_qty + warehouse = item.warehouse + if ( + item.get("parenttype") == "Work Order" + and item.get("skip_transfer") + and item.get("from_wip_warehouse") + ): + warehouse = item.wip_warehouse - if row.item_code not in items: - items[row.item_code] = frappe._dict( - { - "voucher_no": self.sales_order, - "voucher_type": "Sales Order", - "voucher_detail_no": so_details.name, - "item_code": row.item_code, - "warehouse": row.t_warehouse, - "stock_qty": qty, - "from_voucher_no": stock_entry.name, - "from_voucher_type": stock_entry.doctype, - "from_voucher_detail_no": row.name, - } - ) - else: - items[row.item_code]["stock_qty"] += qty + for row in stock_entry.items: + if ( + not row.t_warehouse + or not row.is_finished_item + or row.t_warehouse != warehouse + or row.item_code != item.item_code + ): + continue + + reserved_qty = qty_to_reserve + if qty_to_reserve > row.transfer_qty: + reserved_qty = row.transfer_qty + qty_to_reserve -= row.transfer_qty + else: + qty_to_reserve = 0 + + if row.item_code not in items: + items[row.item_code] = frappe._dict( + { + "voucher_no": item.voucher_no, + "voucher_type": item.voucher_type, + "voucher_detail_no": item.name, + "item_code": row.item_code, + "warehouse": row.t_warehouse, + "stock_qty": reserved_qty, + "from_voucher_no": stock_entry.name, + "from_voucher_type": stock_entry.doctype, + "from_voucher_detail_no": row.name, + "serial_and_batch_bundles": [row.serial_and_batch_bundle], + } + ) + else: + items[row.item_code]["stock_qty"] += reserved_qty return items + def get_wo_details(self): + doctype = frappe.qb.DocType("Work Order") + child_doctype = frappe.qb.DocType("Work Order Item") + + query = ( + frappe.qb.from_(doctype) + .inner_join(child_doctype) + .on(doctype.name == child_doctype.parent) + .select( + child_doctype.name, + child_doctype.required_qty.as_("stock_qty"), + child_doctype.transferred_qty.as_("delivered_qty"), + child_doctype.stock_reserved_qty, + child_doctype.source_warehouse.as_("warehouse"), + doctype.wip_warehouse, + doctype.skip_transfer, + doctype.from_wip_warehouse, + child_doctype.parenttype, + child_doctype.item_code, + child_doctype.parent.as_("voucher_no"), + child_doctype.parenttype.as_("voucher_type"), + ) + .where( + (child_doctype.item_code == self.production_item) + & (doctype.docstatus == 1) + & (doctype.production_plan == self.production_plan) + & ( + IfNull(doctype.production_plan_sub_assembly_item, "") + != self.production_plan_sub_assembly_item + ) + ) + ) + + return query.run(as_dict=1) + def get_so_details(self): return frappe.db.get_value( "Sales Order Item", @@ -1476,9 +1540,16 @@ class WorkOrder(Document): "parent": self.sales_order, "item_code": self.production_item, "docstatus": 1, - "stock_reserved_qty": 0, }, - ["name", "stock_qty", "stock_reserved_qty"], + [ + "name", + "stock_qty", + "stock_reserved_qty", + "warehouse", + "parent as voucher_no", + "parenttype as voucher_type", + "delivered_qty", + ], as_dict=1, ) @@ -1521,7 +1592,7 @@ class WorkOrder(Document): @frappe.whitelist() -def make_stock_reservation_entries(doc, items=None, notify=False): +def make_stock_reservation_entries(doc, items=None, table_name=None, notify=False): if isinstance(doc, str): doc = parse_json(doc) doc = frappe.get_doc("Work Order", doc.get("name")) @@ -1531,7 +1602,13 @@ def make_stock_reservation_entries(doc, items=None, notify=False): sre = StockReservation(doc, items=items, notify=notify) if doc.docstatus == 1: - sre.make_stock_reservation_entries() + if doc.production_plan: + sre.transfer_reservation_entries_to( + doc.production_plan, from_doctype="Production Plan", to_doctype="Work Order" + ) + else: + sre.make_stock_reservation_entries() + frappe.msgprint(_("Stock Reservation Entries Created"), alert=True) elif doc.docstatus == 2: sre.cancel_stock_reservation_entries() 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 48ffbac5820..d233643c244 100644 --- a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py +++ b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py @@ -23,6 +23,7 @@ def get_columns(): """return columns""" columns = [ _("Item") + ":Link/Item:150", + _("Item Name") + "::240", _("Description") + "::300", _("BOM Qty") + ":Float:160", _("BOM UoM") + "::160", @@ -73,6 +74,7 @@ def get_bom_stock(filters): .on((BOM_ITEM.item_code == BIN.item_code) & (CONDITIONS)) .select( BOM_ITEM.item_code, + BOM_ITEM.item_name, BOM_ITEM.description, BOM_ITEM.stock_qty, BOM_ITEM.stock_uom, diff --git a/erpnext/manufacturing/report/bom_stock_report/test_bom_stock_report.py b/erpnext/manufacturing/report/bom_stock_report/test_bom_stock_report.py index 4571129cdce..860ba3f57f7 100644 --- a/erpnext/manufacturing/report/bom_stock_report/test_bom_stock_report.py +++ b/erpnext/manufacturing/report/bom_stock_report/test_bom_stock_report.py @@ -94,6 +94,7 @@ def get_expected_data(bom, warehouse, qty_to_produce, show_exploded_view=False): expected_data.append( [ item.item_code, + item.item_name, item.description, item.stock_qty, item.stock_uom, diff --git a/erpnext/patches.txt b/erpnext/patches.txt index ba9e6b0ee54..ec3ae09117b 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -408,3 +408,6 @@ erpnext.stock.doctype.stock_ledger_entry.patches.ensure_sle_indexes erpnext.patches.v15_0.update_query_report erpnext.patches.v15_0.set_purchase_receipt_row_item_to_capitalization_stock_item erpnext.patches.v15_0.update_payment_schedule_fields_in_invoices +erpnext.patches.v15_0.rename_group_by_to_categorize_by +execute:frappe.db.set_single_value("Accounts Settings", "receivable_payable_fetch_method", "Buffered Cursor") +erpnext.patches.v14_0.set_update_price_list_based_on diff --git a/erpnext/patches/v14_0/set_update_price_list_based_on.py b/erpnext/patches/v14_0/set_update_price_list_based_on.py new file mode 100644 index 00000000000..4ddef4b0c25 --- /dev/null +++ b/erpnext/patches/v14_0/set_update_price_list_based_on.py @@ -0,0 +1,14 @@ +import frappe +from frappe.utils import cint + + +def execute(): + frappe.db.set_single_value( + "Stock Settings", + "update_price_list_based_on", + ( + "Price List Rate" + if cint(frappe.db.get_single_value("Selling Settings", "editable_price_list_rate")) + else "Rate" + ), + ) diff --git a/erpnext/patches/v15_0/rename_group_by_to_categorize_by.py b/erpnext/patches/v15_0/rename_group_by_to_categorize_by.py new file mode 100644 index 00000000000..1490ec572f4 --- /dev/null +++ b/erpnext/patches/v15_0/rename_group_by_to_categorize_by.py @@ -0,0 +1,20 @@ +import frappe +from frappe.model.utils.rename_field import rename_field + + +def execute(): + rename_field("Process Statement Of Accounts", "group_by", "categorize_by") + + frappe.db.sql( + """ + UPDATE + `tabProcess Statement Of Accounts` + SET + categorize_by = CASE + WHEN categorize_by = 'Group by Voucher (Consolidated)' THEN 'Categorize by Voucher (Consolidated)' + WHEN categorize_by = 'Group by Voucher' THEN 'Categorize by Voucher' + END + WHERE + categorize_by IN ('Group by Voucher (Consolidated)', 'Group by Voucher') + """ + ) diff --git a/erpnext/projects/doctype/activity_cost/test_activity_cost.py b/erpnext/projects/doctype/activity_cost/test_activity_cost.py index 03db2fe6d5a..470b21cca87 100644 --- a/erpnext/projects/doctype/activity_cost/test_activity_cost.py +++ b/erpnext/projects/doctype/activity_cost/test_activity_cost.py @@ -6,16 +6,23 @@ import frappe from frappe.tests import IntegrationTestCase from erpnext.projects.doctype.activity_cost.activity_cost import DuplicationError +from erpnext.tests.utils import ERPNextTestSuite -class TestActivityCost(IntegrationTestCase): +class TestActivityCost(ERPNextTestSuite): + @classmethod + def setUpClass(cls): + super().setUpClass() + # TODO: only 1 employee is required + cls.make_employees() + def test_duplication(self): frappe.db.sql("delete from `tabActivity Cost`") activity_cost1 = frappe.new_doc("Activity Cost") activity_cost1.update( { - "employee": "_T-Employee-00001", - "employee_name": "_Test Employee", + "employee": self.employees[0].name, + "employee_name": self.employees[0].first_name, "activity_type": "_Test Activity Type 1", "billing_rate": 100, "costing_rate": 50, diff --git a/erpnext/projects/doctype/project/test_project.py b/erpnext/projects/doctype/project/test_project.py index 901f7beabf1..2c88b88ff4e 100644 --- a/erpnext/projects/doctype/project/test_project.py +++ b/erpnext/projects/doctype/project/test_project.py @@ -9,6 +9,7 @@ from erpnext.projects.doctype.project_template.test_project_template import make from erpnext.projects.doctype.task.test_task import create_task from erpnext.selling.doctype.sales_order.sales_order import make_project as make_project_from_so from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order +from erpnext.tests.utils import ERPNextTestSuite IGNORE_TEST_RECORD_DEPENDENCIES = ["Sales Order"] @@ -22,7 +23,12 @@ class UnitTestProject(UnitTestCase): pass -class TestProject(IntegrationTestCase): +class TestProject(ERPNextTestSuite): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.make_projects() + def test_project_with_template_having_no_parent_and_depend_tasks(self): project_name = "Test Project with Template - No Parent and Dependend Tasks" frappe.db.sql(""" delete from tabTask where project = %s """, project_name) diff --git a/erpnext/projects/doctype/project/test_records.json b/erpnext/projects/doctype/project/test_records.json deleted file mode 100644 index 49021da4b7c..00000000000 --- a/erpnext/projects/doctype/project/test_records.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - { - "company": "_Test Company", - "project_name": "_Test Project", - "status": "Open" - } -] \ No newline at end of file diff --git a/erpnext/projects/doctype/task/test_task.py b/erpnext/projects/doctype/task/test_task.py index 082b474c07a..7a93585e832 100644 --- a/erpnext/projects/doctype/task/test_task.py +++ b/erpnext/projects/doctype/task/test_task.py @@ -7,9 +7,15 @@ from frappe.tests import IntegrationTestCase from frappe.utils import add_days, getdate, nowdate from erpnext.projects.doctype.task.task import CircularReferenceError +from erpnext.tests.utils import ERPNextTestSuite -class TestTask(IntegrationTestCase): +class TestTask(ERPNextTestSuite): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.make_projects() + def test_circular_reference(self): task1 = create_task("_Test Task 1", add_days(nowdate(), -15), add_days(nowdate(), -10)) task2 = create_task("_Test Task 2", add_days(nowdate(), 11), add_days(nowdate(), 15), task1.name) diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py index 311fe3da140..f9c631b00a8 100644 --- a/erpnext/projects/doctype/timesheet/test_timesheet.py +++ b/erpnext/projects/doctype/timesheet/test_timesheet.py @@ -10,9 +10,15 @@ from frappe.utils import add_to_date, now_datetime, nowdate from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.projects.doctype.timesheet.timesheet import OverlapError, make_sales_invoice from erpnext.setup.doctype.employee.test_employee import make_employee +from erpnext.tests.utils import ERPNextTestSuite -class TestTimesheet(IntegrationTestCase): +class TestTimesheet(ERPNextTestSuite): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.make_projects() + def setUp(self): frappe.db.delete("Timesheet") diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index 050891f27d0..a43763177db 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -500,7 +500,6 @@ def get_timesheets_list(doctype, txt, filters, limit_start, limit_page_length=20 user = frappe.session.user # find customer name from contact. customer = "" - timesheets = [] contact = frappe.db.exists("Contact", {"user": user}) if contact: @@ -509,31 +508,43 @@ def get_timesheets_list(doctype, txt, filters, limit_start, limit_page_length=20 customer = contact.get_link_for("Customer") if customer: - sales_invoices = [ - d.name for d in frappe.get_all("Sales Invoice", filters={"customer": customer}) - ] or [None] - projects = [d.name for d in frappe.get_all("Project", filters={"customer": customer})] - # Return timesheet related data to web portal. - timesheets = frappe.db.sql( - f""" - SELECT - ts.name, tsd.activity_type, ts.status, ts.total_billable_hours, - COALESCE(ts.sales_invoice, tsd.sales_invoice) AS sales_invoice, tsd.project - FROM `tabTimesheet` ts, `tabTimesheet Detail` tsd - WHERE tsd.parent = ts.name AND - ( - ts.sales_invoice IN %(sales_invoices)s OR - tsd.sales_invoice IN %(sales_invoices)s OR - tsd.project IN %(projects)s - ) - ORDER BY `end_date` ASC - LIMIT {limit_page_length} offset {limit_start} - """, - dict(sales_invoices=sales_invoices, projects=projects), - as_dict=True, - ) # nosec + sales_invoices = frappe.get_all("Sales Invoice", filters={"customer": customer}, pluck="name") + projects = frappe.get_all("Project", filters={"customer": customer}, pluck="name") - return timesheets + # Return timesheet related data to web portal. + table = frappe.qb.DocType("Timesheet") + child_table = frappe.qb.DocType("Timesheet Detail") + query = ( + frappe.qb.from_(table) + .join(child_table) + .on(table.name == child_table.parent) + .select( + table.name, + child_table.activity_type, + table.status, + child_table.billing_hours, + (table.sales_invoice | child_table.sales_invoice).as_("sales_invoice"), + child_table.project, + ) + .orderby(table.end_date) + .limit(limit_page_length) + .offset(limit_start) + ) + + conditions = [] + if sales_invoices: + conditions.extend( + [table.sales_invoice.isin(sales_invoices), child_table.sales_invoice.isin(sales_invoices)] + ) + if projects: + conditions.append(child_table.project.isin(projects)) + + if conditions: + query = query.where(frappe.qb.terms.Criterion.any(conditions)) + + return query.run(as_dict=True) + else: + return {} def get_list_context(context=None): diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js index cbc59867e46..2b854d649d8 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -54,6 +54,12 @@ erpnext.buying = { return erpnext.queries.company_address_query(this.frm.doc) }); } + + if(this.frm.get_field('dispatch_address')) { + this.frm.set_query("dispatch_address", () => { + return erpnext.queries.address_query(this.frm.doc); + }); + } } setup_queries(doc, cdt, cdn) { @@ -295,6 +301,12 @@ erpnext.buying = { "shipping_address_display", true); } + dispatch_address(){ + var me = this; + erpnext.utils.get_address_display(this.frm, "dispatch_address", + "dispatch_address_display", true); + } + billing_address() { erpnext.utils.get_address_display(this.frm, "billing_address", "billing_address_display", true); diff --git a/erpnext/public/js/controllers/stock_controller.js b/erpnext/public/js/controllers/stock_controller.js index 3181d76857d..bc2df9c388d 100644 --- a/erpnext/public/js/controllers/stock_controller.js +++ b/erpnext/public/js/controllers/stock_controller.js @@ -87,7 +87,7 @@ erpnext.stock.StockController = class StockController extends frappe.ui.form.Con from_date: me.frm.doc.posting_date, to_date: moment(me.frm.doc.modified).format('YYYY-MM-DD'), company: me.frm.doc.company, - group_by: "Group by Voucher (Consolidated)", + categorize_by: "Categorize by Voucher (Consolidated)", show_cancelled_entries: me.frm.doc.docstatus === 2, ignore_prepared_report: true }; diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 7e9ad067cab..bab34a3f665 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -64,7 +64,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { && this.frm.doc.is_pos && this.frm.doc.is_return ) { - this.set_total_amount_to_default_mop(); + await this.set_total_amount_to_default_mop(); this.calculate_paid_amount(); } @@ -911,23 +911,25 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { it should set the return to that mode of payment only. */ - let return_against_mop = await frappe.call({ - method: 'erpnext.controllers.sales_and_purchase_return.get_payment_data', - args: { - invoice: this.frm.doc.return_against - } - }); - - if (return_against_mop.message.length === 1) { - this.frm.doc.payments.forEach(payment => { - if (payment.mode_of_payment == return_against_mop.message[0].mode_of_payment) { - payment.amount = total_amount_to_pay; - } else { - payment.amount = 0; + if(this.frm.doc.return_against){ + let {message : return_against_mop } = await frappe.call({ + method: 'erpnext.controllers.sales_and_purchase_return.get_payment_data', + args: { + invoice: this.frm.doc.return_against } }); - this.frm.refresh_fields(); - return; + + if (return_against_mop.length === 1) { + this.frm.doc.payments.forEach(payment => { + if (payment.mode_of_payment == return_against_mop[0].mode_of_payment) { + payment.amount = total_amount_to_pay; + } else { + payment.amount = 0; + } + }); + this.frm.refresh_fields(); + return; + } } this.frm.doc.payments.find(payment => { diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index e6d78339454..7db0fb1a5b5 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -795,6 +795,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe return; } + if (item.serial_no) { + item.use_serial_batch_fields = 1 + } + if (item && item.serial_no) { if (!item.item_code) { this.frm.trigger("item_code", cdt, cdn); @@ -843,8 +847,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe frappe.model.set_value(item.doctype, item.name, "stock_qty", valid_serial_nos.length); } - validate() { - this.calculate_taxes_and_totals(false); + async validate() { + await this.calculate_taxes_and_totals(false); } update_stock() { @@ -1358,13 +1362,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } } - batch_no(doc, cdt, cdn) { - let item = frappe.get_doc(cdt, cdn); - if (!this.is_a_mapped_document(item)) { - this.apply_price_list(item, true); - } - } - toggle_conversion_factor(item) { // toggle read only property for conversion factor field if the uom and stock uom are same if(this.frm.get_field('items').grid.fields_map.conversion_factor) { @@ -1590,7 +1587,12 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe batch_no(frm, cdt, cdn) { let row = locals[cdt][cdn]; - if (row.use_serial_batch_fields && row.batch_no) { + + if (row.batch_no) { + row.use_serial_batch_fields = 1 + } + + if (row.batch_no) { var params = this._get_args(row); params.batch_no = row.batch_no; params.uom = row.uom; @@ -2777,3 +2779,19 @@ erpnext.apply_putaway_rule = (frm, purpose=null) => { } }); }; + +erpnext.set_unit_price_items_note = (frm) => { + if (frm.doc.has_unit_price_items && !frm.is_new()) { + // Remove existing note + const $note = $(frm.layout.wrapper.find(".unit-price-items-note")); + if ($note.length) { $note.parent().remove(); } + + frm.layout.show_message( + `
+ ${__("The {0} contains Unit Price Items.", [__(frm.doc.doctype)])}
+ `,
+ "yellow",
+ true
+ );
+ }
+};
diff --git a/erpnext/public/js/event.js b/erpnext/public/js/event.js
index a6733915a5c..2950ace888d 100644
--- a/erpnext/public/js/event.js
+++ b/erpnext/public/js/event.js
@@ -47,7 +47,7 @@ frappe.ui.form.on("Event", {
frm.add_custom_button(
__("Add Sales Partners"),
function () {
- new frappe.desk.eventParticipants(frm, "Sales Partners");
+ new frappe.desk.eventParticipants(frm, "Sales Partner");
},
__("Add Participants")
);
diff --git a/erpnext/public/js/stock_reservation.js b/erpnext/public/js/stock_reservation.js
index 58d57aff499..854212ee5ae 100644
--- a/erpnext/public/js/stock_reservation.js
+++ b/erpnext/public/js/stock_reservation.js
@@ -14,7 +14,7 @@ $.extend(erpnext.stock_reservation, {
fields: erpnext.stock_reservation.get_dialog_fields(frm, parms),
primary_action_label: __("Reserve Stock"),
primary_action: () => {
- erpnext.stock_reservation.reserve_stock(frm, parms);
+ erpnext.stock_reservation.reserve_stock(frm, table_name, parms);
},
});
@@ -32,9 +32,20 @@ $.extend(erpnext.stock_reservation, {
"Work Order": "required_qty",
}[frm.doc.doctype];
+ if (frm.doc.doctype === "Production Plan") {
+ if (table_name === "sub_assembly_items") {
+ params["qty_field"] = "qty";
+ params["item_code_field"] = "production_item";
+ params["warehouse_field"] = "fg_warehouse";
+ } else {
+ params["qty_field"] = "quantity";
+ }
+ }
+
params["dispatch_qty_field"] = {
"Sales Order": "delivered_qty",
"Work Order": "transferred_qty",
+ "Production Plan": "delivered_qty",
}[frm.doc.doctype];
params["method"] = {
@@ -140,6 +151,9 @@ $.extend(erpnext.stock_reservation, {
dispatch_qty_field = "consumed_qty";
}
+ let item_code_field = parms.item_code_field || "item_code";
+ let warehouse_field = parms.warehouse_field || "warehouse";
+
frm.doc[parms.table_name].forEach((item) => {
if (frm.doc.reserve_stock) {
let unreserved_qty =
@@ -152,8 +166,8 @@ $.extend(erpnext.stock_reservation, {
if (unreserved_qty > 0) {
let args = {
__checked: 1,
- item_code: item.item_code,
- warehouse: item.warehouse || item.source_warehouse,
+ item_code: item[item_code_field] || item.item_code,
+ warehouse: item[warehouse_field] || item.warehouse || item.source_warehouse,
};
args[field] = item.name;
@@ -167,7 +181,7 @@ $.extend(erpnext.stock_reservation, {
dialog.show();
},
- reserve_stock(frm, parms) {
+ reserve_stock(frm, table_name, parms) {
let dialog = erpnext.stock_reservation.dialog;
var data = { items: dialog.fields_dict.items.grid.get_selected_children() };
@@ -177,6 +191,7 @@ $.extend(erpnext.stock_reservation, {
args: {
doc: frm.doc,
items: data.items,
+ table_name: table_name,
notify: true,
},
freeze: true,
diff --git a/erpnext/public/js/templates/crm_activities.html b/erpnext/public/js/templates/crm_activities.html
index 42603196087..5d0bc16ce32 100644
--- a/erpnext/public/js/templates/crm_activities.html
+++ b/erpnext/public/js/templates/crm_activities.html
@@ -57,6 +57,47 @@
{{ __("No open task") }}
{% } %}
+
+ {% if (typeof tasks_history == "object" && tasks_history?.length) { %}
+
+
+ {% } %}
+ {{ __("Completed Tasks") }}
+
+ {% for (const t of tasks_history) { %}
+
+ {% if(t.date || t.allocated_to) { %}
+
+ {% } %}
+
+
+ {% } %}
+
+ {% if(t.allocated_to) { %}
+ {%= frappe.avatar(t.allocated_to) %}
+ {% } %}
+
+
+
+ {% if (t.date) { %}
+
+ {{ __("Done") }}
+
+
+ {%= frappe.datetime.global_date_format(t.date) %}
+
+ {% } %}
+
+
+
+
+
+
+
@@ -104,6 +145,49 @@
{{ __("No open event") }}
{% } %}
+
+ {% if (typeof events_history == "object" && events_history?.length) { %}
+
+
+ {% } %}
+ {{ __("Past Events") }}
+
+ {% let icon_set = {"Sent/Received Email": "mail", "Call": "call", "Meeting": "share-people"}; %}
+ {% for(const event of events_history) { %}
+
+
+ {% } %}
+
+
+
+
+
+
+
+ {%= event.subject %}
+
+
+
+
+
+
+ {%= frappe.datetime.global_date_format(event.starts_on) %}
+
+ {% if (event.ends_on) { %}
+ {% if (frappe.datetime.obj_to_user(event.starts_on) != frappe.datetime.obj_to_user(event.ends_on)) %}
+ -
+ {%= frappe.datetime.global_date_format(frappe.datetime.obj_to_user(event.ends_on)) %}
+ {%= frappe.datetime.get_time(event.ends_on) %}
+ {% } else if (event.ends_on) { %}
+ -
+ {%= frappe.datetime.get_time(event.ends_on) %}
+ {% } %}
+ {% } %}
+
+
+ ");
const frm = _frm || new frappe.ui.form.Form(doctype, page, false);
const name = frappe.model.make_new_doc_and_get_name(doctype, true);
@@ -566,12 +580,18 @@ erpnext.PointOfSale.Controller = class {
return frm;
}
+ sync_draft_invoice_to_frm(doctype, invoice) {
+ return frappe.db.get_doc(doctype, invoice).then((doc) => {
+ frappe.model.sync(doc);
+ });
+ }
+
async make_return_invoice(doc) {
- frappe.dom.freeze();
- this.frm = this.get_new_frm(this.frm);
- this.frm.doc.items = [];
return frappe.call({
- method: "erpnext.accounts.doctype.pos_invoice.pos_invoice.make_sales_return",
+ method:
+ doc.doctype == "POS Invoice"
+ ? "erpnext.accounts.doctype.pos_invoice.pos_invoice.make_sales_return"
+ : "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_sales_return",
args: {
source_name: doc.name,
target_doc: this.frm.doc,
@@ -579,9 +599,7 @@ erpnext.PointOfSale.Controller = class {
callback: (r) => {
frappe.model.sync(r.message);
frappe.get_doc(r.message.doctype, r.message.name).__run_link_triggers = false;
- this.set_pos_profile_data().then(() => {
- frappe.dom.unfreeze();
- });
+ this.set_pos_profile_data();
},
});
}
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 f166516fd93..3d70a63b579 100644
--- a/erpnext/selling/page/point_of_sale/pos_item_cart.js
+++ b/erpnext/selling/page/point_of_sale/pos_item_cart.js
@@ -209,6 +209,11 @@ erpnext.PointOfSale.ItemCart = class {
// called when discount is applied
this.update_totals_section(frm);
});
+
+ frappe.ui.form.on("Sales Invoice", "paid_amount", (frm) => {
+ // called when discount is applied
+ this.update_totals_section(frm);
+ });
}
attach_shortcuts() {
@@ -989,13 +994,13 @@ erpnext.PointOfSale.ItemCart = class {
}
fetch_customer_transactions() {
- frappe.db
- .get_list("POS Invoice", {
- filters: { customer: this.customer_info.customer, docstatus: 1 },
- fields: ["name", "grand_total", "status", "posting_date", "posting_time", "currency"],
- limit: 20,
+ frappe
+ .call({
+ method: "erpnext.selling.page.point_of_sale.point_of_sale.get_customer_recent_transactions",
+ args: { customer: this.customer_info.customer },
})
.then((res) => {
+ res = res.message;
const transaction_container = this.$customer_section.find(".customer-transactions");
if (!res.length) {
@@ -1019,6 +1024,7 @@ erpnext.PointOfSale.ItemCart = class {
Draft: "red",
Return: "gray",
Consolidated: "blue",
+ "Credit Note Issued": "gray",
};
transaction_container.append(
diff --git a/erpnext/selling/page/point_of_sale/pos_item_details.js b/erpnext/selling/page/point_of_sale/pos_item_details.js
index a0476ee6bda..d1ae0a68b0f 100644
--- a/erpnext/selling/page/point_of_sale/pos_item_details.js
+++ b/erpnext/selling/page/point_of_sale/pos_item_details.js
@@ -6,6 +6,7 @@ erpnext.PointOfSale.ItemDetails = class {
this.allow_rate_change = settings.allow_rate_change;
this.allow_discount_change = settings.allow_discount_change;
this.current_item = {};
+ this.frm_doctype = settings.frm_doctype;
this.init_component();
}
@@ -323,7 +324,9 @@ erpnext.PointOfSale.ItemDetails = class {
};
}
- frappe.model.on("POS Invoice Item", "*", (fieldname, value, item_row) => {
+ const frm_doctype = this.events.get_frm().doc.doctype;
+
+ frappe.model.on(`${frm_doctype} Item`, "*", (fieldname, value, item_row) => {
const field_control = this[`${fieldname}_control`];
const item_row_is_being_edited = this.compare_with_current_item(item_row);
if (
@@ -423,7 +426,7 @@ erpnext.PointOfSale.ItemDetails = class {
warehouse: this.warehouse_control.get_value() || "",
batch_nos: this.current_item.batch_no || "",
posting_date: expiry_date,
- for_doctype: "POS Invoice",
+ for_doctype: this.frm_doctype,
},
});
diff --git a/erpnext/selling/page/point_of_sale/pos_past_order_list.js b/erpnext/selling/page/point_of_sale/pos_past_order_list.js
index dda44f25299..dfc6c2d46db 100644
--- a/erpnext/selling/page/point_of_sale/pos_past_order_list.js
+++ b/erpnext/selling/page/point_of_sale/pos_past_order_list.js
@@ -38,9 +38,10 @@ erpnext.PointOfSale.PastOrderList = class {
});
const me = this;
this.$invoices_container.on("click", ".invoice-wrapper", function () {
+ const invoice_doctype = $(this).attr("data-invoice-doctype");
const invoice_name = unescape($(this).attr("data-invoice-name"));
- me.events.open_invoice_data(invoice_name);
+ me.events.open_invoice_data(invoice_doctype, invoice_name);
});
}
@@ -99,7 +100,9 @@ erpnext.PointOfSale.PastOrderList = class {
const posting_datetime = frappe.datetime.str_to_user(
invoice.posting_date + " " + invoice.posting_time
);
- return `
+ return ` ${invoice.name}
diff --git a/erpnext/selling/page/point_of_sale/pos_past_order_summary.js b/erpnext/selling/page/point_of_sale/pos_past_order_summary.js
index 42024aba097..18d9e6aad34 100644
--- a/erpnext/selling/page/point_of_sale/pos_past_order_summary.js
+++ b/erpnext/selling/page/point_of_sale/pos_past_order_summary.js
@@ -117,9 +117,10 @@ erpnext.PointOfSale.PastOrderSummary = class {
async function get_returned_qty() {
const r = await frappe.call({
- method: "erpnext.controllers.sales_and_purchase_return.get_pos_invoice_item_returned_qty",
+ method: "erpnext.controllers.sales_and_purchase_return.get_invoice_item_returned_qty",
args: {
- pos_invoice: doc.name,
+ doctype: doc.doctype,
+ invoice: doc.name,
customer: doc.customer,
item_row_name: item_data.name,
},
@@ -192,7 +193,7 @@ erpnext.PointOfSale.PastOrderSummary = class {
bind_events() {
this.$summary_container.on("click", ".return-btn", async () => {
- const r = await this.is_pos_invoice_returnable(this.doc.name);
+ const r = await this.is_invoice_returnable(this.doc.doctype, this.doc.name);
if (!r) {
frappe.msgprint({
title: __("Invalid Return"),
@@ -201,21 +202,21 @@ erpnext.PointOfSale.PastOrderSummary = class {
});
return;
}
- this.events.process_return(this.doc.name);
+ this.events.process_return(this.doc.doctype, this.doc.name);
this.toggle_component(false);
this.$component.find(".no-summary-placeholder").css("display", "flex");
this.$summary_wrapper.css("display", "none");
});
this.$summary_container.on("click", ".edit-btn", () => {
- this.events.edit_order(this.doc.name);
+ this.events.edit_order(this.doc.doctype, this.doc.name);
this.toggle_component(false);
this.$component.find(".no-summary-placeholder").css("display", "flex");
this.$summary_wrapper.css("display", "none");
});
this.$summary_container.on("click", ".delete-btn", () => {
- this.events.delete_order(this.doc.name);
+ this.events.delete_order(this.doc.doctype, this.doc.name);
this.show_summary_placeholder();
});
@@ -461,11 +462,12 @@ erpnext.PointOfSale.PastOrderSummary = class {
show ? this.$component.css("display", "flex") : this.$component.css("display", "none");
}
- async is_pos_invoice_returnable(invoice) {
+ async is_invoice_returnable(doctype, invoice) {
const r = await frappe.call({
- method: "erpnext.controllers.sales_and_purchase_return.is_pos_invoice_returnable",
+ method: "erpnext.controllers.sales_and_purchase_return.is_invoice_returnable",
args: {
- pos_invoice: invoice,
+ doctype: doctype,
+ invoice: invoice,
},
});
return r.message;
diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js
index f56cc5d4743..56ca8b2154c 100644
--- a/erpnext/selling/page/point_of_sale/pos_payment.js
+++ b/erpnext/selling/page/point_of_sale/pos_payment.js
@@ -164,36 +164,12 @@ erpnext.PointOfSale.Payment = class {
}
});
- frappe.ui.form.on("POS Invoice", "contact_mobile", (frm) => {
- const contact = frm.doc.contact_mobile;
- const request_button = $(this.request_for_payment_field?.$input[0]);
- if (contact) {
- request_button.removeClass("btn-default").addClass("btn-primary");
- } else {
- request_button.removeClass("btn-primary").addClass("btn-default");
- }
+ frappe.ui.form.on("POS Invoice", "coupon_code", (frm) => {
+ this.bind_coupon_code_event(frm);
});
- frappe.ui.form.on("POS Invoice", "coupon_code", (frm) => {
- if (frm.doc.coupon_code && !frm.applying_pos_coupon_code) {
- if (!frm.doc.ignore_pricing_rule) {
- frm.applying_pos_coupon_code = true;
- frappe.run_serially([
- () => (frm.doc.ignore_pricing_rule = 1),
- () => frm.trigger("ignore_pricing_rule"),
- () => (frm.doc.ignore_pricing_rule = 0),
- () => frm.trigger("apply_pricing_rule"),
- () => frm.save(),
- () => this.update_totals_section(frm.doc),
- () => (frm.applying_pos_coupon_code = false),
- ]);
- } else if (frm.doc.ignore_pricing_rule) {
- frappe.show_alert({
- message: __("Ignore Pricing Rule is enabled. Cannot apply coupon code."),
- indicator: "orange",
- });
- }
- }
+ frappe.ui.form.on("Sales Invoice", "coupon_code", (frm) => {
+ this.bind_coupon_code_event(frm);
});
this.setup_listener_for_payments();
@@ -225,19 +201,19 @@ erpnext.PointOfSale.Payment = class {
});
frappe.ui.form.on("POS Invoice", "paid_amount", (frm) => {
- this.update_totals_section(frm.doc);
-
- // need to re calculate cash shortcuts after discount is applied
- const is_cash_shortcuts_invisible = !this.$payment_modes.find(".cash-shortcuts").is(":visible");
- this.attach_cash_shortcuts(frm.doc);
- !is_cash_shortcuts_invisible &&
- this.$payment_modes.find(".cash-shortcuts").css("display", "grid");
- this.render_payment_mode_dom();
+ this.bind_paid_amount_event(frm);
});
frappe.ui.form.on("POS Invoice", "loyalty_amount", (frm) => {
- const formatted_currency = format_currency(frm.doc.loyalty_amount, frm.doc.currency);
- this.$payment_modes.find(`.loyalty-amount-amount`).html(formatted_currency);
+ this.bind_loyalty_amount_event(frm);
+ });
+
+ frappe.ui.form.on("Sales Invoice", "paid_amount", (frm) => {
+ this.bind_paid_amount_event(frm);
+ });
+
+ frappe.ui.form.on("Sales Invoice", "loyalty_amount", (frm) => {
+ this.bind_loyalty_amount_event(frm);
});
frappe.ui.form.on("Sales Invoice Payment", "amount", (frm, cdt, cdn) => {
@@ -250,6 +226,43 @@ erpnext.PointOfSale.Payment = class {
});
}
+ bind_coupon_code_event(frm) {
+ if (frm.doc.coupon_code && !frm.applying_pos_coupon_code) {
+ if (!frm.doc.ignore_pricing_rule) {
+ frm.applying_pos_coupon_code = true;
+ frappe.run_serially([
+ () => (frm.doc.ignore_pricing_rule = 1),
+ () => frm.trigger("ignore_pricing_rule"),
+ () => (frm.doc.ignore_pricing_rule = 0),
+ () => frm.trigger("apply_pricing_rule"),
+ () => frm.save(),
+ () => this.update_totals_section(frm.doc),
+ () => (frm.applying_pos_coupon_code = false),
+ ]);
+ } else if (frm.doc.ignore_pricing_rule) {
+ frappe.show_alert({
+ message: __("Ignore Pricing Rule is enabled. Cannot apply coupon code."),
+ indicator: "orange",
+ });
+ }
+ }
+ }
+
+ bind_paid_amount_event(frm) {
+ this.update_totals_section(frm.doc);
+
+ // need to re calculate cash shortcuts after discount is applied
+ const is_cash_shortcuts_invisible = !this.$payment_modes.find(".cash-shortcuts").is(":visible");
+ this.attach_cash_shortcuts(frm.doc);
+ !is_cash_shortcuts_invisible && this.$payment_modes.find(".cash-shortcuts").css("display", "grid");
+ this.render_payment_mode_dom();
+ }
+
+ bind_loyalty_amount_event(frm) {
+ const formatted_currency = format_currency(frm.doc.loyalty_amount, frm.doc.currency);
+ this.$payment_modes.find(`.loyalty-amount-amount`).html(formatted_currency);
+ }
+
setup_listener_for_payments() {
frappe.realtime.on("process_phone_payment", (data) => {
const doc = this.events.get_frm().doc;
diff --git a/erpnext/selling/report/address_and_contacts/address_and_contacts.py b/erpnext/selling/report/address_and_contacts/address_and_contacts.py
index 74784f3aff9..743db85b63b 100644
--- a/erpnext/selling/report/address_and_contacts/address_and_contacts.py
+++ b/erpnext/selling/report/address_and_contacts/address_and_contacts.py
@@ -11,9 +11,9 @@ field_map = {
"name",
"address_line1",
"address_line2",
+ "pincode",
"city",
"state",
- "pincode",
"country",
"is_primary_address",
],
@@ -52,14 +52,10 @@ def get_columns(filters):
"Email Id",
"Is Primary Contact:Check",
]
- if filters.get("party_type") == "Supplier" and frappe.db.get_single_value(
- "Buying Settings", "supp_master_name"
- ) == ["Naming Series", "Auto Name"]:
- columns.insert(1, "Supplier Name:Data:150")
- if filters.get("party_type") == "Customer" and frappe.db.get_single_value(
- "Selling Settings", "cust_master_name"
- ) == ["Naming Series", "Auto Name"]:
- columns.insert(1, "Customer Name:Data:150")
+
+ if should_add_party_name(party_type):
+ columns.insert(2, f"{party_type} Name:Data:150")
+
return columns
@@ -81,6 +77,7 @@ def get_party_addresses_and_contact(party_type, party, party_group, filters):
if party:
query_filters = {"name": party}
+
if filters.get("party_type") in ["Customer", "Supplier"]:
field = filters.get("party_type").lower() + "_name"
else:
@@ -102,14 +99,18 @@ def get_party_addresses_and_contact(party_type, party, party_group, filters):
party_details = get_party_details(party_type, party_list, "Address", party_details)
party_details = get_party_details(party_type, party_list, "Contact", party_details)
+ add_party_name = should_add_party_name(party_type)
+
for party, details in party_details.items():
addresses = details.get("address", [])
contacts = details.get("contact", [])
if not any([addresses, contacts]):
result = [party]
result.append(party_groups[party])
- if filters.get("party_type") in ["Customer", "Supplier"]:
+
+ if add_party_name:
result.append(party_name_map[party])
+
result.extend(add_blank_columns_for("Contact"))
result.extend(add_blank_columns_for("Address"))
data.append(result)
@@ -121,8 +122,10 @@ def get_party_addresses_and_contact(party_type, party, party_group, filters):
for idx in range(0, max_length):
result = [party]
result.append(party_groups[party])
- if filters.get("party_type") in ["Customer", "Supplier"]:
+
+ if add_party_name:
result.append(party_name_map[party])
+
address = addresses[idx] if idx < len(addresses) else add_blank_columns_for("Address")
contact = contacts[idx] if idx < len(contacts) else add_blank_columns_for("Contact")
result.extend(address)
@@ -139,9 +142,11 @@ def get_party_details(party_type, party_list, doctype, party_details):
fields = ["`tabDynamic Link`.link_name", *field_map.get(doctype, [])]
records = frappe.get_list(doctype, filters=filters, fields=fields, as_list=True)
+
for d in records:
details = party_details.get(d[0])
details.setdefault(frappe.scrub(doctype), []).append(d[1:])
+
return party_details
@@ -160,3 +165,16 @@ def get_party_group(party_type):
}
return group[party_type]
+
+
+def should_add_party_name(party_type):
+ settings_map = {
+ "Supplier": ("Buying Settings", "supp_master_name"),
+ "Customer": ("Selling Settings", "cust_master_name"),
+ }
+
+ if party_type in settings_map:
+ doctype, fieldname = settings_map.get(party_type)
+ return frappe.db.get_single_value(doctype, fieldname) in ["Naming Series", "Auto Name"]
+
+ return False
diff --git a/erpnext/setup/doctype/employee/test_records.json b/erpnext/setup/doctype/employee/test_records.json
deleted file mode 100644
index dfd3eed6ea5..00000000000
--- a/erpnext/setup/doctype/employee/test_records.json
+++ /dev/null
@@ -1,38 +0,0 @@
-[
- {
- "company": "_Test Company",
- "date_of_birth": "1980-01-01",
- "date_of_joining": "2010-01-01",
- "department": "_Test Department - _TC",
- "doctype": "Employee",
- "first_name": "_Test Employee",
- "gender": "Female",
- "naming_series": "_T-Employee-",
- "status": "Active",
- "user_id": "test@example.com"
- },
- {
- "company": "_Test Company",
- "date_of_birth": "1980-01-01",
- "date_of_joining": "2010-01-01",
- "department": "_Test Department 1 - _TC",
- "doctype": "Employee",
- "first_name": "_Test Employee 1",
- "gender": "Male",
- "naming_series": "_T-Employee-",
- "status": "Active",
- "user_id": "test1@example.com"
- },
- {
- "company": "_Test Company",
- "date_of_birth": "1980-01-01",
- "date_of_joining": "2010-01-01",
- "department": "_Test Department 1 - _TC",
- "doctype": "Employee",
- "first_name": "_Test Employee 2",
- "gender": "Male",
- "naming_series": "_T-Employee-",
- "status": "Active",
- "user_id": "test2@example.com"
- }
-]
\ No newline at end of file
diff --git a/erpnext/setup/doctype/sales_person/test_records.json b/erpnext/setup/doctype/sales_person/test_records.json
deleted file mode 100644
index 536552a0c34..00000000000
--- a/erpnext/setup/doctype/sales_person/test_records.json
+++ /dev/null
@@ -1,23 +0,0 @@
-[
- {
- "doctype": "Sales Person",
- "employee": "_T-Employee-00001",
- "is_group": 0,
- "parent_sales_person": "Sales Team",
- "sales_person_name": "_Test Sales Person"
- },
- {
- "doctype": "Sales Person",
- "employee": "_T-Employee-00002",
- "is_group": 0,
- "parent_sales_person": "Sales Team",
- "sales_person_name": "_Test Sales Person 1"
- },
- {
- "doctype": "Sales Person",
- "employee": "_T-Employee-00003",
- "is_group": 0,
- "parent_sales_person": "Sales Team",
- "sales_person_name": "_Test Sales Person 2"
- }
-]
\ No newline at end of file
diff --git a/erpnext/setup/doctype/sales_person/test_sales_person.py b/erpnext/setup/doctype/sales_person/test_sales_person.py
deleted file mode 100644
index 650b1d27c25..00000000000
--- a/erpnext/setup/doctype/sales_person/test_sales_person.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# License: GNU General Public License v3. See license.txt
-
-EXTRA_TEST_RECORD_DEPENDENCIES = ["Employee"]
-
-import frappe
-
-IGNORE_TEST_RECORD_DEPENDENCIES = ["Item Group"]
diff --git a/erpnext/setup/setup_wizard/operations/defaults_setup.py b/erpnext/setup/setup_wizard/operations/defaults_setup.py
index b81d744f954..82698808250 100644
--- a/erpnext/setup/setup_wizard/operations/defaults_setup.py
+++ b/erpnext/setup/setup_wizard/operations/defaults_setup.py
@@ -34,6 +34,7 @@ def set_default_settings(args):
stock_settings.stock_uom = "Nos"
stock_settings.auto_indent = 1
stock_settings.auto_insert_price_list_rate_if_missing = 1
+ stock_settings.update_price_list_based_on = "Rate"
stock_settings.set_qty_in_transactions_based_on_serial_no_input = 1
stock_settings.save()
diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py
index 84cf05f128e..6bd126020aa 100644
--- a/erpnext/setup/setup_wizard/operations/install_fixtures.py
+++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py
@@ -273,8 +273,6 @@ def install(country=None):
{"doctype": "Issue Priority", "name": _("Low")},
{"doctype": "Issue Priority", "name": _("Medium")},
{"doctype": "Issue Priority", "name": _("High")},
- {"doctype": "Email Account", "email_id": "sales@example.com", "append_to": "Opportunity"},
- {"doctype": "Email Account", "email_id": "support@example.com", "append_to": "Issue"},
{"doctype": "Party Type", "party_type": "Customer", "account_type": "Receivable"},
{"doctype": "Party Type", "party_type": "Supplier", "account_type": "Payable"},
{"doctype": "Party Type", "party_type": "Employee", "account_type": "Payable"},
@@ -505,6 +503,7 @@ def update_stock_settings():
stock_settings.stock_uom = "Nos"
stock_settings.auto_indent = 1
stock_settings.auto_insert_price_list_rate_if_missing = 1
+ stock_settings.update_price_list_based_on = "Rate"
stock_settings.set_qty_in_transactions_based_on_serial_no_input = 1
stock_settings.save()
diff --git a/erpnext/setup/setup_wizard/operations/taxes_setup.py b/erpnext/setup/setup_wizard/operations/taxes_setup.py
index f8cd61d50ea..3cc405aa658 100644
--- a/erpnext/setup/setup_wizard/operations/taxes_setup.py
+++ b/erpnext/setup/setup_wizard/operations/taxes_setup.py
@@ -214,13 +214,12 @@ def get_or_create_account(company_name, account):
default_root_type = "Liability"
root_type = account.get("root_type", default_root_type)
+ or_filters = {"account_name": account.get("account_name")}
+ if account.get("account_number"):
+ or_filters.update({"account_number": account.get("account_number")})
+
existing_accounts = frappe.get_all(
- "Account",
- filters={"company": company_name, "root_type": root_type},
- or_filters={
- "account_name": account.get("account_name"),
- "account_number": account.get("account_number"),
- },
+ "Account", filters={"company": company_name, "root_type": root_type}, or_filters=or_filters
)
if existing_accounts:
diff --git a/erpnext/startup/boot.py b/erpnext/startup/boot.py
index 12de9273834..a5f469a58ec 100644
--- a/erpnext/startup/boot.py
+++ b/erpnext/startup/boot.py
@@ -3,6 +3,7 @@
import frappe
+from frappe.defaults import get_user_default
from frappe.utils import cint
import erpnext.accounts.utils
@@ -57,7 +58,9 @@ def boot_session(bootinfo):
party_account_types = frappe.db.sql(""" select name, ifnull(account_type, '') from `tabParty Type`""")
bootinfo.party_account_types = frappe._dict(party_account_types)
- fiscal_year = erpnext.accounts.utils.get_fiscal_years(frappe.utils.nowdate(), raise_on_missing=False)
+ fiscal_year = erpnext.accounts.utils.get_fiscal_years(
+ frappe.utils.nowdate(), company=get_user_default("company"), raise_on_missing=False
+ )
if fiscal_year:
bootinfo.current_fiscal_year = fiscal_year[0]
diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py
index 800d4f70c40..c7d9823d144 100644
--- a/erpnext/stock/doctype/batch/batch.py
+++ b/erpnext/stock/doctype/batch/batch.py
@@ -223,6 +223,7 @@ def get_batch_qty(
ignore_voucher_nos=None,
for_stock_levels=False,
consider_negative_batches=False,
+ do_not_check_future_batches=False,
):
"""Returns batch actual qty if warehouse is passed,
or returns dict of qty by warehouse if warehouse is None
@@ -249,6 +250,7 @@ def get_batch_qty(
"ignore_voucher_nos": ignore_voucher_nos,
"for_stock_levels": for_stock_levels,
"consider_negative_batches": consider_negative_batches,
+ "do_not_check_future_batches": do_not_check_future_batches,
}
)
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py
index 3f86fb11acf..7f12edb099c 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.py
@@ -1158,7 +1158,10 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
# Invert the address on target doc creation
update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
update_address(
- target_doc, "shipping_address", "shipping_address_display", source_doc.customer_address
+ target_doc, "dispatch_address", "dispatch_address_display", source_doc.dispatch_address_name
+ )
+ update_address(
+ target_doc, "shipping_address", "shipping_address_display", source_doc.shipping_address_name
)
update_address(
target_doc, "billing_address", "billing_address_display", source_doc.customer_address
diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js
index 675d49aab22..a764a006b0c 100644
--- a/erpnext/stock/doctype/item/item.js
+++ b/erpnext/stock/doctype/item/item.js
@@ -7,6 +7,35 @@ const SALES_DOCTYPES = ["Quotation", "Sales Order", "Delivery Note", "Sales Invo
const PURCHASE_DOCTYPES = ["Purchase Order", "Purchase Receipt", "Purchase Invoice"];
frappe.ui.form.on("Item", {
+ valuation_method(frm) {
+ if (!frm.is_new() && frm.doc.valuation_method === "Moving Average") {
+ let stock_exists = frm.doc.__onload && frm.doc.__onload.stock_exists ? 1 : 0;
+ let current_valuation_method = frm.doc.__onload.current_valuation_method;
+
+ if (stock_exists && current_valuation_method !== frm.doc.valuation_method) {
+ let msg = __(
+ "Changing the valuation method to Moving Average will affect new transactions. If backdated entries are added, earlier FIFO-based entries will be reposted, which may change closing balances."
+ );
+ msg += "
- "; + msg += __( + "Also you can't switch back to FIFO after setting the valuation method to Moving Average for this item." + ); + msg += " "; + msg += __("Do you want to change valuation method?"); + + frappe.confirm( + msg, + () => { + frm.set_value("valuation_method", "Moving Average"); + }, + () => { + frm.set_value("valuation_method", current_valuation_method); + } + ); + } + } + }, + setup: function (frm) { frm.add_fetch("attribute", "numeric_values", "numeric_values"); frm.add_fetch("attribute", "from_range", "from_range"); diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index a58c290b66c..fe40f748646 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -33,6 +33,7 @@ from erpnext.controllers.item_variant import ( validate_item_variant_attributes, ) from erpnext.stock.doctype.item_default.item_default import ItemDefault +from erpnext.stock.utils import get_valuation_method class DuplicateReorderRows(frappe.ValidationError): @@ -153,6 +154,7 @@ class Item(Document): def onload(self): self.set_onload("stock_exists", self.stock_ledger_created()) self.set_onload("asset_naming_series", get_asset_naming_series()) + self.set_onload("current_valuation_method", get_valuation_method(self.name)) def autoname(self): if frappe.db.get_default("item_naming_by") == "Naming Series": @@ -974,6 +976,11 @@ class Item(Document): changed_fields = [ field for field in restricted_fields if cstr(self.get(field)) != cstr(values.get(field)) ] + + # Allow to change valuation method from FIFO to Moving Average not vice versa + if self.valuation_method == "Moving Average" and "valuation_method" in changed_fields: + changed_fields.remove("valuation_method") + if not changed_fields: return diff --git a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py index 2ca85d90ae6..c80bcc8123b 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py @@ -7,7 +7,7 @@ from frappe import _ from frappe.model.document import Document from frappe.model.meta import get_field_precision from frappe.query_builder.custom import ConstantColumn -from frappe.utils import flt +from frappe.utils import cint, flt import erpnext from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals @@ -273,13 +273,19 @@ class LandedCostVoucher(Document): "item_code": item.item_code, "docstatus": ["!=", 2], }, - fields=["name", "docstatus"], + fields=["name", "docstatus", "asset_quantity"], ) - if not docs or len(docs) < item.qty: + + total_asset_qty = sum((cint(d.asset_quantity)) for d in docs) + + if not docs or total_asset_qty < item.qty: frappe.throw( _( - "There are only {0} asset created or linked to {1}. Please create or link {2} Assets with respective document." - ).format(len(docs), item.receipt_document, item.qty) + "For item {0}, only {1} asset have been created or linked to {2}. " + "Please create or link {3} more asset with the respective document." + ).format( + item.item_code, total_asset_qty, item.receipt_document, item.qty - total_asset_qty + ) ) if docs: for d in docs: diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json index dd095fd8df9..de4087c8610 100755 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json @@ -112,8 +112,10 @@ "contact_mobile", "contact_email", "section_break_98", - "shipping_address", + "dispatch_address", + "dispatch_address_display", "column_break_100", + "shipping_address", "shipping_address_display", "billing_address_section", "billing_address", @@ -1198,7 +1200,7 @@ { "fieldname": "section_break_98", "fieldtype": "Section Break", - "label": "Company Shipping Address" + "label": "Shipping Address" }, { "fieldname": "billing_address_section", @@ -1267,13 +1269,28 @@ "no_copy": 1, "print_hide": 1, "read_only": 1 + }, + { + "fieldname": "dispatch_address", + "fieldtype": "Link", + "label": "Dispatch Address Template", + "options": "Address", + "print_hide": 1 + }, + { + "fieldname": "dispatch_address_display", + "fieldtype": "Text Editor", + "label": "Dispatch Address", + "print_hide": 1, + "read_only": 1 } ], + "grid_page_length": 50, "icon": "fa fa-truck", "idx": 261, "is_submittable": 1, "links": [], - "modified": "2024-11-13 16:55:14.129055", + "modified": "2025-04-09 16:52:19.323878", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt", @@ -1334,6 +1351,7 @@ "write": 1 } ], + "row_format": "Dynamic", "search_fields": "status, posting_date, supplier", "show_name_in_global_search": 1, "sort_field": "creation", diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 25e31758cde..b6d87b7525c 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -13,11 +13,11 @@ from pypika import functions as fn import erpnext from erpnext.accounts.utils import get_account_currency from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled -from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.accounts_controller import merge_taxes from erpnext.controllers.buying_controller import BuyingController from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_transaction +from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -70,6 +70,8 @@ class PurchaseReceipt(BuyingController): currency: DF.Link disable_rounded_total: DF.Check discount_amount: DF.Currency + dispatch_address: DF.Link | None + dispatch_address_display: DF.TextEditor | None grand_total: DF.Currency group_same_items: DF.Check ignore_pricing_rule: DF.Check @@ -382,7 +384,7 @@ class PurchaseReceipt(BuyingController): self.make_gl_entries() self.repost_future_sle_and_gle() self.set_consumed_qty_in_subcontract_order() - self.reserve_stock_for_sales_order() + self.reserve_stock() self.update_received_qty_if_from_pp() def update_received_qty_if_from_pp(self): @@ -912,6 +914,10 @@ class PurchaseReceipt(BuyingController): pr_doc = self if (pr == self.name) else frappe.get_doc("Purchase Receipt", pr) update_billing_percentage(pr_doc, update_modified=update_modified) + def reserve_stock(self): + self.reserve_stock_for_sales_order() + self.reserve_stock_for_production_plan() + def reserve_stock_for_sales_order(self): if ( self.is_return @@ -952,6 +958,66 @@ class PurchaseReceipt(BuyingController): notify=True, ) + def reserve_stock_for_production_plan(self): + if self.is_return or not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"): + return + + production_plan_references = self.get_production_plan_references() + production_plan_items = [] + + docnames = [] + for row in self.items: + if row.material_request_item and row.material_request_item in production_plan_references: + _ref = production_plan_references[row.material_request_item] + docnames.append(_ref.production_plan) + row.update( + { + "voucher_type": "Production Plan", + "voucher_no": _ref.production_plan, + "voucher_detail_no": _ref.material_request_plan_item, + "from_voucher_no": self.name, + "from_voucher_detail_no": row.name, + "from_voucher_type": self.doctype, + } + ) + + production_plan_items.append(row) + + if not production_plan_items: + return + + sre = StockReservation(doc=self, items=production_plan_items) + sre.make_stock_reservation_entries() + if docnames: + sre.transfer_reservation_entries_to( + docnames, from_doctype="Production Plan", to_doctype="Work Order" + ) + + def get_production_plan_references(self): + production_plan_references = frappe._dict() + material_request_items = [] + + for row in self.items: + if row.material_request_item: + material_request_items.append(row.material_request_item) + + if not material_request_items: + return frappe._dict() + + items = frappe.get_all( + "Material Request Item", + fields=["material_request_plan_item", "production_plan", "name"], + filters={"name": ["in", material_request_items], "docstatus": 1}, + ) + + for item in items: + if not item.production_plan: + continue + + production_plan_references.setdefault(item.name, item) + + return production_plan_references + def enable_recalculate_rate_in_sles(self): rejected_warehouses = frappe.get_all( "Purchase Receipt Item", filters={"parent": self.name}, pluck="rejected_warehouse" @@ -1104,6 +1170,7 @@ def get_billed_amount_against_po(po_items): def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate=False): # Update Billing % based on pending accepted qty buying_settings = frappe.get_single("Buying Settings") + over_billing_allowance = frappe.db.get_single_value("Accounts Settings", "over_billing_allowance") total_amount, total_billed_amount = 0, 0 item_wise_returned_qty = get_item_wise_returned_qty(pr_doc) @@ -1142,6 +1209,14 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate adjusted_amt = flt(adjusted_amt * flt(pr_doc.conversion_rate), item.precision("amount")) item.db_set("amount_difference_with_purchase_invoice", adjusted_amt, update_modified=False) + elif item.billed_amt > item.amount: + per_over_billed = (flt(item.billed_amt / item.amount, 2) * 100) - 100 + if per_over_billed > over_billing_allowance: + frappe.throw( + _("Over Billing Allowance exceeded for Purchase Receipt Item {0} ({1}) by {2}%").format( + item.name, frappe.bold(item.item_code), per_over_billed - over_billing_allowance + ) + ) percent_billed = round(100 * (total_billed_amount / (total_amount or 1)), 6) pr_doc.db_set("per_billed", percent_billed) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 61d52b1ef36..5e0663ea89d 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -7,6 +7,8 @@ from frappe.utils import add_days, cint, cstr, flt, get_datetime, getdate, nowti from pypika import functions as fn import erpnext +import erpnext.controllers +import erpnext.controllers.status_updater from erpnext.accounts.doctype.account.test_account import get_inventory_account from erpnext.buying.doctype.supplier.test_supplier import create_supplier from erpnext.controllers.accounts_controller import InvalidQtyError @@ -4178,6 +4180,59 @@ class TestPurchaseReceipt(IntegrationTestCase): self.assertTrue(sles) + def test_internal_pr_qty_change_only_single_batch(self): + from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + + prepare_data_for_internal_transfer() + + def get_sabb_qty(sabb): + return frappe.get_value("Serial and Batch Bundle", sabb, "total_qty") + + item = make_item("Item with only Batch", {"has_batch_no": 1}) + item.create_new_batch = 1 + item.save() + + make_purchase_receipt( + item_code=item.item_code, + qty=10, + rate=100, + company="_Test Company with perpetual inventory", + warehouse="Stores - TCP1", + ) + + dn = create_delivery_note( + item_code=item.item_code, + qty=10, + rate=100, + company="_Test Company with perpetual inventory", + customer="_Test Internal Customer 2", + cost_center="Main - TCP1", + warehouse="Stores - TCP1", + target_warehouse="Work In Progress - TCP1", + ) + pr = make_inter_company_purchase_receipt(dn.name) + + pr.items[0].warehouse = "Stores - TCP1" + pr.items[0].qty = 8 + pr.save() + + # Test 1 - Check if SABB qty is changed on first save + self.assertEqual(abs(get_sabb_qty(pr.items[0].serial_and_batch_bundle)), 8) + + pr.items[0].qty = 6 + pr.items[0].received_qty = 6 + pr.save() + + # Test 2 - Check if SABB qty is changed when saved again + self.assertEqual(abs(get_sabb_qty(pr.items[0].serial_and_batch_bundle)), 6) + + pr.items[0].qty = 12 + pr.items[0].received_qty = 12 + + # Test 3 - OverAllowanceError should be thrown as qty is greater than qty in DN + self.assertRaises(erpnext.controllers.status_updater.OverAllowanceError, pr.submit) + def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index 8aed2277de3..021b7b1cf17 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -203,10 +203,11 @@ class QualityInspection(Document): self.get_item_specification_details() def on_update(self): - if ( - frappe.db.get_single_value("Stock Settings", "action_if_quality_inspection_is_not_submitted") - == "Warn" - ): + action_if_qi_in_draft = frappe.db.get_single_value( + "Stock Settings", "action_if_quality_inspection_is_not_submitted" + ) + + if not action_if_qi_in_draft or action_if_qi_in_draft == "Warn": self.update_qc_reference() def on_submit(self): 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 20c4b277a67..11f9ee932ee 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 @@ -231,6 +231,9 @@ class SerialandBatchBundle(Document): "warehouse": self.warehouse, "check_serial_nos": True, "serial_nos": serial_nos, + "sabb_voucher_type": self.voucher_type, + "sabb_voucher_no": self.voucher_no, + "sabb_voucher_detail_no": self.voucher_detail_no, } if self.voucher_type == "POS Invoice": kwargs["ignore_voucher_nos"] = [self.voucher_no] @@ -1874,9 +1877,16 @@ def get_reserved_serial_nos(kwargs) -> list: ignore_serial_nos.extend(get_reserved_serial_nos_for_pos(kwargs)) reserved_entries = get_reserved_serial_nos_for_sre(kwargs) + if not reserved_entries: + return ignore_serial_nos + + reserved_voucher_details = get_reserved_voucher_details(kwargs) serial_nos = [] for entry in reserved_entries: + if entry.voucher_no in reserved_voucher_details: + continue + if kwargs.get("serial_nos") and entry.serial_no in kwargs.get("serial_nos"): frappe.throw( _( @@ -1893,6 +1903,29 @@ def get_reserved_serial_nos(kwargs) -> list: return ignore_serial_nos +def get_reserved_voucher_details(kwargs): + reserved_voucher_details = [] + + value = { + "Delivery Note": ["Delivery Note Item", "against_sales_order"], + }.get(kwargs.get("voucher_type")) + + if not value or not kwargs.get("sabb_voucher_no"): + return reserved_voucher_details + + reserved_voucher_details = frappe.get_all( + value[0], + pluck=value[1], + filters={ + "name": kwargs.get("sabb_voucher_detail_no"), + "parent": kwargs.get("sabb_voucher_no"), + "docstatus": 1, + }, + ) + + return reserved_voucher_details + + def get_reserved_serial_nos_for_pos(kwargs): from erpnext.controllers.sales_and_purchase_return import get_returned_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos diff --git a/erpnext/stock/doctype/shipment/shipment.js b/erpnext/stock/doctype/shipment/shipment.js index 8843d383531..7f1e1c8d729 100644 --- a/erpnext/stock/doctype/shipment/shipment.js +++ b/erpnext/stock/doctype/shipment/shipment.js @@ -162,7 +162,7 @@ frappe.ui.form.on("Shipment", { args: { contact: contact_name }, callback: function (r) { if (r.message) { - if (!(r.message.contact_email && (r.message.contact_phone || r.message.contact_mobile))) { + if (!(r.message.contact_email || r.message.contact_phone || r.message.contact_mobile)) { if (contact_type == "Delivery") { frm.set_value("delivery_contact_name", ""); frm.set_value("delivery_contact", ""); diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index 0c619b22a33..63597dd3e72 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -462,6 +462,7 @@ frappe.ui.form.on("Stock Entry", { docstatus: 1, purpose: "Material Transfer", add_to_transit: 1, + per_transferred: ["<", 100], }, }); }, @@ -950,6 +951,15 @@ frappe.ui.form.on("Stock Entry Detail", { }, batch_no(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + + if (row.batch_no) { + frappe.model.set_value(cdt, cdn, { + use_serial_batch_fields: 1, + serial_and_batch_bundle: "", + }); + } + validate_sample_quantity(frm, cdt, cdn); }, @@ -1074,6 +1084,13 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle serial_no(doc, cdt, cdn) { var item = frappe.get_doc(cdt, cdn); + if (item.serial_no) { + frappe.model.set_value(cdt, cdn, { + use_serial_batch_fields: 1, + serial_and_batch_bundle: "", + }); + } + if (item?.serial_no) { // Replace all occurences of comma with line feed item.serial_no = item.serial_no.replace(/,/g, "\n"); diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 081b988e78e..b8f23be201e 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -6,7 +6,7 @@ import json from collections import defaultdict import frappe -from frappe import _ +from frappe import _, bold from frappe.model.mapper import get_mapped_doc from frappe.query_builder.functions import Sum from frappe.utils import ( @@ -522,17 +522,37 @@ class StockEntry(StockController): ).format(frappe.bold(self.company)) ) - elif ( - self.is_opening == "Yes" - and frappe.db.get_value("Account", d.expense_account, "report_type") == "Profit and Loss" - ): + acc_details = frappe.get_cached_value( + "Account", + d.expense_account, + ["account_type", "report_type"], + as_dict=True, + ) + + if self.is_opening == "Yes" and acc_details.report_type == "Profit and Loss": frappe.throw( _( - "Difference Account must be a Asset/Liability type account, since this Stock Entry is an Opening Entry" + "Difference Account must be a Asset/Liability type account (Temporary Opening), since this Stock Entry is an Opening Entry" ), OpeningEntryAccountError, ) + if acc_details.account_type == "Stock": + frappe.throw( + _( + "At row {0}: the Difference Account must not be a Stock type account, please change the Account Type for the account {1} or select a different account" + ).format(d.idx, get_link_to_form("Account", d.expense_account)), + OpeningEntryAccountError, + ) + + if self.purpose != "Material Issue" and acc_details.account_type == "Cost of Goods Sold": + frappe.msgprint( + _( + "At row {0}: You have selected the Difference Account {1}, which is a Cost of Goods Sold type account. Please select a different account" + ).format(d.idx, bold(get_link_to_form("Account", d.expense_account))), + title=_("Warning : Cost of Goods Sold Account"), + ) + def validate_warehouse(self): """perform various (sometimes conditional) validations on warehouse""" @@ -1626,7 +1646,11 @@ class StockEntry(StockController): def make_stock_reserve_for_wip_and_fg(self): if self.is_stock_reserve_for_work_order(): pro_doc = frappe.get_doc("Work Order", self.work_order) - if self.purpose == "Manufacture" and not pro_doc.sales_order: + if ( + self.purpose == "Manufacture" + and not pro_doc.sales_order + and not pro_doc.production_plan_sub_assembly_item + ): return pro_doc.set_reserved_qty_for_wip_and_fg(self) @@ -1634,7 +1658,11 @@ class StockEntry(StockController): def cancel_stock_reserve_for_wip_and_fg(self): if self.is_stock_reserve_for_work_order(): pro_doc = frappe.get_doc("Work Order", self.work_order) - if self.purpose == "Manufacture" and not pro_doc.sales_order: + if ( + self.purpose == "Manufacture" + and not pro_doc.sales_order + and not pro_doc.production_plan_sub_assembly_item + ): return pro_doc.cancel_reserved_qty_for_wip_and_fg(self) diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index 3554f6401e7..2dd1a98e148 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -1314,7 +1314,7 @@ class TestStockLedgerEntry(IntegrationTestCase, StockTestMixin): # To deliver 100 qty we fall short of 11.0073 qty (11.007 with precision 3) # Stock up with 11.007 (balance in db becomes 99.9997, on UI it will show as 100) make_stock_entry(item_code=item_code, target=warehouse, qty=11.007, rate=100) - self.assertEqual(get_stock_balance(item_code, warehouse), 99.9997) + self.assertEqual(get_stock_balance(item_code, warehouse), 100.0) # See if delivery note goes through # Negative qty error should not be raised as 99.9997 is 100 with precision 3 (system precision) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js index 9307eee46f1..44dd2952409 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js @@ -289,8 +289,16 @@ frappe.ui.form.on("Stock Reconciliation Item", { frm.events.set_valuation_rate_and_qty(frm, cdt, cdn); }, - batch_no: function (frm, cdt, cdn) { - frm.events.set_valuation_rate_and_qty(frm, cdt, cdn); + batch_no(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + if (row.batch_no) { + frappe.model.set_value(cdt, cdn, { + use_serial_batch_fields: 1, + serial_and_batch_bundle: "", + }); + + frm.events.set_valuation_rate_and_qty(frm, cdt, cdn); + } }, qty: function (frm, cdt, cdn) { @@ -310,6 +318,11 @@ frappe.ui.form.on("Stock Reconciliation Item", { var child = locals[cdt][cdn]; if (child.serial_no) { + frappe.model.set_value(cdt, cdn, { + use_serial_batch_fields: 1, + serial_and_batch_bundle: "", + }); + const serial_nos = child.serial_no.trim().split("\n"); frappe.model.set_value(cdt, cdn, "qty", serial_nos.length); } diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index c3d0480e820..4ea683a904d 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -5,11 +5,11 @@ import frappe from frappe import _, bold, json, msgprint from frappe.query_builder.functions import CombineDatetime, Sum -from frappe.utils import add_to_date, cint, cstr, flt +from frappe.utils import add_to_date, cint, cstr, flt, get_datetime import erpnext from erpnext.accounts.utils import get_company_default -from erpnext.controllers.stock_controller import StockController +from erpnext.controllers.stock_controller import StockController, create_repost_item_valuation_entry from erpnext.stock.doctype.batch.batch import get_available_batches, get_batch_qty from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( @@ -186,9 +186,45 @@ class StockReconciliation(StockController): if not item.reconcile_all_serial_batch and item.serial_and_batch_bundle: bundle = self.get_bundle_for_specific_serial_batch(item) + if not bundle: + continue + item.current_serial_and_batch_bundle = bundle.name item.current_valuation_rate = abs(bundle.avg_rate) + if bundle.total_qty: + item.current_qty = abs(bundle.total_qty) + + if save: + if not item.current_qty: + frappe.throw( + _("Row # {0}: Please enter quantity for Item {1} as it is not zero.").format( + item.idx, item.item_code + ) + ) + + if ( + self.docstatus == 1 + and item.current_serial_and_batch_bundle + and frappe.db.get_value( + "Serial and Batch Bundle", item.current_serial_and_batch_bundle, "docstatus" + ) + == 0 + ): + sabb_doc = frappe.get_doc( + "Serial and Batch Bundle", item.current_serial_and_batch_bundle + ) + sabb_doc.voucher_no = self.name + sabb_doc.submit() + + item.db_set( + { + "current_serial_and_batch_bundle": item.current_serial_and_batch_bundle, + "current_qty": item.current_qty, + "current_valuation_rate": item.current_valuation_rate, + } + ) + if not item.valuation_rate: item.valuation_rate = item.current_valuation_rate continue @@ -333,20 +369,26 @@ class StockReconciliation(StockController): entry.batch_no, row.warehouse, row.item_code, + ignore_voucher_nos=[self.name], posting_date=self.posting_date, posting_time=self.posting_time, for_stock_levels=True, consider_negative_batches=True, + do_not_check_future_batches=True, ) + if not current_qty: + continue + total_current_qty += current_qty entry.qty = current_qty * -1 - reco_obj.save() + if total_current_qty: + reco_obj.save() - row.current_qty = total_current_qty + row.current_qty = total_current_qty - return reco_obj + return reco_obj def has_change_in_serial_batch(self, row) -> bool: bundles = {row.serial_and_batch_bundle: [], row.current_serial_and_batch_bundle: []} @@ -726,6 +768,12 @@ class StockReconciliation(StockController): ) self.make_sl_entries(sl_entries, allow_negative_stock=allow_negative_stock) + elif self.docstatus == 1: + frappe.throw( + _( + "No stock ledger entries were created. Please set the quantity or valuation rate for the items properly and try again." + ) + ) def make_adjustment_entry(self, row, sl_entries): from erpnext.stock.stock_ledger import get_stock_value_difference @@ -962,7 +1010,7 @@ class StockReconciliation(StockController): else: self._cancel() - def recalculate_current_qty(self, voucher_detail_no): + def recalculate_current_qty(self, voucher_detail_no, sle_creation, add_new_sle=False): from erpnext.stock.stock_ledger import get_valuation_rate for row in self.items: @@ -1030,6 +1078,49 @@ class StockReconciliation(StockController): } ) + if ( + add_new_sle + and not frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_detail_no": row.name, "actual_qty": ("<", 0), "is_cancelled": 0}, + "name", + ) + and not row.current_serial_and_batch_bundle + ): + self.set_current_serial_and_batch_bundle(voucher_detail_no, save=True) + row.reload() + + self.add_missing_stock_ledger_entry(row, voucher_detail_no, sle_creation) + + def add_missing_stock_ledger_entry(self, row, voucher_detail_no, sle_creation): + if row.current_qty == 0: + return + + new_sle = frappe.get_doc(self.get_sle_for_items(row)) + new_sle.actual_qty = row.current_qty * -1 + new_sle.valuation_rate = row.current_valuation_rate + new_sle.serial_and_batch_bundle = row.current_serial_and_batch_bundle + new_sle.submit() + + creation = add_to_date(sle_creation, seconds=-1) + new_sle.db_set("creation", creation) + + if not frappe.db.exists( + "Repost Item Valuation", + {"item": row.item_code, "warehouse": row.warehouse, "docstatus": 1, "status": "Queued"}, + ): + create_repost_item_valuation_entry( + { + "based_on": "Item and Warehouse", + "item_code": row.item_code, + "warehouse": row.warehouse, + "company": self.company, + "allow_negative_stock": 1, + "posting_date": self.posting_date, + "posting_time": self.posting_time, + } + ) + def has_negative_stock_allowed(self): allow_negative_stock = cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock")) if allow_negative_stock: @@ -1103,6 +1194,7 @@ class StockReconciliation(StockController): ignore_voucher_nos=[doc.voucher_no], for_stock_levels=True, consider_negative_batches=True, + do_not_check_future_batches=True, ) or 0 ) * -1 diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 58606154291..663e9cad955 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -1069,7 +1069,7 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin): sr.reload() self.assertTrue(sr.items[0].serial_and_batch_bundle) - self.assertFalse(sr.items[0].current_serial_and_batch_bundle) + self.assertTrue(sr.items[0].current_serial_and_batch_bundle) def test_not_reconcile_all_batch(self): from erpnext.stock.doctype.batch.batch import get_batch_qty @@ -1446,6 +1446,74 @@ class TestStockReconciliation(IntegrationTestCase, StockTestMixin): self.assertEqual(sr.difference_amount, 100 * -1) self.assertTrue(sr.items[0].qty == 0) + def test_stock_reco_recalculate_qty_for_backdated_entry(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item_code = self.make_item( + "Test Batch Item Stock Reco Recalculate Qty", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TEST-BATCH-RRQ-.###", + }, + ).name + + warehouse = "_Test Warehouse - _TC" + + sr = create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=10, + rate=100, + use_serial_batch_fields=1, + ) + + sr.reload() + self.assertEqual(sr.items[0].current_qty, 0) + self.assertEqual(sr.items[0].current_valuation_rate, 0) + + batch_no = get_batch_from_bundle(sr.items[0].serial_and_batch_bundle) + stock_ledgers = frappe.get_all( + "Stock Ledger Entry", + filters={"voucher_no": sr.name, "is_cancelled": 0}, + pluck="name", + ) + + self.assertTrue(len(stock_ledgers) == 1) + + make_stock_entry( + item_code=item_code, + target=warehouse, + qty=10, + basic_rate=100, + use_serial_batch_fields=1, + batch_no=batch_no, + ) + + # Make backdated stock reconciliation entry + create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=10, + rate=100, + use_serial_batch_fields=1, + batch_no=batch_no, + posting_date=add_days(nowdate(), -1), + ) + + stock_ledgers = frappe.get_all( + "Stock Ledger Entry", + filters={"voucher_no": sr.name, "is_cancelled": 0}, + pluck="name", + ) + + sr.reload() + self.assertEqual(sr.items[0].current_qty, 10) + self.assertEqual(sr.items[0].current_valuation_rate, 100) + + self.assertTrue(len(stock_ledgers) == 2) + def create_batch_item_with_batch(item_name, batch_id): batch_item_doc = create_item(item_name, is_stock_item=1) diff --git a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json index 64108b44fc8..083a9340c09 100644 --- a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json +++ b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json @@ -14,6 +14,7 @@ "column_break_6", "warehouse", "qty", + "stock_uom", "valuation_rate", "amount", "allow_zero_valuation_rate", @@ -86,6 +87,16 @@ "in_list_view": 1, "label": "Quantity" }, + { + "columns": 2, + "fetch_from": "item_code.stock_uom", + "fieldname": "stock_uom", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Stock UOM", + "options": "UOM", + "read_only": 1 + }, { "columns": 2, "fieldname": "valuation_rate", @@ -257,7 +268,7 @@ "grid_page_length": 50, "istable": 1, "links": [], - "modified": "2025-03-07 10:26:25.856337", + "modified": "2025-04-28 22:40:30.086415", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reconciliation Item", @@ -269,4 +280,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.py b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.py index f2a9aeba8f4..aa0d8ee1b26 100644 --- a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.py +++ b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.py @@ -36,6 +36,7 @@ class StockReconciliationItem(Document): reconcile_all_serial_batch: DF.Check serial_and_batch_bundle: DF.Link | None serial_no: DF.LongText | None + stock_uom: DF.Link | None use_serial_batch_fields: DF.Check valuation_rate: DF.Currency warehouse: DF.Link diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json index daae6218fed..79837e5513f 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -84,7 +84,7 @@ "no_copy": 1, "oldfieldname": "voucher_type", "oldfieldtype": "Data", - "options": "\nSales Order\nWork Order", + "options": "\nSales Order\nWork Order\nProduction Plan", "print_width": "150px", "read_only": 1, "width": "150px" @@ -289,7 +289,7 @@ "fieldtype": "Select", "label": "From Voucher Type", "no_copy": 1, - "options": "\nPick List\nPurchase Receipt\nStock Entry\nWork Order", + "options": "\nPick List\nPurchase Receipt\nStock Entry\nWork Order\nProduction Plan", "print_hide": 1, "read_only": 1, "report_hide": 1 @@ -339,12 +339,13 @@ "label": "Qty in WIP Warehouse" } ], + "grid_page_length": 50, "hide_toolbar": 1, "in_create": 1, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2024-09-19 15:28:24.726283", + "modified": "2025-04-30 22:15:22.998138", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reservation Entry", @@ -450,8 +451,9 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py index 56e48b84593..b9a5593afc1 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -30,7 +30,9 @@ class StockReservationEntry(Document): delivered_qty: DF.Float from_voucher_detail_no: DF.Data | None from_voucher_no: DF.DynamicLink | None - from_voucher_type: DF.Literal["", "Pick List", "Purchase Receipt", "Stock Entry", "Work Order"] + from_voucher_type: DF.Literal[ + "", "Pick List", "Purchase Receipt", "Stock Entry", "Work Order", "Production Plan" + ] has_batch_no: DF.Check has_serial_no: DF.Check item_code: DF.Link | None @@ -46,7 +48,7 @@ class StockReservationEntry(Document): voucher_detail_no: DF.Data | None voucher_no: DF.DynamicLink | None voucher_qty: DF.Float - voucher_type: DF.Literal["", "Sales Order", "Work Order"] + voucher_type: DF.Literal["", "Sales Order", "Work Order", "Production Plan"] warehouse: DF.Link | None # end: auto-generated types @@ -335,6 +337,7 @@ class StockReservationEntry(Document): item_doctype = { "Sales Order": "Sales Order Item", "Work Order": "Work Order Item", + "Production Plan": "Production Plan Sub Assembly Item", }.get(self.voucher_type, None) if item_doctype: @@ -350,6 +353,11 @@ class StockReservationEntry(Document): ) ).run(as_list=True)[0][0] or 0 + if self.voucher_type == "Production Plan" and frappe.db.exists( + "Material Request Plan Item", self.voucher_detail_no + ): + item_doctype = "Material Request Plan Item" + frappe.db.set_value( item_doctype, self.voucher_detail_no, @@ -968,13 +976,14 @@ def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: st class StockReservation: - def __init__(self, doc, items=None, notify=True): + def __init__(self, doc, items=None, kwargs=None, notify=True): if isinstance(doc, str): doc = parse_json(doc) doc = frappe.get_doc("Work Order", doc.get("name")) self.doc = doc self.items = items + self.kwargs = kwargs self.initialize_fields() def initialize_fields(self) -> None: @@ -989,6 +998,9 @@ class StockReservation: self.warehouse_field = "source_warehouse" if self.doc.skip_transfer and self.doc.from_wip_warehouse: self.warehouse = self.doc.wip_warehouse + elif self.doc.doctype == "Production Plan" and self.kwargs: + for key, value in self.kwargs.items(): + setattr(self, key, value) def cancel_stock_reservation_entries(self, names=None) -> None: """Cancels Stock Reservation Entries for the Voucher.""" @@ -1037,11 +1049,16 @@ class StockReservation: if isinstance(item, dict): item = frappe._dict(item) + item_code = item.get("item_code") or item.get("production_item") + item_details = frappe.get_cached_value( - "Item", item.item_code, ["has_serial_no", "has_batch_no", "stock_uom"], as_dict=True + "Item", item_code, ["has_serial_no", "has_batch_no", "stock_uom"], as_dict=True ) warehouse = self.warehouse or item.get(self.warehouse_field) or item.get("warehouse") + if self.doc.doctype == "Production Plan" and item.get("from_warehouse"): + warehouse = item.get("from_warehouse") + if ( not warehouse and self.doc.doctype == "Work Order" @@ -1052,10 +1069,12 @@ class StockReservation: ) qty = item.get(self.qty_field) or item.get("stock_qty") + if not qty: + continue - self.available_qty_to_reserve = self.get_available_qty_to_reserve(item.item_code, warehouse) + self.available_qty_to_reserve = self.get_available_qty_to_reserve(item_code, warehouse) if not self.available_qty_to_reserve: - self.throw_stock_not_exists_error(item, warehouse) + self.throw_stock_not_exists_error(item.idx, item_code, warehouse) self.qty_to_be_reserved = ( qty if self.available_qty_to_reserve >= qty else self.available_qty_to_reserve @@ -1064,7 +1083,7 @@ class StockReservation: if not self.qty_to_be_reserved: continue - sre.item_code = item.item_code + sre.item_code = item_code sre.warehouse = warehouse sre.has_serial_no = item_details.has_serial_no sre.has_batch_no = item_details.has_batch_no @@ -1093,6 +1112,7 @@ class StockReservation: "Serial and Batch Entry", fields=["serial_no", "batch_no", "qty"], filters={"parent": ("in", serial_batch_bundles)}, + order_by="creation", ) for detail in bundle_details: @@ -1106,10 +1126,10 @@ class StockReservation: }, ) - def throw_stock_not_exists_error(self, item, warehouse): + def throw_stock_not_exists_error(self, idx, item_code, warehouse): frappe.msgprint( _("Row #{0}: Stock not available to reserve for the Item {1} in Warehouse {2}.").format( - item.idx, frappe.bold(item.item_code), frappe.bold(warehouse) + idx, frappe.bold(item_code), frappe.bold(warehouse) ), title=_("Stock Reservation"), indicator="orange", @@ -1142,6 +1162,163 @@ class StockReservation: return available_qty + def transfer_reservation_entries_to(self, docnames, from_doctype, to_doctype): + delivery_qty_to_update = frappe._dict() + if isinstance(docnames, str): + docnames = [docnames] + + items_to_reserve = self.get_items_to_reserve(docnames, from_doctype, to_doctype) + if not items_to_reserve: + return + + reservation_entries = self.get_reserved_entries(from_doctype, docnames) + if not reservation_entries: + return + + entries_to_reserve = [] + for row in reservation_entries: + for entry in items_to_reserve: + if not ( + row.item_code == entry.item_code and row.warehouse == entry.warehouse and entry.qty > 0 + ): + continue + + available_qty = row.reserved_qty - row.delivered_qty + if available_qty <= 0: + continue + + # transfer qty + if available_qty > entry.qty: + qty_to_reserve = entry.qty + row.delivered_qty += available_qty - entry.qty + delivery_qty_to_update.setdefault(row.name, row.delivered_qty) + else: + qty_to_reserve = available_qty + row.delivered_qty += qty_to_reserve + delivery_qty_to_update.setdefault(row.name, row.delivered_qty) + + entries_to_reserve.append([entry, row, qty_to_reserve]) + + entry.qty -= qty_to_reserve + + if delivery_qty_to_update: + self.update_delivered_qty(delivery_qty_to_update) + + for entry, row, qty_to_reserve in entries_to_reserve: + self.make_stock_reservation_entry(entry, row, qty_to_reserve) + + def update_delivered_qty(self, delivery_qty_to_update): + for name, delivered_qty in delivery_qty_to_update.items(): + doctype = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.update(doctype) + .set(doctype.delivered_qty, delivered_qty) + .set( + doctype.status, + "Delivered" if doctype.reserved_qty == doctype.delivered_qty else "Reserved", + ) + .where(doctype.name == name) + ) + + query.run() + + def make_stock_reservation_entry(self, row, against_row, reserved_qty): + fields = [ + "item_code", + "warehouse", + "voucher_type", + "voucher_no", + "voucher_detail_no", + "company", + "stock_uom", + ] + + sre = frappe.new_doc("Stock Reservation Entry") + for row_field in fields: + sre.set(row_field, row.get(row_field)) + + sre.available_qty = reserved_qty + sre.reserved_qty = reserved_qty + sre.voucher_qty = row.required_qty + sre.from_voucher_no = against_row.voucher_no + sre.from_voucher_detail_no = against_row.voucher_detail_no + sre.from_voucher_type = against_row.voucher_type + + bundles = [against_row.name] + if row.serial_and_batch_bundles: + bundles = row.serial_and_batch_bundles + + self.set_serial_batch(sre, bundles) + + sre.save() + sre.submit() + + def get_reserved_entries(self, doctype, docnames): + filters = { + "docstatus": 1, + "status": ("not in", ["Delivered", "Cancelled", "Draft"]), + "voucher_type": doctype, + "voucher_no": docnames, + } + + if isinstance(docnames, list): + filters["voucher_no"] = ("in", docnames) + + return frappe.get_all("Stock Reservation Entry", fields=["*"], filters=filters) + + def get_items_to_reserve(self, docnames, from_doctype, to_doctype): + field = frappe.scrub(from_doctype) + + doctype = frappe.qb.DocType(to_doctype) + child_doctype = frappe.qb.DocType(to_doctype + " Item") + + query = ( + frappe.qb.from_(doctype) + .inner_join(child_doctype) + .on(doctype.name == child_doctype.parent) + .select( + doctype.name.as_("voucher_no"), + child_doctype.name.as_("voucher_detail_no"), + child_doctype.item_code, + doctype.company, + child_doctype.stock_uom, + ) + .where((doctype.docstatus == 1) & (doctype[field].isin(docnames))) + ) + + if to_doctype == "Work Order": + query = query.select( + child_doctype.source_warehouse, + doctype.wip_warehouse, + doctype.skip_transfer, + doctype.from_wip_warehouse, + child_doctype.required_qty, + (child_doctype.required_qty - child_doctype.transferred_qty).as_("qty"), + child_doctype.stock_reserved_qty, + ) + + query = query.where( + (doctype.qty > doctype.material_transferred_for_manufacturing) + & (doctype.status != "Completed") + ) + + data = query.run(as_dict=True) + items = [] + + for row in data: + if row.qty > row.stock_reserved_qty: + row.qty -= flt(row.stock_reserved_qty) + row.warehouse = row.source_warehouse + if row.skip_transfer and row.from_wip_warehouse: + row.warehouse = row.wip_warehouse + + if to_doctype == "Work Order": + row.voucher_type = "Work Order" + + items.append(row) + + return items + def create_stock_reservation_entries_for_so_items( sales_order: object, diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.js b/erpnext/stock/doctype/stock_settings/stock_settings.js index 79638590f9b..76651bf69fe 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.js +++ b/erpnext/stock/doctype/stock_settings/stock_settings.js @@ -51,4 +51,30 @@ frappe.ui.form.on("Stock Settings", { } ); }, + auto_insert_price_list_rate_if_missing(frm) { + if (!frm.doc.auto_insert_price_list_rate_if_missing) return; + + frm.set_value( + "update_price_list_based_on", + cint(frappe.defaults.get_default("editable_price_list_rate")) ? "Price List Rate" : "Rate" + ); + }, + update_price_list_based_on(frm) { + if ( + frm.doc.update_price_list_based_on === "Price List Rate" && + !cint(frappe.defaults.get_default("editable_price_list_rate")) + ) { + const dialog = frappe.warn( + __("Incompatible Setting Detected"), + __( + " Price List Rate has not been set as editable in Selling Settings. In this scenario, setting Update Price List Based On to Price List Rate will prevent auto-updation of Item Price. Are you sure you want to continue?" + ) + ); + dialog.set_secondary_action(() => { + frm.set_value("update_price_list_based_on", "Rate"); + dialog.hide(); + }); + return; + } + }, }); diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 0d821856088..e6e52c395ea 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -16,6 +16,7 @@ "stock_uom", "price_list_defaults_section", "auto_insert_price_list_rate_if_missing", + "update_price_list_based_on", "column_break_12", "update_existing_price_list_rate", "conversion_factor_section", @@ -528,6 +529,15 @@ "fieldname": "allow_to_make_quality_inspection_after_purchase_or_delivery", "fieldtype": "Check", "label": "Allow to Make Quality Inspection after Purchase / Delivery" + }, + { + "default": "Rate", + "depends_on": "eval: doc.auto_insert_price_list_rate_if_missing", + "fieldname": "update_price_list_based_on", + "fieldtype": "Select", + "label": "Update Price List Based On", + "mandatory_depends_on": "eval: doc.auto_insert_price_list_rate_if_missing", + "options": "Rate\nPrice List Rate" } ], "icon": "icon-cog", @@ -535,7 +545,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-04-11 18:56:35.781929", + "modified": "2025-05-06 02:39:24.284587", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index 14379a906f1..afb28e4861f 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -65,6 +65,7 @@ class StockSettings(Document): stock_frozen_upto_days: DF.Int stock_uom: DF.Link | None update_existing_price_list_rate: DF.Check + update_price_list_based_on: DF.Literal["Rate", "Price List Rate"] use_naming_series: DF.Check use_serial_batch_fields: DF.Check valuation_method: DF.Literal["FIFO", "Moving Average", "LIFO"] diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 37fbf73533c..60c7a9d7cd6 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -962,8 +962,8 @@ def get_price_list_rate(ctx: ItemDetailsCtx, item_doc, out: ItemDetails = None): price_list_rate = get_price_list_rate_for(ctx, item_doc.variant_of) # insert in database - if price_list_rate is None or frappe.db.get_single_value( - "Stock Settings", "update_existing_price_list_rate" + if price_list_rate is None or frappe.get_cached_value( + "Stock Settings", "Stock Settings", "update_existing_price_list_rate" ): insert_item_price(ctx) @@ -988,54 +988,69 @@ def insert_item_price(ctx: ItemDetailsCtx): if not ctx.price_list or not ctx.rate or ctx.is_internal_supplier or ctx.is_internal_customer: return - if frappe.db.get_value("Price List", ctx.price_list, "currency", cache=True) == ctx.currency and cint( - frappe.db.get_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing") - ): - if frappe.has_permission("Item Price", "write"): - price_list_rate = ( - (flt(ctx.rate) + flt(ctx.discount_amount)) / ctx.conversion_factor - if ctx.conversion_factor - else (flt(ctx.rate) + flt(ctx.discount_amount)) - ) + stock_settings = frappe.get_cached_doc("Stock Settings") - item_price = frappe.db.get_value( - "Item Price", - { - "item_code": ctx.item_code, - "price_list": ctx.price_list, - "currency": ctx.currency, - "uom": ctx.stock_uom, - }, - ["name", "price_list_rate"], - as_dict=1, - ) - if item_price and item_price.name: - if item_price.price_list_rate != price_list_rate and frappe.db.get_single_value( - "Stock Settings", "update_existing_price_list_rate" - ): - frappe.db.set_value("Item Price", item_price.name, "price_list_rate", price_list_rate) - frappe.msgprint( - _("Item Price updated for {0} in Price List {1}").format( - ctx.item_code, ctx.price_list - ), - alert=True, - ) - else: - item_price = frappe.get_doc( - { - "doctype": "Item Price", - "price_list": ctx.price_list, - "item_code": ctx.item_code, - "currency": ctx.currency, - "price_list_rate": price_list_rate, - "uom": ctx.stock_uom, - } - ) - item_price.insert() - frappe.msgprint( - _("Item Price added for {0} in Price List {1}").format(ctx.item_code, ctx.price_list), - alert=True, - ) + if ( + not frappe.db.get_value("Price List", ctx.price_list, "currency", cache=True) == ctx.currency + or not stock_settings.auto_insert_price_list_rate_if_missing + or not frappe.has_permission("Item Price", "write") + ): + return + + item_price = frappe.db.get_value( + "Item Price", + { + "item_code": ctx.item_code, + "price_list": ctx.price_list, + "currency": ctx.currency, + "uom": ctx.stock_uom, + }, + ["name", "price_list_rate"], + as_dict=1, + ) + + update_based_on_price_list_rate = stock_settings.update_price_list_based_on == "Price List Rate" + + if item_price and item_price.name: + if not stock_settings.update_existing_price_list_rate: + return + + rate_to_consider = flt(ctx.price_list_rate) if update_based_on_price_list_rate else flt(ctx.rate) + price_list_rate = _get_stock_uom_rate(rate_to_consider, ctx) + + if not price_list_rate or item_price.price_list_rate == price_list_rate: + return + + frappe.db.set_value("Item Price", item_price.name, "price_list_rate", price_list_rate) + frappe.msgprint( + _("Item Price updated for {0} in Price List {1}").format(ctx.item_code, ctx.price_list), + alert=True, + ) + else: + rate_to_consider = ( + (flt(ctx.price_list_rate) or flt(ctx.rate)) if update_based_on_price_list_rate else flt(ctx.rate) + ) + price_list_rate = _get_stock_uom_rate(rate_to_consider, ctx) + + item_price = frappe.get_doc( + { + "doctype": "Item Price", + "price_list": ctx.price_list, + "item_code": ctx.item_code, + "currency": ctx.currency, + "price_list_rate": price_list_rate, + "uom": ctx.stock_uom, + } + ) + item_price.insert() + frappe.msgprint( + _("Item Price added for {0} in Price List {1}").format(ctx.item_code, ctx.price_list), + alert=True, + ) + + +def _get_stock_uom_rate(rate: float, ctx: ItemDetailsCtx): + return rate / ctx.conversion_factor if ctx.conversion_factor else rate def get_item_price( diff --git a/erpnext/stock/report/available_serial_no/available_serial_no.js b/erpnext/stock/report/available_serial_no/available_serial_no.js index 17f8c666e04..c69c6503de8 100644 --- a/erpnext/stock/report/available_serial_no/available_serial_no.js +++ b/erpnext/stock/report/available_serial_no/available_serial_no.js @@ -51,49 +51,11 @@ frappe.query_reports["Available Serial No"] = { }; }, }, - { - fieldname: "item_group", - label: __("Item Group"), - fieldtype: "Link", - options: "Item Group", - }, - { - fieldname: "batch_no", - label: __("Batch No"), - fieldtype: "Link", - options: "Batch", - on_change() { - const batch_no = frappe.query_report.get_filter_value("batch_no"); - if (batch_no) { - frappe.query_report.set_filter_value("segregate_serial_batch_bundle", 1); - } else { - frappe.query_report.set_filter_value("segregate_serial_batch_bundle", 0); - } - }, - }, - { - fieldname: "brand", - label: __("Brand"), - fieldtype: "Link", - options: "Brand", - }, { fieldname: "voucher_no", label: __("Voucher #"), fieldtype: "Data", }, - { - fieldname: "project", - label: __("Project"), - fieldtype: "Link", - options: "Project", - }, - { - fieldname: "include_uom", - label: __("Include UOM"), - fieldtype: "Link", - options: "UOM", - }, { fieldname: "valuation_field_type", label: __("Valuation Field Type"), diff --git a/erpnext/stock/report/available_serial_no/available_serial_no.py b/erpnext/stock/report/available_serial_no/available_serial_no.py index bdde9c7f3b6..6911b979ae4 100644 --- a/erpnext/stock/report/available_serial_no/available_serial_no.py +++ b/erpnext/stock/report/available_serial_no/available_serial_no.py @@ -3,108 +3,62 @@ import frappe from frappe import _ -from frappe.query_builder.functions import Sum from frappe.utils import cint, flt -from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos, get_serial_nos_from_sle_list -from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import get_stock_balance_for from erpnext.stock.report.stock_ledger.stock_ledger import ( - check_inventory_dimension_filters_applied, get_item_details, - get_item_group_condition, get_opening_balance, - get_opening_balance_from_batch, get_stock_ledger_entries, ) -from erpnext.stock.utils import ( - is_reposting_item_valuation_in_progress, - update_included_uom_in_report, -) +from erpnext.stock.utils import is_reposting_item_valuation_in_progress def execute(filters=None): is_reposting_item_valuation_in_progress() - include_uom = filters.get("include_uom") columns = get_columns(filters) items = get_items(filters) sl_entries = get_stock_ledger_entries(filters, items) - item_details = get_item_details(items, sl_entries, include_uom) + item_details = get_item_details(items, sl_entries, False) - opening_row, actual_qty, stock_value = get_opening_balance_data(filters, columns, sl_entries) + opening_row = get_opening_balance_data(filters, columns, sl_entries) precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) - data, conversion_factors = process_stock_ledger_entries( - filters, sl_entries, item_details, opening_row, actual_qty, stock_value, precision - ) + data = process_stock_ledger_entries(sl_entries, item_details, opening_row, precision) - update_included_uom_in_report(columns, data, include_uom, conversion_factors) return columns, data def get_opening_balance_data(filters, columns, sl_entries): - if filters.get("batch_no"): - opening_row = get_opening_balance_from_batch(filters, columns, sl_entries) - else: - opening_row = get_opening_balance(filters, columns, sl_entries) - - actual_qty = opening_row.get("qty_after_transaction") if opening_row else 0 - stock_value = opening_row.get("stock_value") if opening_row else 0 - return opening_row, actual_qty, stock_value + opening_row = get_opening_balance(filters, columns, sl_entries) + return opening_row -def process_stock_ledger_entries( - filters, sl_entries, item_details, opening_row, actual_qty, stock_value, precision -): +def process_stock_ledger_entries(sl_entries, item_details, opening_row, precision): data = [] - conversion_factors = [] if opening_row: data.append(opening_row) - conversion_factors.append(0) - batch_balance_dict = frappe._dict({}) + available_serial_nos = {} + if sabb_list := [sle.serial_and_batch_bundle for sle in sl_entries if sle.serial_and_batch_bundle]: + available_serial_nos = get_serial_nos_from_sle_list(sabb_list) - if actual_qty and filters.get("batch_no"): - batch_balance_dict[filters.batch_no] = [actual_qty, stock_value] - - available_serial_nos = get_serial_nos_from_sle_list( - [sle.serial_and_batch_bundle for sle in sl_entries if sle.serial_and_batch_bundle] - ) + if not available_serial_nos: + return [], [] for sle in sl_entries: - update_stock_ledger_entry( - sle, item_details, filters, actual_qty, stock_value, batch_balance_dict, precision - ) + update_stock_ledger_entry(sle, item_details, precision) update_available_serial_nos(available_serial_nos, sle) data.append(sle) - if filters.get("include_uom"): - conversion_factors.append(item_details[sle.item_code].conversion_factor) - - return data, conversion_factors + return data -def update_stock_ledger_entry( - sle, item_details, filters, actual_qty, stock_value, batch_balance_dict, precision -): +def update_stock_ledger_entry(sle, item_details, precision): item_detail = item_details[sle.item_code] sle.update(item_detail) - if filters.get("batch_no") or check_inventory_dimension_filters_applied(filters): - actual_qty += flt(sle.actual_qty, precision) - stock_value += sle.stock_value_difference - - if sle.batch_no: - batch_balance_dict.setdefault(sle.batch_no, [0, 0]) - batch_balance_dict[sle.batch_no][0] += sle.actual_qty - - if sle.voucher_type == "Stock Reconciliation" and not sle.actual_qty: - actual_qty = sle.qty_after_transaction - stock_value = sle.stock_value - - sle.update({"qty_after_transaction": actual_qty, "stock_value": stock_value}) - sle.update({"in_qty": max(sle.actual_qty, 0), "out_qty": min(sle.actual_qty, 0)}) if sle.actual_qty: @@ -120,13 +74,10 @@ def update_available_serial_nos(available_serial_nos, sle): else available_serial_nos.get(sle.serial_and_batch_bundle) ) key = (sle.item_code, sle.warehouse) + sle.serial_no = "\n".join(serial_nos) if serial_nos else "" if key not in available_serial_nos: - stock_balance = get_stock_balance_for( - sle.item_code, sle.warehouse, sle.posting_date, sle.posting_time - ) - serials = get_serial_nos(stock_balance["serial_nos"]) if stock_balance["serial_nos"] else [] - available_serial_nos.setdefault(key, serials) - sle.balance_serial_no = "\n".join(serials) + available_serial_nos.setdefault(key, serial_nos) + sle.balance_serial_no = "\n".join(serial_nos) return existing_serial_no = available_serial_nos[key] @@ -151,25 +102,14 @@ def get_columns(filters): }, {"label": _("Item Name"), "fieldname": "item_name", "width": 100}, { - "label": _("Stock UOM"), + "label": _("UOM"), "fieldname": "stock_uom", "fieldtype": "Link", "options": "UOM", - "width": 90, + "width": 60, }, ] - for dimension in get_inventory_dimensions(): - columns.append( - { - "label": _(dimension.doctype), - "fieldname": dimension.fieldname, - "fieldtype": "Link", - "options": dimension.doctype, - "width": 110, - } - ) - columns.extend( [ { @@ -201,20 +141,11 @@ def get_columns(filters): "width": 150, }, { - "label": _("Item Group"), - "fieldname": "item_group", - "fieldtype": "Link", - "options": "Item Group", - "width": 100, + "label": _("Serial No (In/Out)"), + "fieldname": "serial_no", + "width": 150, }, - { - "label": _("Brand"), - "fieldname": "brand", - "fieldtype": "Link", - "options": "Brand", - "width": 100, - }, - {"label": _("Description"), "fieldname": "description", "width": 200}, + {"label": _("Balance Serial No"), "fieldname": "balance_serial_no", "width": 150}, { "label": _("Incoming Rate"), "fieldname": "incoming_rate", @@ -257,28 +188,6 @@ def get_columns(filters): "width": 110, "options": "Company:company:default_currency", }, - {"label": _("Voucher Type"), "fieldname": "voucher_type", "width": 110}, - { - "label": _("Voucher #"), - "fieldname": "voucher_no", - "fieldtype": "Dynamic Link", - "options": "voucher_type", - "width": 100, - }, - { - "label": _("Batch"), - "fieldname": "batch_no", - "fieldtype": "Link", - "options": "Batch", - "width": 100, - }, - { - "label": _("Serial No"), - "fieldname": "serial_no", - "fieldtype": "Link", - "options": "Serial No", - "width": 100, - }, { "label": _("Serial and Batch Bundle"), "fieldname": "serial_and_batch_bundle", @@ -286,12 +195,12 @@ def get_columns(filters): "options": "Serial and Batch Bundle", "width": 100, }, - {"label": _("Balance Serial No"), "fieldname": "balance_serial_no", "width": 100}, + {"label": _("Voucher Type"), "fieldname": "voucher_type", "width": 110}, { - "label": _("Project"), - "fieldname": "project", - "fieldtype": "Link", - "options": "Project", + "label": _("Voucher #"), + "fieldname": "voucher_no", + "fieldtype": "Dynamic Link", + "options": "voucher_type", "width": 100, }, { @@ -310,19 +219,8 @@ def get_columns(filters): def get_items(filters): item = frappe.qb.DocType("Item") query = frappe.qb.from_(item).select(item.name).where(item.has_serial_no == 1) - conditions = [] if item_code := filters.get("item_code"): - conditions.append(item.name == item_code) - else: - if brand := filters.get("brand"): - conditions.append(item.brand == brand) - if item_group := filters.get("item_group"): - if condition := get_item_group_condition(item_group, item): - conditions.append(condition) - - if conditions: - for condition in conditions: - query = query.where(condition) + query = query.where(item.name == item_code) return query.run(pluck=True) diff --git a/erpnext/stock/report/serial_no_warranty_expiry/serial_no_warranty_expiry.json b/erpnext/stock/report/serial_no_warranty_expiry/serial_no_warranty_expiry.json index 75e2fac98fd..2f6acad6557 100644 --- a/erpnext/stock/report/serial_no_warranty_expiry/serial_no_warranty_expiry.json +++ b/erpnext/stock/report/serial_no_warranty_expiry/serial_no_warranty_expiry.json @@ -10,14 +10,14 @@ "is_standard": "Yes", "json": "{\"add_total_row\": 0, \"sort_by\": \"Serial No.modified\", \"sort_order\": \"desc\", \"sort_by_next\": null, \"filters\": [[\"Serial No\", \"warehouse\", \"=\", \"\"]], \"sort_order_next\": \"desc\", \"columns\": [[\"name\", \"Serial No\"], [\"item_code\", \"Serial No\"], [\"amc_expiry_date\", \"Serial No\"], [\"maintenance_status\", \"Serial No\"],[\"item_name\", \"Serial No\"], [\"description\", \"Serial No\"], [\"item_group\", \"Serial No\"], [\"brand\", \"Serial No\"]]}", "letterhead": null, - "modified": "2024-09-26 13:07:23.451182", + "modified": "2025-04-24 13:07:23.451182", "modified_by": "Administrator", "module": "Stock", - "name": "Serial No Service Contract Expiry", + "name": "Serial No Warranty Expiry", "owner": "Administrator", "prepared_report": 0, "ref_doctype": "Serial No", - "report_name": "Serial No Service Contract Expiry", + "report_name": "Serial No Warranty Expiry", "report_type": "Report Builder", "roles": [ { diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index e127960d6bb..bff764228f5 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -743,6 +743,9 @@ class BatchNoValuation(DeprecatedBatchNoValuation): if not self.sle.actual_qty: self.sle.actual_qty = self.get_actual_qty() + if not self.sle.actual_qty: + return 0.0 + return abs(flt(self.stock_value_change) / flt(self.sle.actual_qty)) def get_actual_qty(self): diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 0d8d61470d6..5c802d1cb40 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -898,7 +898,7 @@ class update_entries_after: self.wh_data.prev_stock_value = self.wh_data.stock_value # update current sle - sle.qty_after_transaction = self.wh_data.qty_after_transaction + sle.qty_after_transaction = flt(self.wh_data.qty_after_transaction, self.flt_precision) sle.valuation_rate = self.wh_data.valuation_rate sle.stock_value = self.wh_data.stock_value sle.stock_queue = json.dumps(self.wh_data.stock_queue) @@ -966,7 +966,7 @@ class update_entries_after: def reset_actual_qty_for_stock_reco(self, sle): doc = frappe.get_cached_doc("Stock Reconciliation", sle.voucher_no) - doc.recalculate_current_qty(sle.voucher_detail_no) + doc.recalculate_current_qty(sle.voucher_detail_no, sle.creation, sle.actual_qty > 0) if sle.actual_qty < 0: sle.actual_qty = ( @@ -2109,29 +2109,6 @@ def get_future_sle_with_negative_batch_qty(sle_args): def validate_reserved_stock(kwargs): - if kwargs.serial_no: - serial_nos = kwargs.serial_no.split("\n") - validate_reserved_serial_nos(kwargs.item_code, kwargs.warehouse, serial_nos) - - elif kwargs.batch_no: - validate_reserved_batch_nos(kwargs.item_code, kwargs.warehouse, [kwargs.batch_no]) - - elif kwargs.serial_and_batch_bundle: - sbb_entries = frappe.db.get_all( - "Serial and Batch Entry", - { - "parenttype": "Serial and Batch Bundle", - "parent": kwargs.serial_and_batch_bundle, - "docstatus": 1, - }, - ["batch_no", "serial_no"], - ) - - if serial_nos := [entry.serial_no for entry in sbb_entries if entry.serial_no]: - validate_reserved_serial_nos(kwargs.item_code, kwargs.warehouse, serial_nos) - elif batch_nos := [entry.batch_no for entry in sbb_entries if entry.batch_no]: - validate_reserved_batch_nos(kwargs.item_code, kwargs.warehouse, batch_nos) - # Qty based validation for non-serial-batch items OR SRE with Reservation Based On Qty. precision = cint(frappe.db.get_default("float_precision")) or 2 balance_qty = get_stock_balance(kwargs.item_code, kwargs.warehouse) diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 15c356dde3d..6b93e0883cf 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -109,6 +109,8 @@ def get_stock_balance( from erpnext.stock.stock_ledger import get_previous_sle + frappe.has_permission("Item", "read") + if posting_date is None: posting_date = nowdate() if posting_time is None: diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js index 2aaf8a8adcd..c9fe457ef79 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js @@ -54,7 +54,7 @@ frappe.ui.form.on("Subcontracting Receipt", { from_date: frm.doc.posting_date, to_date: moment(frm.doc.modified).format("YYYY-MM-DD"), company: frm.doc.company, - group_by: "Group by Voucher (Consolidated)", + categorize_by: "Categorize by Voucher (Consolidated)", show_cancelled_entries: frm.doc.docstatus === 2, }; frappe.set_route("query-report", "General Ledger"); diff --git a/erpnext/templates/includes/rfq.js b/erpnext/templates/includes/rfq.js index 37beb5a584b..cc998a90030 100644 --- a/erpnext/templates/includes/rfq.js +++ b/erpnext/templates/includes/rfq.js @@ -31,8 +31,8 @@ rfq = class rfq { var me = this; $('.rfq-items').on("change", ".rfq-qty", function(){ me.idx = parseFloat($(this).attr('data-idx')); - me.qty = parseFloat($(this).val()) || 0; - me.rate = parseFloat($(repl('.rfq-rate[data-idx=%(idx)s]',{'idx': me.idx})).val()); + me.qty = parseFloat(flt($(this).val())) || 0; + me.rate = parseFloat(flt($(repl('.rfq-rate[data-idx=%(idx)s]',{'idx': me.idx})).val())); me.update_qty_rate(); $(this).val(format_number(me.qty, doc.number_format, 2)); }) @@ -42,8 +42,8 @@ rfq = class rfq { var me = this; $(".rfq-items").on("change", ".rfq-rate", function(){ me.idx = parseFloat($(this).attr('data-idx')); - me.rate = parseFloat($(this).val()) || 0; - me.qty = parseFloat($(repl('.rfq-qty[data-idx=%(idx)s]',{'idx': me.idx})).val()); + me.rate = parseFloat(flt($(this).val())) || 0; + me.qty = parseFloat(flt($(repl('.rfq-qty[data-idx=%(idx)s]',{'idx': me.idx})).val())); me.update_qty_rate(); $(this).val(format_number(me.rate, doc.number_format, 2)); }) diff --git a/erpnext/templates/includes/timesheet/timesheet_row.html b/erpnext/templates/includes/timesheet/timesheet_row.html index 0f9cc77e89d..8905262a88e 100644 --- a/erpnext/templates/includes/timesheet/timesheet_row.html +++ b/erpnext/templates/includes/timesheet/timesheet_row.html @@ -5,7 +5,7 @@ {{ doc.name }} {{ doc.total_billable_hours }}
+ {{ doc.billing_hours }}
{{ doc.project or '' }}
{{ doc.sales_invoice or '' }}
{{ _(doc.activity_type) }}
diff --git a/erpnext/tests/utils.py b/erpnext/tests/utils.py
index 7ed84514f52..7dedd637d3c 100644
--- a/erpnext/tests/utils.py
+++ b/erpnext/tests/utils.py
@@ -5,6 +5,7 @@ from typing import Any, NewType
import frappe
from frappe.core.doctype.report.report import get_report_module_dotted_path
+from frappe.tests import IntegrationTestCase
ReportFilters = dict[str, Any]
ReportName = NewType("ReportName", str)
@@ -114,3 +115,188 @@ def if_lending_app_not_installed(function):
return
return wrapper
+
+
+class ERPNextTestSuite(IntegrationTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ @classmethod
+ def make_monthly_distribution(cls):
+ records = [
+ {
+ "doctype": "Monthly Distribution",
+ "distribution_id": "_Test Distribution",
+ "fiscal_year": "_Test Fiscal Year 2013",
+ "percentages": [
+ {"month": "January", "percentage_allocation": "8"},
+ {"month": "February", "percentage_allocation": "8"},
+ {"month": "March", "percentage_allocation": "8"},
+ {"month": "April", "percentage_allocation": "8"},
+ {"month": "May", "percentage_allocation": "8"},
+ {"month": "June", "percentage_allocation": "8"},
+ {"month": "July", "percentage_allocation": "8"},
+ {"month": "August", "percentage_allocation": "8"},
+ {"month": "September", "percentage_allocation": "8"},
+ {"month": "October", "percentage_allocation": "8"},
+ {"month": "November", "percentage_allocation": "10"},
+ {"month": "December", "percentage_allocation": "10"},
+ ],
+ }
+ ]
+ cls.monthly_distribution = []
+ for x in records:
+ if not frappe.db.exists("Monthly Distribution", {"distribution_id": x.get("distribution_id")}):
+ cls.monthly_distribution.append(frappe.get_doc(x).insert())
+ else:
+ cls.monthly_distribution.append(
+ frappe.get_doc("Monthly Distribution", {"distribution_id": x.get("distribution_id")})
+ )
+
+ @classmethod
+ def make_projects(cls):
+ records = [
+ {
+ "doctype": "Project",
+ "company": "_Test Company",
+ "project_name": "_Test Project",
+ "status": "Open",
+ }
+ ]
+
+ cls.projects = []
+ for x in records:
+ if not frappe.db.exists("Project", {"project_name": x.get("project_name")}):
+ cls.projects.append(frappe.get_doc(x).insert())
+ else:
+ cls.projects.append(frappe.get_doc("Project", {"project_name": x.get("project_name")}))
+
+ @classmethod
+ def make_employees(cls):
+ records = [
+ {
+ "company": "_Test Company",
+ "date_of_birth": "1980-01-01",
+ "date_of_joining": "2010-01-01",
+ "department": "_Test Department - _TC",
+ "doctype": "Employee",
+ "first_name": "_Test Employee",
+ "gender": "Female",
+ "naming_series": "_T-Employee-",
+ "status": "Active",
+ "user_id": "test@example.com",
+ },
+ {
+ "company": "_Test Company",
+ "date_of_birth": "1980-01-01",
+ "date_of_joining": "2010-01-01",
+ "department": "_Test Department 1 - _TC",
+ "doctype": "Employee",
+ "first_name": "_Test Employee 1",
+ "gender": "Male",
+ "naming_series": "_T-Employee-",
+ "status": "Active",
+ "user_id": "test1@example.com",
+ },
+ {
+ "company": "_Test Company",
+ "date_of_birth": "1980-01-01",
+ "date_of_joining": "2010-01-01",
+ "department": "_Test Department 1 - _TC",
+ "doctype": "Employee",
+ "first_name": "_Test Employee 2",
+ "gender": "Male",
+ "naming_series": "_T-Employee-",
+ "status": "Active",
+ "user_id": "test2@example.com",
+ },
+ ]
+ cls.employees = []
+ for x in records:
+ if not frappe.db.exists("Employee", {"first_name": x.get("first_name")}):
+ cls.employees.append(frappe.get_doc(x).insert())
+ else:
+ cls.employees.append(frappe.get_doc("Employee", {"first_name": x.get("first_name")}))
+
+ @classmethod
+ def make_sales_person(cls):
+ records = [
+ {
+ "doctype": "Sales Person",
+ "employee": "_T-Employee-00001",
+ "is_group": 0,
+ "parent_sales_person": "Sales Team",
+ "sales_person_name": "_Test Sales Person",
+ },
+ {
+ "doctype": "Sales Person",
+ "employee": "_T-Employee-00002",
+ "is_group": 0,
+ "parent_sales_person": "Sales Team",
+ "sales_person_name": "_Test Sales Person 1",
+ },
+ {
+ "doctype": "Sales Person",
+ "employee": "_T-Employee-00003",
+ "is_group": 0,
+ "parent_sales_person": "Sales Team",
+ "sales_person_name": "_Test Sales Person 2",
+ },
+ ]
+ cls.sales_person = []
+ for x in records:
+ if not frappe.db.exists("Sales Person", {"sales_person_name": x.get("sales_person_name")}):
+ cls.sales_person.append(frappe.get_doc(x).insert())
+ else:
+ cls.sales_person.append(
+ frappe.get_doc("Sales Person", {"sales_person_name": x.get("sales_person_name")})
+ )
+
+ @classmethod
+ def make_leads(cls):
+ records = [
+ {
+ "doctype": "Lead",
+ "email_id": "test_lead@example.com",
+ "lead_name": "_Test Lead",
+ "status": "Open",
+ "territory": "_Test Territory",
+ "naming_series": "_T-Lead-",
+ },
+ {
+ "doctype": "Lead",
+ "email_id": "test_lead1@example.com",
+ "lead_name": "_Test Lead 1",
+ "status": "Open",
+ "naming_series": "_T-Lead-",
+ },
+ {
+ "doctype": "Lead",
+ "email_id": "test_lead2@example.com",
+ "lead_name": "_Test Lead 2",
+ "status": "Lead",
+ "naming_series": "_T-Lead-",
+ },
+ {
+ "doctype": "Lead",
+ "email_id": "test_lead3@example.com",
+ "lead_name": "_Test Lead 3",
+ "status": "Converted",
+ "naming_series": "_T-Lead-",
+ },
+ {
+ "doctype": "Lead",
+ "email_id": "test_lead4@example.com",
+ "lead_name": "_Test Lead 4",
+ "company_name": "_Test Lead 4",
+ "status": "Open",
+ "naming_series": "_T-Lead-",
+ },
+ ]
+ cls.leads = []
+ for x in records:
+ if not frappe.db.exists("Lead", {"email_id": x.get("email_id")}):
+ cls.leads.append(frappe.get_doc(x).insert())
+ else:
+ cls.leads.append(frappe.get_doc("Lead", {"email_id": x.get("email_id")}))
diff --git a/erpnext/utilities/doctype/rename_tool/rename_tool.js b/erpnext/utilities/doctype/rename_tool/rename_tool.js
index 1b8b2be2610..47677a62500 100644
--- a/erpnext/utilities/doctype/rename_tool/rename_tool.js
+++ b/erpnext/utilities/doctype/rename_tool/rename_tool.js
@@ -18,29 +18,70 @@ frappe.ui.form.on("Rename Tool", {
allowed_file_types: [".csv"],
},
};
- if (!frm.doc.file_to_rename) {
- frm.get_field("rename_log").$wrapper.html("");
- }
+
+ frm.trigger("render_overview");
+
frm.page.set_primary_action(__("Rename"), function () {
- frm.get_field("rename_log").$wrapper.html("Renaming... "); frappe.call({ method: "erpnext.utilities.doctype.rename_tool.rename_tool.upload", args: { select_doctype: frm.doc.select_doctype, }, - callback: function (r) { - let html = r.message.join(""); + freeze: true, + freeze_message: __("Scheduling..."), + callback: function () { + frappe.msgprint({ + message: __("Rename jobs for doctype {0} have been enqueued.", [ + frm.doc.select_doctype, + ]), + alert: true, + indicator: "green", + }); + frm.set_value("select_doctype", ""); + frm.set_value("file_to_rename", ""); - if (r.exc) { - r.exc = frappe.utils.parse_json(r.exc); - if (Array.isArray(r.exc)) { - html += " " + r.exc.join(" "); - } - } + frm.trigger("render_overview"); + }, + error: function (r) { + frappe.msgprint({ + message: __("Rename jobs for doctype {0} have not been enqueued.", [ + frm.doc.select_doctype, + ]), + alert: true, + indicator: "red", + }); - frm.get_field("rename_log").$wrapper.html(html); + frm.trigger("render_overview"); }, }); }); }, + render_overview: function (frm) { + frappe.db + .get_list("RQ Job", { filters: { status: ["in", ["started", "queued", "finished", "failed"]] } }) + .then((jobs) => { + let counts = { + started: 0, + queued: 0, + finished: 0, + failed: 0, + }; + + for (const job of jobs) { + if (job.job_name !== "frappe.model.rename_doc.bulk_rename") { + continue; + } + + counts[job.status]++; + } + + frm.get_field("rename_log").$wrapper.html(` + ${__("Bulk Rename Jobs")} +${__("Queued")}: ${counts.queued} +${__("Started")}: ${counts.started} + + + `); + }); + }, }); diff --git a/erpnext/utilities/doctype/rename_tool/rename_tool.py b/erpnext/utilities/doctype/rename_tool/rename_tool.py index 19b29f79aa1..230845e55de 100644 --- a/erpnext/utilities/doctype/rename_tool/rename_tool.py +++ b/erpnext/utilities/doctype/rename_tool/rename_tool.py @@ -45,4 +45,11 @@ def upload(select_doctype=None, rows=None): rows = read_csv_content_from_attached_file(frappe.get_doc("Rename Tool", "Rename Tool")) - return bulk_rename(select_doctype, rows=rows) + # bulk rename allows only 500 rows at a time, so we created one job per 500 rows + for i in range(0, len(rows), 500): + frappe.enqueue( + method=bulk_rename, + queue="long", + doctype=select_doctype, + rows=rows[i : i + 500], + ) diff --git a/erpnext/utilities/transaction_base.py b/erpnext/utilities/transaction_base.py index d6616bd7b9a..a3feecc36f7 100644 --- a/erpnext/utilities/transaction_base.py +++ b/erpnext/utilities/transaction_base.py @@ -296,6 +296,10 @@ class TransactionBase(StatusUpdater): item_details = self.fetch_item_details(item_obj) self.set_fetched_values(item_obj, item_details) + + if self.doctype == "Request for Quotation": + return + self.set_item_rate_and_discounts(item_obj, item_details) self.add_taxes_from_item_template(item_obj, item_details) self.add_free_item(item_obj, item_details) |