diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 3f75c24a4bb..a697e8adf00 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -662,6 +662,48 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(ste.from_bom, 1.0) self.assertEqual(ste.bom_no, work_order.bom_no) + def test_job_card_material_transfer_via_pick_list(self): + from erpnext.stock.doctype.material_request.mapper import create_pick_list + from erpnext.stock.doctype.pick_list.mapper import ( + create_stock_entry as create_stock_entry_from_pick_list, + ) + + create_bom_with_multiple_operations() + work_order = make_wo_with_transfer_against_jc() + + for item in work_order.required_items: + make_stock_entry( + item_code=item.item_code, + target=item.source_warehouse, + qty=item.required_qty * 2, + basic_rate=100, + ) + + job_card_name = frappe.db.get_value("Job Card", {"work_order": work_order.name}, "name") + job_card = frappe.get_doc("Job Card", job_card_name) + + mr = make_material_request(job_card_name) + mr.schedule_date = today() + mr.submit() + + pick_list = create_pick_list(mr.name) + pick_list.submit() + + ste = frappe.get_doc(create_stock_entry_from_pick_list(pick_list.as_dict())) + self.assertEqual(ste.purpose, "Material Transfer for Manufacture") + self.assertEqual(ste.job_card, job_card_name) + self.assertEqual(ste.work_order, work_order.name) + self.assertEqual(ste.fg_completed_qty, job_card.for_quantity) + for row in ste.items: + self.assertEqual(row.t_warehouse, job_card.wip_warehouse) + self.assertTrue(row.job_card_item) + + ste.insert() + ste.submit() + + job_card.reload() + self.assertEqual(job_card.transferred_qty, job_card.for_quantity) + def test_job_card_proccess_qty_and_completed_qty(self): from erpnext.manufacturing.doctype.routing.test_routing import ( create_routing, diff --git a/erpnext/stock/doctype/pick_list/mapper.py b/erpnext/stock/doctype/pick_list/mapper.py new file mode 100644 index 00000000000..f9281931de5 --- /dev/null +++ b/erpnext/stock/doctype/pick_list/mapper.py @@ -0,0 +1,467 @@ +# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import json +from itertools import groupby + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.model.mapper import map_child_doc +from frappe.utils import flt, get_link_to_form + +from erpnext.selling.doctype.sales_order.mapper import ( + make_delivery_note as create_delivery_note_from_sales_order, +) +from erpnext.selling.doctype.sales_order.mapper import ( + make_sales_invoice as create_sales_invoice_from_sales_order, +) + + +def validate_item_locations(pick_list): + if not pick_list.locations: + frappe.throw(_("Add items in the Item Locations table")) + + +@frappe.whitelist() +def create_delivery_note(source_name: str, target_doc: str | Document | None = None): + return create_delivery(source_name, target_doc, "Delivery Note") + + +@frappe.whitelist() +def create_delivery(source_name: str, target_doc: str | Document | None = None, target: str | None = None): + pick_list = frappe.get_doc("Pick List", source_name) + target = target or (frappe.flags.args or {}).get("target") or "Delivery Note" + validate_item_locations(pick_list) + sales_dict = dict() + sales_orders = [] + documents = [] + for location in pick_list.locations: + if location.sales_order: + sales_orders.append( + frappe.db.get_value( + "Sales Order", + location.sales_order, + [ + "customer", + "name as sales_order", + "company_address", + "dispatch_address_name", + "shipping_address_name", + "customer_address", + ], + as_dict=True, + ) + ) + + group_key = lambda so: ( # noqa + so["customer"], + so["company_address"] or "", + so["dispatch_address_name"] or "", + so["shipping_address_name"] or "", + so["customer_address"] or "", + ) + for key, rows in groupby(sorted(sales_orders, key=group_key), key=group_key): + sales_dict[key] = {row.sales_order for row in rows} + + if sales_dict: + documents.extend(create_delivery_with_so(sales_dict, pick_list, target)) + + if not all(item.sales_order for item in pick_list.locations): + documents.append(create_delivery_wo_so(pick_list, target, target_doc)) + + if len(documents) == 1: + return documents[0] + else: + from frappe.utils import comma_and + + doc_list = [get_link_to_form(target, p.name) for p in documents] + frappe.msgprint(_("{0} created").format(comma_and(doc_list))) + + +def create_dn_wo_so(pick_list, delivery_note=None): + return create_delivery_wo_so(pick_list, "Delivery Note", delivery_note) + + +def create_delivery_wo_so(pick_list, target, target_doc=None): + if not target_doc: + target_doc = frappe.new_doc(target) + + target_doc.company = pick_list.company + + item_table_mapper_without_so = { + "doctype": f"{target} Item", + "field_map": { + "rate": "rate", + "name": "name", + "parent": "", + }, + } + map_pl_locations(pick_list, item_table_mapper_without_so, target_doc) + target_doc.flags.ignore_mandatory = True + if target == "Sales Invoice": + target_doc.update_stock = 1 + target_doc.save() + + return target_doc + + +@frappe.whitelist() +def create_dn_for_pick_lists( + source_name: str, target_doc: str | Document | None = None, kwargs: dict | str | None = None +): + """Get Items from Multiple Pick Lists and create a Delivery Note for filtered customer""" + if kwargs is None: + kwargs = {} + kwargs = frappe.parse_json(kwargs) + + pick_list = frappe.get_doc("Pick List", source_name) + validate_item_locations(pick_list) + + sales_order_arg = kwargs.get("sales_order") + customer_arg = kwargs.get("customer") + + if sales_order_arg: + sales_orders = {sales_order_arg} + else: + sales_orders = {row.sales_order for row in pick_list.locations if row.sales_order} + + if customer_arg: + sales_orders = frappe.get_all( + "Sales Order", + filters={"customer": customer_arg, "name": ["in", list(sales_orders)]}, + pluck="name", + ) + + delivery_note = create_delivery_from_so( + pick_list, sales_orders, "Delivery Note", target_doc=target_doc, kwargs=kwargs + ) + + if not sales_order_arg and not all(item.sales_order for item in pick_list.locations): + if isinstance(delivery_note, str): + delivery_note = frappe.get_doc(frappe.parse_json(delivery_note)) + + delivery_note = create_delivery_wo_so(pick_list, "Delivery Note", delivery_note) + + return delivery_note + + +def create_dn_with_so(sales_dict, pick_list): + return create_delivery_with_so(sales_dict, pick_list, "Delivery Note") + + +def create_delivery_with_so(sales_dict, pick_list, target): + """Create target document for each customer (based on SO) in a Pick List.""" + documents = [] + + for key in sales_dict: + document = create_delivery_from_so(pick_list, sales_dict[key], target) + if document: + document.flags.ignore_mandatory = True + # updates packed_items on save + # save as multiple customers are possible + if target == "Sales Invoice": + document.update_stock = 1 + document.save() + documents.append(document) + + return documents + + +def create_dn_from_so(pick_list, sales_order_list, delivery_note=None, kwargs=None): + return create_delivery_from_so( + pick_list, sales_order_list, "Delivery Note", target_doc=delivery_note, kwargs=kwargs + ) + + +def create_delivery_from_so(pick_list, sales_order_list, target, target_doc=None, kwargs=None): + if not sales_order_list: + return target_doc + + if kwargs is None: + kwargs = {} + + def select_item(d): + filtered_items = kwargs.get("filtered_children", []) + child_filter = d.name in filtered_items if filtered_items else True + return child_filter + + item_table_mapper = { + "doctype": f"{target} Item", + "field_map": { + "rate": "rate", + "name": "so_detail", + "parent": "against_sales_order" if target == "Delivery Note" else "sales_order", + }, + "condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty) + and doc.delivered_by_supplier != 1 + and select_item(doc), + } + + kwargs = {"skip_item_mapping": True, "ignore_pricing_rule": pick_list.ignore_pricing_rule} + + target_doc = ( + create_delivery_note_from_sales_order(next(iter(sales_order_list)), target_doc, kwargs=kwargs) + if target == "Delivery Note" + else create_sales_invoice_from_sales_order(next(iter(sales_order_list)), target_doc, args=kwargs) + ) + + if not target_doc: + return + + for so in sales_order_list: + map_pl_locations(pick_list, item_table_mapper, target_doc, so) + + return target_doc + + +def map_pl_locations(pick_list, item_mapper, target_doc, sales_order=None): + for location in pick_list.locations: + if location.sales_order != sales_order or location.product_bundle_item: + continue + + if location.sales_order_item: + sales_order_item = frappe.get_doc("Sales Order Item", location.sales_order_item) + else: + sales_order_item = None + + source_doc = sales_order_item or location + + child_item = map_child_doc(source_doc, target_doc, item_mapper) + + if child_item: + child_item.against_pick_list = pick_list.name + child_item.pick_list_item = location.name + child_item.warehouse = location.warehouse + child_item.qty = flt(location.picked_qty - location.delivered_qty) / ( + flt(child_item.conversion_factor) or 1 + ) + child_item.batch_no = location.batch_no + child_item.serial_no = location.serial_no + child_item.use_serial_batch_fields = location.use_serial_batch_fields + + if not child_item.qty: + target_doc.items.remove(child_item) + continue + + update_child_item(source_doc, child_item, target_doc) + + add_product_bundles_to_target(pick_list, target_doc, item_mapper, sales_order) + set_target_missing_values(target_doc) + + target_doc.company = pick_list.company + if sales_order: + target_doc.customer = frappe.get_value("Sales Order", sales_order, "customer") + + +def add_product_bundles_to_delivery_note(pick_list, delivery_note, item_mapper, sales_order=None) -> None: + return add_product_bundles_to_target(pick_list, delivery_note, item_mapper, sales_order) + + +def add_product_bundles_to_target(pick_list, target_doc, item_mapper, sales_order=None) -> None: + """Add product bundles found in pick list to target document. + + When mapping pick list items, the bundle item itself isn't part of the + locations. Dynamically fetch and add parent bundle item into target document.""" + product_bundles = pick_list._get_product_bundles() + product_bundle_qty_map = pick_list._get_product_bundle_qty_map(product_bundles.values()) + + for so_row, value in product_bundles.items(): + sales_order_item = frappe.get_doc("Sales Order Item", so_row) + if sales_order and sales_order_item.parent != sales_order: + continue + + target_bundle_item = map_child_doc(sales_order_item, target_doc, item_mapper) + target_bundle_item.qty = pick_list._compute_picked_qty_for_bundle( + so_row, product_bundle_qty_map[value.item_code] + ) + target_bundle_item.pick_list_item = value.pick_list_item + target_bundle_item.against_pick_list = pick_list.name + update_child_item(sales_order_item, target_bundle_item, target_doc) + + +@frappe.whitelist() +def create_stock_entry(pick_list: str | dict): + pick_list = frappe.get_doc(frappe.parse_json(pick_list)) + validate_item_locations(pick_list) + + stock_entry = frappe.new_doc("Stock Entry") + stock_entry.pick_list = pick_list.get("name") + stock_entry.purpose = pick_list.get("purpose") + stock_entry.company = pick_list.get("company") + + job_card = pick_list.get("material_request") and frappe.db.get_value( + "Material Request", pick_list.get("material_request"), "job_card" + ) + + if job_card: + stock_entry = update_stock_entry_based_on_job_card(pick_list, stock_entry, job_card) + elif pick_list.get("work_order"): + stock_entry = update_stock_entry_based_on_work_order(pick_list, stock_entry) + elif pick_list.get("material_request"): + stock_entry = update_stock_entry_based_on_material_request(pick_list, stock_entry) + else: + stock_entry = update_stock_entry_items_with_no_reference(pick_list, stock_entry) + + stock_entry.set_stock_entry_type() + + if not stock_entry.get("items"): + return frappe.msgprint(_("All picked items have already been transferred against this Pick List")) + + stock_entry.set_missing_values() + + return stock_entry.as_dict() + + +def update_delivery_note_item(source, target, delivery_note): + return update_child_item(source, target, delivery_note) + + +def update_child_item(source, target, target_doc): + cost_center = frappe.db.get_value("Project", target_doc.project, "cost_center") + if not cost_center: + cost_center = get_cost_center(source.item_code, "Item", target_doc.company) + + if not cost_center: + cost_center = get_cost_center(source.item_group, "Item Group", target_doc.company) + + target.cost_center = cost_center + + +def get_cost_center(for_item, from_doctype, company): + """Returns Cost Center for Item or Item Group""" + return frappe.db.get_value( + "Item Default", + fieldname=["buying_cost_center"], + filters={"parent": for_item, "parenttype": from_doctype, "company": company}, + ) + + +def set_delivery_note_missing_values(target): + return set_target_missing_values(target) + + +def set_target_missing_values(target): + target.run_method("set_missing_values") + target.run_method("set_po_nos") + target.run_method("calculate_taxes_and_totals") + + +def stock_entry_exists(pick_list_name): + return frappe.db.exists("Stock Entry", {"pick_list": pick_list_name}) + + +def update_stock_entry_based_on_job_card(pick_list, stock_entry, job_card): + job_card = frappe.db.get_value( + "Job Card", + job_card, + ["name", "work_order", "bom_no", "for_quantity", "transferred_qty", "wip_warehouse"], + as_dict=True, + ) + + stock_entry.purpose = "Material Transfer for Manufacture" + stock_entry.job_card = job_card.name + stock_entry.work_order = job_card.work_order + stock_entry.from_bom = 1 + stock_entry.bom_no = job_card.bom_no + stock_entry.fg_completed_qty = max(flt(job_card.for_quantity) - flt(job_card.transferred_qty), 0) + stock_entry.to_warehouse = job_card.wip_warehouse + + for location in pick_list.locations: + if get_pending_transfer_stock_qty(location) <= 0: + continue + item = frappe._dict() + update_common_item_properties(item, location) + item.t_warehouse = job_card.wip_warehouse + if location.material_request_item: + item.job_card_item = frappe.db.get_value( + "Material Request Item", location.material_request_item, "job_card_item" + ) + stock_entry.append("items", item) + + return stock_entry + + +def update_stock_entry_based_on_work_order(pick_list, stock_entry): + work_order = frappe.get_doc("Work Order", pick_list.get("work_order")) + + stock_entry.purpose = "Material Transfer for Manufacture" + stock_entry.work_order = work_order.name + stock_entry.company = work_order.company + stock_entry.from_bom = 1 + stock_entry.bom_no = work_order.bom_no + stock_entry.use_multi_level_bom = work_order.use_multi_level_bom + stock_entry.fg_completed_qty = 0 + if work_order.bom_no: + stock_entry.inspection_required = frappe.db.get_value("BOM", work_order.bom_no, "inspection_required") + + is_wip_warehouse_group = frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group") + if not (is_wip_warehouse_group and work_order.skip_transfer): + wip_warehouse = work_order.wip_warehouse + else: + wip_warehouse = None + stock_entry.to_warehouse = wip_warehouse + + stock_entry.project = work_order.project + + for location in pick_list.locations: + if get_pending_transfer_stock_qty(location) <= 0: + continue + item = frappe._dict() + update_common_item_properties(item, location) + item.t_warehouse = wip_warehouse + + stock_entry.append("items", item) + + return stock_entry + + +def update_stock_entry_based_on_material_request(pick_list, stock_entry): + for location in pick_list.locations: + if get_pending_transfer_stock_qty(location) <= 0: + continue + target_warehouse = None + if location.material_request_item: + target_warehouse = frappe.get_value( + "Material Request Item", location.material_request_item, "warehouse" + ) + item = frappe._dict() + update_common_item_properties(item, location) + item.t_warehouse = target_warehouse + stock_entry.append("items", item) + + return stock_entry + + +def update_stock_entry_items_with_no_reference(pick_list, stock_entry): + for location in pick_list.locations: + if get_pending_transfer_stock_qty(location) <= 0: + continue + item = frappe._dict() + update_common_item_properties(item, location) + + stock_entry.append("items", item) + + return stock_entry + + +def get_pending_transfer_stock_qty(location): + """Stock qty of this pick list row still to be moved into a Stock Entry.""" + return flt(location.picked_qty) - flt(location.transferred_qty) + + +def update_common_item_properties(item, location): + pending_stock_qty = get_pending_transfer_stock_qty(location) + item.item_code = location.item_code + item.item_name = location.item_name + item.s_warehouse = location.warehouse + item.transfer_qty = pending_stock_qty + item.qty = flt(pending_stock_qty / (location.conversion_factor or 1), location.precision("qty")) + item.uom = location.uom + item.conversion_factor = location.conversion_factor + item.stock_uom = location.stock_uom + item.material_request = location.material_request + item.serial_no = location.serial_no + item.batch_no = location.batch_no + item.material_request_item = location.material_request_item + item.pick_list_item = location.name