diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 64a1d3530f2..862cdcd70b6 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -370,6 +370,8 @@ class SalesInvoice(SellingController): if row.billing_amount: row.billing_amount = -abs(row.billing_amount) + self.validate_update_stock_for_pick_list_reference() + self.set_serial_and_batch_bundle_from_pick_list() self.update_packing_list() self.set_billing_hours_and_amount() self.update_timesheet_billing_for_project() @@ -389,6 +391,18 @@ class SalesInvoice(SellingController): self.validate_subcontracted_sales_order() self.validate_scio_self_rm_qty() + def validate_update_stock_for_pick_list_reference(self): + if self.update_stock or self.is_return: + return + + for row in self.items: + if row.get("against_pick_list"): + frappe.throw( + _( + "Row {0}: Update Stock must be checked for item {1} because it is against Pick List {2}." + ).format(row.idx, frappe.bold(row.item_code), frappe.bold(row.against_pick_list)) + ) + def validate_accounts(self): self.validate_write_off_account() self.validate_account_for_change_amount() @@ -491,6 +505,7 @@ class SalesInvoice(SellingController): if self.update_stock == 1: self.repost_future_sle_and_gle() + self.update_pick_list_status() if not self.is_return: self.update_billing_status_for_zero_amount_refdoc("Delivery Note") @@ -614,6 +629,7 @@ class SalesInvoice(SellingController): if self.update_stock == 1: self.update_stock_reservation_entries() self.repost_future_sle_and_gle() + self.update_pick_list_status() self.db_set("status", "Cancelled") @@ -665,26 +681,41 @@ class SalesInvoice(SellingController): if not cint(self.update_stock): return - self.status_updater.append( - { - "source_dt": "Sales Invoice Item", - "target_dt": "Sales Order Item", - "target_parent_dt": "Sales Order", - "target_parent_field": "per_delivered", - "target_field": "delivered_qty", - "target_ref_field": "qty", - "source_field": "qty", - "join_field": "so_detail", - "percent_join_field": "sales_order", - "status_field": "delivery_status", - "keyword": "Delivered", - "second_source_dt": "Delivery Note Item", - "second_source_field": "qty", - "second_join_field": "so_detail", - "overflow_type": "delivery", - "extra_cond": """ and exists(select name from `tabSales Invoice` - where name=`tabSales Invoice Item`.parent and update_stock = 1)""", - } + self.status_updater.extend( + [ + { + "source_dt": "Sales Invoice Item", + "target_dt": "Sales Order Item", + "target_parent_dt": "Sales Order", + "target_parent_field": "per_delivered", + "target_field": "delivered_qty", + "target_ref_field": "qty", + "source_field": "qty", + "join_field": "so_detail", + "percent_join_field": "sales_order", + "status_field": "delivery_status", + "keyword": "Delivered", + "second_source_dt": "Delivery Note Item", + "second_source_field": "qty", + "second_join_field": "so_detail", + "overflow_type": "delivery", + "extra_cond": """ and exists(select name from `tabSales Invoice` + where name=`tabSales Invoice Item`.parent and update_stock = 1)""", + }, + { + "source_dt": "Sales Invoice Item", + "target_dt": "Pick List Item", + "join_field": "pick_list_item", + "target_field": "delivered_qty", + "target_parent_dt": "Pick List", + "target_parent_field": "per_delivered", + "target_ref_field": "picked_qty", + "source_field": "stock_qty", + "percent_join_field": "against_pick_list", + "status_field": "delivery_status", + "keyword": "Delivered", + }, + ] ) if not cint(self.is_return): diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json index c9ed9d3f15d..5f1e0b1444b 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json @@ -104,6 +104,7 @@ "sales_order", "so_detail", "sales_invoice_item", + "pick_list_item", "column_break_74", "delivery_note", "dn_detail", @@ -112,6 +113,7 @@ "pos_invoice", "pos_invoice_item", "scio_detail", + "against_pick_list", "internal_transfer_section", "purchase_order", "column_break_92", @@ -855,8 +857,8 @@ "fieldtype": "Currency", "label": "Rate of Stock UOM", "no_copy": 1, - "print_hide": 1, "options": "currency", + "print_hide": 1, "read_only": 1 }, { @@ -1011,13 +1013,30 @@ "label": "Consider for Tax Withholding", "print_hide": 1, "read_only": 1 + }, + { + "fieldname": "against_pick_list", + "fieldtype": "Link", + "hidden": 1, + "label": "Against Pick List", + "no_copy": 1, + "options": "Pick List", + "read_only": 1 + }, + { + "fieldname": "pick_list_item", + "fieldtype": "Data", + "hidden": 1, + "label": "Pick List Item", + "no_copy": 1, + "read_only": 1 } ], "grid_page_length": 50, "idx": 1, "istable": 1, "links": [], - "modified": "2026-05-29 12:23:28.259905", + "modified": "2026-06-03 13:17:36.145788", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Item", diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py index 2b444d760b7..12bf334af25 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py @@ -22,6 +22,7 @@ class SalesInvoiceItem(Document): actual_batch_qty: DF.Float actual_qty: DF.Float + against_pick_list: DF.Link | None allow_zero_valuation_rate: DF.Check amount: DF.Currency apply_tds: DF.Check @@ -72,6 +73,7 @@ class SalesInvoiceItem(Document): parent: DF.Data parentfield: DF.Data parenttype: DF.Data + pick_list_item: DF.Data | None pos_invoice: DF.Link | None pos_invoice_item: DF.Data | None price_list_rate: DF.Currency diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 33411702d45..f08e6fbdd07 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -1039,6 +1039,44 @@ class SellingController(StockController): qty_to_undelivered -= qty_can_be_undelivered + def set_serial_and_batch_bundle_from_pick_list(self): + from erpnext.stock.serial_batch_bundle import SerialBatchCreation + + for item in self.items: + if item.use_serial_batch_fields or not item.against_pick_list or not self.get("update_stock", 1): + continue + + if item.pick_list_item and not item.serial_and_batch_bundle: + filters = { + "item_code": item.item_code, + "voucher_type": "Pick List", + "voucher_no": item.against_pick_list, + "voucher_detail_no": item.pick_list_item, + } + + bundle_id = frappe.db.get_value("Serial and Batch Bundle", filters, "name") + + if bundle_id: + cls_obj = SerialBatchCreation( + { + "type_of_transaction": "Outward", + "serial_and_batch_bundle": bundle_id, + "item_code": item.get("item_code"), + "warehouse": item.get("warehouse"), + } + ) + + cls_obj.duplicate_package() + + item.serial_and_batch_bundle = cls_obj.serial_and_batch_bundle + + def update_pick_list_status(self): + from erpnext.stock.doctype.pick_list.pick_list import update_pick_list_status + + pick_lists = {row.against_pick_list for row in self.items if row.against_pick_list} + for pick_list in pick_lists: + update_pick_list_status(pick_list) + def set_default_income_account_for_item(obj): """Set income account as default for items in the transaction. diff --git a/erpnext/selling/doctype/sales_order/mapper.py b/erpnext/selling/doctype/sales_order/mapper.py new file mode 100644 index 00000000000..eb7201eb24d --- /dev/null +++ b/erpnext/selling/doctype/sales_order/mapper.py @@ -0,0 +1,1092 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import json + +import frappe +from frappe import _ +from frappe.contacts.doctype.address.address import get_company_address +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc +from frappe.model.utils import get_fetch_values +from frappe.query_builder.functions import Sum +from frappe.utils import add_days, cint, flt, nowdate, strip_html + +from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_party_account +from erpnext.manufacturing.doctype.production_plan.production_plan import ( + get_items_for_material_requests, + get_sales_orders, +) +from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults +from erpnext.stock.doctype.item.item import get_item_defaults +from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle, make_packing_list +from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_sre_details_for_voucher, + get_sre_reserved_qty_details_for_voucher, + get_ssb_bundle_for_voucher, +) +from erpnext.stock.get_item_details import ItemDetailsCtx, get_bin_details, get_price_list_rate + + +def get_requested_item_qty(sales_order: str) -> dict: + result = {} + + so = frappe.get_doc("Sales Order", sales_order) + + for item in so.items: + if is_product_bundle(item.item_code): + for packed_item in so.get("packed_items"): + if ( + packed_item.parent_item == item.item_code + and packed_item.parent_detail_docname == item.name + ): + result[packed_item.name] = frappe._dict({"qty": packed_item.requested_qty}) + else: + result[item.name] = frappe._dict({"qty": item.requested_qty}) + + return result + + +@frappe.whitelist() +def make_material_request(source_name: str, target_doc: str | Document | None = None): + requested_item_qty = get_requested_item_qty(source_name) + + def postprocess(source, target): + if source.tc_name and frappe.db.get_value("Terms and Conditions", source.tc_name, "buying") != 1: + target.tc_name = None + target.terms = None + + def get_remaining_qty(so_item): + return flt( + flt(so_item.qty) + - flt(requested_item_qty.get(so_item.name, {}).get("qty")) + - max( + flt(so_item.get("delivered_qty")), + 0, + ) + ) + + def get_remaining_packed_item_qty(so_item): + delivered_qty = frappe.db.get_value( + "Sales Order Item", {"name": so_item.parent_detail_docname}, ["delivered_qty"] + ) + + bundle_item_qty = frappe.db.get_value( + "Product Bundle Item", {"parent": so_item.parent_item, "item_code": so_item.item_code}, ["qty"] + ) + + return flt( + flt(so_item.qty) + - flt(requested_item_qty.get(so_item.name, {}).get("qty")) + - max( + flt(delivered_qty) * flt(bundle_item_qty), + 0, + ) + ) + + def update_item(source, target, source_parent): + # qty is for packed items, because packed items don't have stock_qty field + target.project = source_parent.project + target.qty = ( + get_remaining_packed_item_qty(source) + if source.parentfield == "packed_items" + else get_remaining_qty(source) + ) + target.stock_qty = flt(target.qty) * flt(target.conversion_factor) + target.actual_qty = get_bin_details( + target.item_code, target.warehouse, source_parent.company, True + ).get("actual_qty", 0) + + ctx = ItemDetailsCtx(target.as_dict().copy()) + ctx.update( + { + "company": source_parent.get("company"), + "price_list": frappe.db.get_single_value("Buying Settings", "buying_price_list"), + "currency": source_parent.get("currency"), + "conversion_rate": source_parent.get("conversion_rate"), + } + ) + + target.rate = flt( + get_price_list_rate(ctx, item_doc=frappe.get_cached_doc("Item", target.item_code)).get( + "price_list_rate" + ) + ) + target.amount = target.qty * target.rate + + doc = get_mapped_doc( + "Sales Order", + source_name, + { + "Sales Order": {"doctype": "Material Request", "validation": {"docstatus": ["=", 1]}}, + "Packed Item": { + "doctype": "Material Request Item", + "field_map": {"parent": "sales_order", "uom": "stock_uom", "name": "packed_item"}, + "condition": lambda item: get_remaining_packed_item_qty(item) > 0, + "postprocess": update_item, + }, + "Sales Order Item": { + "doctype": "Material Request Item", + "field_map": { + "name": "sales_order_item", + "parent": "sales_order", + "delivery_date": "schedule_date", + "bom_no": "bom_no", + }, + "condition": lambda item: not frappe.db.exists( + "Product Bundle", {"name": item.item_code, "disabled": 0} + ) + and get_remaining_qty(item) > 0, + "postprocess": update_item, + }, + }, + target_doc, + postprocess, + ) + if doc and doc.items: + return doc + else: + frappe.throw(_("Material Request already created for the ordered quantity")) + + +@frappe.whitelist() +def make_project(source_name: str, target_doc: str | Document | None = None): + def postprocess(source, doc): + doc.project_type = "External" + doc.project_name = source.name + + doc = get_mapped_doc( + "Sales Order", + source_name, + { + "Sales Order": { + "doctype": "Project", + "validation": {"docstatus": ["=", 1]}, + "field_map": { + "name": "sales_order", + "base_grand_total": "estimated_costing", + "net_total": "total_sales_amount", + }, + }, + }, + target_doc, + postprocess, + ) + + return doc + + +def set_serial_batch_for_bundle_reservation(source, target, use_serial_batch_fields, packed_sre): + for item in source.packed_items: + target_item = next( + ( + d + for d in target.packed_items + if (d.parent_item, d.item_code, d.warehouse) + == (item.parent_item, item.item_code, item.warehouse) + ), + None, + ) + if target_item and (sre := [sre for sre in packed_sre if sre.voucher_detail_no == item.name]): + if sre[0].reservation_based_on == "Serial and Batch": + qty = 0 + serial_nos = [] + batch_nos = [] + if use_serial_batch_fields: + target_item.use_serial_batch_fields = 1 + for item in sre: + qty += item.reserved_qty + if item.has_serial_no: + serial_nos.extend( + frappe.get_all( + "Serial and Batch Entry", + filters={"parent": item.name}, + pluck="serial_no", + ) + ) + if item.has_batch_no: + batch_nos.extend( + frappe.get_all( + "Serial and Batch Entry", + filters={"parent": item.name}, + pluck="batch_no", + ) + ) + + if len(batch_nos) == 1: + target_item.batch_no = batch_nos[0] if batch_nos else None + if serial_nos and len(batch_nos) < 2: + target_item.serial_no = "\n".join(serial_nos) + + if not use_serial_batch_fields or len(batch_nos) > 1: + target_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher(sre).name + + +@frappe.whitelist() +def make_delivery_note( + source_name: str, target_doc: str | Document | None = None, kwargs: dict | None = None +): + if not kwargs: + kwargs = { + "for_reserved_stock": frappe.flags.args and frappe.flags.args.for_reserved_stock, + "skip_item_mapping": frappe.flags.args and frappe.flags.args.skip_item_mapping, + } + + kwargs = frappe._dict(kwargs) + + sre_details = {} + if kwargs.for_reserved_stock: + sre_details = get_sre_reserved_qty_details_for_voucher("Sales Order", source_name) + + mapper = { + "Sales Order": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}}, + "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True}, + "Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, + } + + # 0 qty is accepted, as the qty is uncertain for some items + has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items") + use_serial_batch_fields = frappe.get_single_value("Stock Settings", "use_serial_batch_fields") + + def is_unit_price_row(source): + return has_unit_price_items and source.qty == 0 + + 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 + + def set_missing_values(source, target): + if kwargs.get("ignore_pricing_rule"): + # Skip pricing rule when the dn is creating from the pick list + target.ignore_pricing_rule = 1 + + target.run_method("set_missing_values") + target.run_method("set_po_nos") + target.run_method("calculate_taxes_and_totals") + target.run_method("set_use_serial_batch_fields") + + if source.company_address: + target.update({"company_address": source.company_address}) + else: + # set company address + target.update(get_company_address(target.company)) + + if target.company_address: + target.update(get_fetch_values("Delivery Note", "company_address", target.company_address)) + + # if invoked in bulk creation, validations are ignored and thus this method is nerver invoked + if frappe.flags.bulk_transaction: + # set target items names to ensure proper linking with packed_items + target.set_new_name() + + make_packing_list(target) + + def condition(doc): + if doc.name in sre_details: + del sre_details[doc.name] + return False + + # make_mapped_doc sets js `args` into `frappe.flags.args` + if frappe.flags.args and frappe.flags.args.delivery_dates: + if frappe.utils.cstr(doc.delivery_date) not in frappe.flags.args.delivery_dates: + return False + if frappe.flags.args and frappe.flags.args.until_delivery_date: + if frappe.utils.cstr(doc.delivery_date) > frappe.flags.args.until_delivery_date: + return False + + return ( + (abs(doc.delivered_qty) < abs(doc.qty)) or is_unit_price_row(doc) + ) and doc.delivered_by_supplier != 1 + + def update_item(source, target, source_parent): + target.base_amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.base_rate) + target.amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.rate) + target.qty = ( + flt(source.qty) if is_unit_price_row(source) else flt(source.qty) - flt(source.delivered_qty) + ) + + item = get_item_defaults(target.item_code, source_parent.company) + item_group = get_item_group_defaults(target.item_code, source_parent.company) + + if item: + target.cost_center = ( + frappe.db.get_value("Project", source_parent.project, "cost_center") + or item.get("buying_cost_center") + or item_group.get("buying_cost_center") + ) + + if not kwargs.skip_item_mapping: + mapper["Sales Order Item"] = { + "doctype": "Delivery Note Item", + "field_map": { + "rate": "rate", + "name": "so_detail", + "parent": "against_sales_order", + }, + "condition": lambda d: condition(d) and select_item(d), + "postprocess": update_item, + } + + so = frappe.get_doc("Sales Order", source_name) + target_doc = get_mapped_doc("Sales Order", so.name, mapper, target_doc) + + packed_sre = [] + if not kwargs.skip_item_mapping and kwargs.for_reserved_stock: + sre_list = get_sre_details_for_voucher("Sales Order", source_name) + + if sre_list: + + def update_dn_item(source, target, source_parent): + update_item(source, target, so) + + so_items = {d.name: d for d in so.items if d.stock_reserved_qty} + + for sre in sre_list: + if not so_items.get(sre.voucher_detail_no): + packed_sre.append(sre) + continue + + if not condition(so_items[sre.voucher_detail_no]): + continue + + dn_item = get_mapped_doc( + "Sales Order Item", + sre.voucher_detail_no, + { + "Sales Order Item": { + "doctype": "Delivery Note Item", + "field_map": { + "rate": "rate", + "name": "so_detail", + "parent": "against_sales_order", + }, + "postprocess": update_dn_item, + } + }, + ignore_permissions=True, + ) + + dn_item.qty = flt(sre.reserved_qty) / flt(dn_item.get("conversion_factor", 1)) + dn_item.warehouse = sre.warehouse + + if ( + not use_serial_batch_fields + and sre.reservation_based_on == "Serial and Batch" + and (sre.has_serial_no or sre.has_batch_no) + ): + dn_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher([sre]).name + + target_doc.append("items", dn_item) + # Correct rows index. + for idx, item in enumerate(target_doc.items): + item.idx = idx + 1 + + if not kwargs.skip_item_mapping and frappe.flags.bulk_transaction and not target_doc.items: + # the (date) condition filter resulted in an unintendedly created empty DN; remove it + del target_doc + return + + # Should be called after mapping items. + target_doc.packed_items = [] + set_missing_values(so, target_doc) + set_serial_batch_for_bundle_reservation(so, target_doc, use_serial_batch_fields, packed_sre) + + return target_doc + + +@frappe.whitelist() +def make_sales_invoice( + source_name: str, + target_doc: str | Document | None = None, + ignore_permissions: bool = False, + args: str | dict | None = None, +): + if args is None: + args = {} + if isinstance(args, str): + args = json.loads(args) + + # 0 qty is accepted, as the qty is uncertain for some items + has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items") + + def is_unit_price_row(source): + return has_unit_price_items and source.qty == 0 + + def postprocess(source, target): + set_missing_values(source, target) + # Get the advance paid Journal Entries in Sales Invoice Advance + if target.get("allocate_advances_automatically"): + target.set_advances() + + make_packing_list(target) + set_serial_batch_for_bundle_reservation( + source, + target, + frappe.get_single_value("Stock Settings", "use_serial_batch_fields"), + get_sre_details_for_voucher("Sales Order", source_name), + ) + + def set_missing_values(source, target): + target.flags.ignore_permissions = True + target.run_method("set_missing_values") + target.run_method("set_po_nos") + target.run_method("calculate_taxes_and_totals") + target.run_method("set_use_serial_batch_fields") + + if source.company_address: + target.update({"company_address": source.company_address}) + else: + # set company address + target.update(get_company_address(target.company)) + + if target.company_address: + target.update(get_fetch_values("Sales Invoice", "company_address", target.company_address)) + + # set the redeem loyalty points if provided via shopping cart + if source.loyalty_points and source.order_type == "Shopping Cart": + target.redeem_loyalty_points = 1 + target.loyalty_points = source.loyalty_points + + target.debit_to = get_party_account("Customer", source.customer, source.company) + + def update_item(source, target, source_parent): + def get_billed_qty(so_item_name): + table = frappe.qb.DocType("Sales Invoice Item") + query = ( + frappe.qb.from_(table) + .select(Sum(table.qty).as_("qty")) + .where((table.docstatus == 1) & (table.so_detail == so_item_name)) + ) + return query.run(pluck="qty")[0] or 0 + + if source_parent.has_unit_price_items: + # 0 Amount rows (as seen in Unit Price Items) should be mapped as it is + pending_amount = flt(source.amount) - flt(source.billed_amt) + target.amount = pending_amount if flt(source.amount) else 0 + else: + target.amount = flt(source.amount) - flt(source.billed_amt) + + target.base_amount = target.amount * flt(source_parent.conversion_rate) + target.qty = ( + source.qty - get_billed_qty(source.name) + if (source.qty and source.billed_amt) + else (source.qty if is_unit_price_row(source) else source.qty - source.returned_qty) + ) + + if source_parent.project: + target.cost_center = frappe.db.get_value("Project", source_parent.project, "cost_center") + if target.item_code: + item = get_item_defaults(target.item_code, source_parent.company) + item_group = get_item_group_defaults(target.item_code, source_parent.company) + cost_center = item.get("selling_cost_center") or item_group.get("selling_cost_center") + + if cost_center: + target.cost_center = cost_center + + def select_item(d): + filtered_items = args.get("filtered_children", []) + child_filter = d.name in filtered_items if filtered_items else True + return child_filter + + def add_self_rm(doclist): + parent = frappe.qb.DocType("Subcontracting Inward Order") + child = frappe.qb.DocType("Subcontracting Inward Order Received Item") + query = ( + frappe.qb.from_(parent) + .join(child) + .on(parent.name == child.parent) + .select( + child.required_qty, + child.consumed_qty, + child.billed_qty, + child.rm_item_code, + child.stock_uom, + child.name, + ) + .where( + (parent.docstatus == 1) + & (parent.sales_order == source_name) + & (child.is_customer_provided_item == 0) + ) + ) + result = query.run(as_dict=True) + + if result: + idx = len(doclist.items) + 1 + for item in result: + if (qty := max(item.required_qty, item.consumed_qty) - item.billed_qty) > 0: + doclist.append( + "items", + { + "item_code": item.rm_item_code, + "qty": qty, + "uom": item.stock_uom, + "scio_detail": item.name, + }, + ) + doclist.process_item_selection(idx) + idx += 1 + doclist.has_subcontracted = 1 + + doclist = get_mapped_doc( + "Sales Order", + source_name, + { + "Sales Order": { + "doctype": "Sales Invoice", + "field_map": { + "party_account_currency": "party_account_currency", + }, + "field_no_map": ["payment_terms_template"], + "validation": {"docstatus": ["=", 1]}, + }, + "Sales Order Item": { + "doctype": "Sales Invoice Item", + "field_map": { + "name": "so_detail", + "parent": "sales_order", + }, + "postprocess": update_item, + "condition": lambda doc: ( + True + if is_unit_price_row(doc) + else (doc.qty and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount))) + ) + and select_item(doc) + and not args.get("skip_item_mapping"), + }, + "Sales Taxes and Charges": { + "doctype": "Sales Taxes and Charges", + "reset_value": True, + }, + "Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, + }, + target_doc, + postprocess, + ignore_permissions=ignore_permissions, + ) + + if frappe.get_cached_value("Sales Order", source_name, "is_subcontracted"): + add_self_rm(doclist) + + automatically_fetch_payment_terms = cint( + frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms") + ) + if automatically_fetch_payment_terms: + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + PaymentScheduleService(doclist).set_payment_schedule() + + return doclist + + +@frappe.whitelist() +def make_maintenance_schedule(source_name: str, target_doc: str | Document | None = None): + maint_schedule = frappe.db.exists( + "Maintenance Schedule Item", {"sales_order": source_name, "docstatus": 1} + ) + + if not maint_schedule: + doclist = get_mapped_doc( + "Sales Order", + source_name, + { + "Sales Order": {"doctype": "Maintenance Schedule", "validation": {"docstatus": ["=", 1]}}, + "Sales Order Item": { + "doctype": "Maintenance Schedule Item", + "field_map": {"parent": "sales_order"}, + }, + }, + target_doc, + ) + + return doclist + + +@frappe.whitelist() +def make_maintenance_visit(source_name: str, target_doc: str | Document | None = None): + MaintenanceVisit = frappe.qb.DocType("Maintenance Visit") + MaintenanceVisitPurpose = frappe.qb.DocType("Maintenance Visit Purpose") + + query = ( + frappe.qb.from_(MaintenanceVisit) + .join(MaintenanceVisitPurpose) + .on(MaintenanceVisitPurpose.parent == MaintenanceVisit.name) + .select(MaintenanceVisit.name) + .where(MaintenanceVisitPurpose.prevdoc_docname == source_name) + .where(MaintenanceVisit.docstatus == 1) + .where(MaintenanceVisit.completion_status == "Fully Completed") + ) + + if not query.run(): + doclist = get_mapped_doc( + "Sales Order", + source_name, + { + "Sales Order": {"doctype": "Maintenance Visit", "validation": {"docstatus": ["=", 1]}}, + "Sales Order Item": { + "doctype": "Maintenance Visit Purpose", + "field_map": {"parent": "prevdoc_docname", "parenttype": "prevdoc_doctype"}, + }, + }, + target_doc, + ) + + return doclist + + +@frappe.whitelist() +def make_purchase_order( + source_name: str, selected_items: str | list | None = None, target_doc: str | Document | None = None +): + """Creates Purchase Order for each Supplier. Returns a list of doc objects.""" + + from erpnext.setup.utils import get_exchange_rate + + if not selected_items: + return + + if isinstance(selected_items, str): + selected_items = json.loads(selected_items) + + def set_missing_values(source, target): + target.supplier = supplier + company_currency = frappe.db.get_value( + "Company", filters={"name": target.company}, fieldname=["default_currency"] + ) + supplier_currency = frappe.db.get_value( + "Supplier", filters={"name": supplier}, fieldname=["default_currency"] + ) + + target.currency = supplier_currency if supplier_currency else company_currency + + target.conversion_rate = get_exchange_rate(target.currency, company_currency, args="for_buying") + + target.apply_discount_on = "" + target.additional_discount_percentage = 0.0 + target.discount_amount = 0.0 + target.inter_company_order_reference = "" + target.shipping_rule = "" + target.tc_name = "" + target.terms = "" + target.payment_schedule = [] + + default_price_list = frappe.get_value("Supplier", supplier, "default_price_list") + if default_price_list: + target.buying_price_list = default_price_list + + default_payment_terms = frappe.get_value("Supplier", supplier, "payment_terms") + if default_payment_terms: + target.payment_terms_template = default_payment_terms + + if any(item.delivered_by_supplier for item in target.items): + if source.shipping_address_name: + target.shipping_address = source.shipping_address_name + target.shipping_address_display = source.shipping_address + else: + target.shipping_address = source.customer_address + target.shipping_address_display = source.address_display + + target.customer_contact_person = source.contact_person + target.customer_contact_display = source.contact_display + target.customer_contact_mobile = source.contact_mobile + target.customer_contact_email = source.contact_email + + else: + target.customer = "" + target.customer_name = "" + + target.run_method("set_missing_values") + target.run_method("calculate_taxes_and_totals") + + def update_item(source, target, source_parent): + target.schedule_date = source.delivery_date + target.qty = flt(source.qty) - (flt(source.ordered_qty) / flt(source.conversion_factor)) + target.stock_qty = flt(source.stock_qty) - flt(source.ordered_qty) + target.project = source_parent.project + + def update_item_for_packed_item(source, target, _): + target.qty = flt(source.qty) - flt(source.ordered_qty) + + def filter_items(item, supplier): + if ( + item.ordered_qty < item.stock_qty + and not is_product_bundle(item.item_code) + and items_to_map.get(item.item_code) == supplier + ): + return True + + return False + + items_to_map = { + item.get("item_code"): item.get("supplier") for item in selected_items if item.get("item_code") + } + item_codes = list(set(items_to_map.keys())) + suppliers = list(set(items_to_map.values())) + + if not suppliers: + suppliers = [None] + + purchase_orders = [] + for supplier in suppliers: + doc = get_mapped_doc( + "Sales Order", + source_name, + { + "Sales Order": { + "doctype": "Purchase Order", + "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP], + "validation": {"docstatus": ["=", 1]}, + }, + "Sales Order Item": { + "doctype": "Purchase Order Item", + "field_map": [ + ["name", "sales_order_item"], + ["parent", "sales_order"], + ["stock_uom", "stock_uom"], + ["uom", "uom"], + ["conversion_factor", "conversion_factor"], + ["delivery_date", "schedule_date"], + ], + "field_no_map": [ + "rate", + "price_list_rate", + "item_tax_template", + "discount_percentage", + "discount_amount", + "pricing_rules", + "margin_type", + "margin_rate_or_amount", + ], + "postprocess": update_item, + "condition": lambda doc, s=supplier: filter_items(doc, s), + }, + "Packed Item": { + "doctype": "Purchase Order Item", + "field_map": [ + ["name", "sales_order_packed_item"], + ["parent", "sales_order"], + ["uom", "uom"], + ["conversion_factor", "conversion_factor"], + ["parent_item", "product_bundle"], + ["rate", "rate"], + ], + "field_no_map": [ + "price_list_rate", + "item_tax_template", + "discount_percentage", + "discount_amount", + "supplier", + "pricing_rules", + ], + "postprocess": update_item_for_packed_item, + "condition": lambda doc: doc.parent_item in item_codes + and flt(doc.ordered_qty) < flt(doc.qty), + }, + }, + target_doc, + set_missing_values, + ) + + set_delivery_date(doc.items, source_name) + if doc.supplier: + doc.insert() + purchase_orders.append(doc) + + return purchase_orders + + +def set_delivery_date(items: list, sales_order: str) -> None: + delivery_dates = frappe.get_all( + "Sales Order Item", filters={"parent": sales_order}, fields=["delivery_date", "item_code"] + ) + + delivery_by_item = frappe._dict() + for date in delivery_dates: + delivery_by_item[date.item_code] = date.delivery_date + + for item in items: + if item.product_bundle: + item.schedule_date = delivery_by_item[item.product_bundle] + + +@frappe.whitelist() +def make_work_orders(items: str, sales_order: str, company: str, project: str | None = None): + """Make Work Orders against the given Sales Order for the given `items`""" + items = json.loads(items).get("items") + out = [] + + for i in items: + if not i.get("bom"): + frappe.throw(_("Please select BOM against item {0}").format(i.get("item_code"))) + if not i.get("pending_qty"): + frappe.throw(_("Please select Qty against item {0}").format(i.get("item_code"))) + + work_order = frappe.get_doc( + doctype="Work Order", + production_item=i["item_code"], + bom_no=i.get("bom"), + qty=i["pending_qty"], + company=company, + sales_order=sales_order, + sales_order_item=i["sales_order_item"], + project=project, + fg_warehouse=i["warehouse"], + description=i["description"], + ).insert() + work_order.set_work_order_operations() + work_order.flags.ignore_mandatory = True + work_order.save() + out.append(work_order) + + return [p.name for p in out] + + +@frappe.whitelist() +def make_production_plan(source_name: str, target_doc: str | Document | None = None): + sales_order = frappe.get_doc("Sales Order", source_name) + + production_plan = frappe.new_doc( + "Production Plan", + company=sales_order.company, + get_items_from="Sales Order", + posting_date=nowdate(), + ) + + open_so = [data.name for data in get_sales_orders(production_plan)] + if sales_order.name not in open_so: + frappe.throw(_("Sales Order {0} is not available for production").format(sales_order.name)) + + production_plan.append( + "sales_orders", + { + "sales_order": sales_order.name, + "sales_order_date": sales_order.transaction_date, + "customer": sales_order.customer, + "grand_total": sales_order.base_grand_total, + }, + ) + production_plan.get_items() + if not production_plan.get("po_items"): + frappe.throw(_("Sales Order {0} is not available for production").format(sales_order.name)) + + return production_plan + + +@frappe.whitelist() +def make_raw_material_request( + items: str | frappe._dict, company: str, sales_order: str, project: str | None = None +): + if not frappe.has_permission("Sales Order", "write"): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + if isinstance(items, str): + items = frappe._dict(json.loads(items)) + + for item in items.get("items"): + item["include_exploded_items"] = items.get("include_exploded_items") + item["ignore_existing_ordered_qty"] = items.get("ignore_existing_ordered_qty") + item["include_raw_materials_from_sales_order"] = items.get("include_raw_materials_from_sales_order") + + items.update({"company": company, "sales_order": sales_order}) + + item_wh = {} + for item in items.get("items"): + if item.get("warehouse"): + item_wh[item.get("item_code")] = item.get("warehouse") + + raw_materials = get_items_for_material_requests(items) + if not raw_materials: + frappe.msgprint(_("Material Request not created, as quantity for Raw Materials already available.")) + return + + material_request = frappe.new_doc("Material Request") + material_request.update( + dict( + doctype="Material Request", + transaction_date=nowdate(), + company=company, + material_request_type="Purchase", + ) + ) + for item in raw_materials: + item_doc = frappe.get_cached_doc("Item", item.get("item_code")) + + schedule_date = add_days(nowdate(), cint(item_doc.lead_time_days)) + row = material_request.append( + "items", + { + "item_code": item.get("item_code"), + "qty": item.get("quantity"), + "schedule_date": schedule_date, + "warehouse": item_wh.get(item.get("main_bom_item")) or item.get("warehouse"), + "sales_order": sales_order, + "project": project, + }, + ) + + if not (strip_html(item.get("description")) and strip_html(item_doc.description)): + row.description = item_doc.item_name or item.get("item_code") + + material_request.insert() + material_request.flags.ignore_permissions = 1 + material_request.run_method("set_missing_values") + material_request.submit() + return material_request + + +@frappe.whitelist() +def make_inter_company_purchase_order(source_name: str, target_doc: str | Document | None = None): + from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_transaction + + return make_inter_company_transaction("Sales Order", source_name, target_doc) + + +@frappe.whitelist() +def create_pick_list(source_name: str, target_doc: str | Document | None = None): + def validate_sales_order(): + so = frappe.get_doc("Sales Order", source_name) + for item in so.items: + if item.stock_reserved_qty > 0: + frappe.throw( + _( + "Cannot create a pick list for Sales Order {0} because it has reserved stock. Please unreserve the stock in order to create a pick list." + ).format(frappe.bold(source_name)) + ) + + def update_item_quantity(source, target, source_parent) -> None: + picked_qty = flt(source.picked_qty) / (flt(source.conversion_factor) or 1) + qty_to_be_picked = flt(source.qty) - max(picked_qty, flt(source.delivered_qty)) + + target.qty = qty_to_be_picked + target.stock_qty = qty_to_be_picked * flt(source.conversion_factor) + + # update available qty + bin_details = get_bin_details(source.item_code, source.warehouse, source_parent.company) + target.actual_qty = bin_details.get("actual_qty") + target.company_total_stock = bin_details.get("company_total_stock") + + def update_packed_item_qty(source, target, source_parent) -> None: + qty = flt(source.qty) + for item in source_parent.items: + if source.parent_detail_docname == item.name: + picked_qty = flt(item.picked_qty) / (flt(item.conversion_factor) or 1) + pending_percent = (item.qty - max(picked_qty, item.delivered_qty)) / item.qty + target.qty = target.stock_qty = qty * pending_percent + return + + def should_pick_order_item(item) -> bool: + return ( + abs(item.delivered_qty) < abs(item.qty) + and item.delivered_by_supplier != 1 + and not is_product_bundle(item.item_code) + ) + + # Don't allow a Pick List to be created against a Sales Order that has reserved stock. + validate_sales_order() + + doc = get_mapped_doc( + "Sales Order", + source_name, + { + "Sales Order": { + "doctype": "Pick List", + "field_map": {"set_warehouse": "parent_warehouse"}, + "validation": {"docstatus": ["=", 1]}, + }, + "Sales Order Item": { + "doctype": "Pick List Item", + "field_map": {"parent": "sales_order", "name": "sales_order_item"}, + "postprocess": update_item_quantity, + "condition": should_pick_order_item, + }, + "Packed Item": { + "doctype": "Pick List Item", + "field_map": { + "parent": "sales_order", + "parent_detail_docname": "sales_order_item", + "name": "product_bundle_item", + }, + "field_no_map": ["picked_qty"], + "postprocess": update_packed_item_qty, + }, + }, + target_doc, + ) + + doc.purpose = "Delivery" + + doc.set_item_locations() + + return doc + + +@frappe.whitelist() +def make_subcontracting_inward_order(source_name: str, target_doc: str | Document | None = None): + if not is_so_fully_subcontracted(source_name): + return get_mapped_subcontracting_inward_order(source_name, target_doc) + else: + frappe.throw(_("This Sales Order has been fully subcontracted.")) + + +def is_so_fully_subcontracted(so_name: str) -> bool: + table = frappe.qb.DocType("Sales Order Item") + query = ( + frappe.qb.from_(table) + .select(table.name) + .where((table.parent == so_name) & (table.qty != table.subcontracted_qty)) + ) + return not query.run(as_dict=True) + + +def get_mapped_subcontracting_inward_order( + source_name: str, target_doc: str | Document | None = None +) -> Document: + def post_process(source_doc, target_doc): + if ( + frappe.db.count( + "Warehouse", {"customer": source_doc.customer, "disabled": 0, "is_rejected_warehouse": 0} + ) + == 1 + ): + target_doc.customer_warehouse = frappe.get_cached_value( + "Warehouse", + {"customer": source_doc.customer, "disabled": 0, "is_rejected_warehouse": 0}, + "name", + ) + target_doc.populate_items_table() + + if target_doc and isinstance(target_doc, str): + target_doc = json.loads(target_doc) + for key in ["service_items", "items", "received_items"]: + if key in target_doc: + del target_doc[key] + target_doc = json.dumps(target_doc) + + target_doc = get_mapped_doc( + "Sales Order", + source_name, + { + "Sales Order": { + "doctype": "Subcontracting Inward Order", + "field_map": {}, + "field_no_map": ["total_qty", "total", "net_total"], + "validation": { + "docstatus": ["=", 1], + }, + }, + "Sales Order Item": { + "doctype": "Subcontracting Inward Order Service Item", + "field_map": { + "name": "sales_order_item", + }, + "field_no_map": ["qty", "fg_item_qty", "amount"], + "condition": lambda item: item.qty != item.subcontracted_qty, + }, + }, + target_doc, + post_process, + ) + + return target_doc diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index f0e44ed368d..0be8c8f864d 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -356,37 +356,6 @@ class DeliveryNote(SellingController): ] ) - def set_serial_and_batch_bundle_from_pick_list(self): - from erpnext.stock.serial_batch_bundle import SerialBatchCreation - - for item in self.items: - if item.use_serial_batch_fields or not item.against_pick_list: - continue - - if item.pick_list_item and not item.serial_and_batch_bundle: - filters = { - "item_code": item.item_code, - "voucher_type": "Pick List", - "voucher_no": item.against_pick_list, - "voucher_detail_no": item.pick_list_item, - } - - bundle_id = frappe.db.get_value("Serial and Batch Bundle", filters, "name") - - if bundle_id: - cls_obj = SerialBatchCreation( - { - "type_of_transaction": "Outward", - "serial_and_batch_bundle": bundle_id, - "item_code": item.get("item_code"), - "warehouse": item.get("warehouse"), - } - ) - - cls_obj.duplicate_package() - - item.serial_and_batch_bundle = cls_obj.serial_and_batch_bundle - def validate_references(self): self.validate_sales_order_references() self.validate_sales_invoice_references() @@ -617,13 +586,6 @@ class DeliveryNote(SellingController): ) ) - def update_pick_list_status(self): - from erpnext.stock.doctype.pick_list.pick_list import update_pick_list_status - - pick_lists = {row.against_pick_list for row in self.items if row.against_pick_list} - for pick_list in pick_lists: - update_pick_list_status(pick_list) - def check_next_docstatus(self): submit_rv = frappe.db.sql( """select t1.name diff --git a/erpnext/stock/doctype/pick_list/mapper.py b/erpnext/stock/doctype/pick_list/mapper.py new file mode 100644 index 00000000000..0ee754ce2ed --- /dev/null +++ b/erpnext/stock/doctype/pick_list/mapper.py @@ -0,0 +1,415 @@ +# 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 = {} + if isinstance(kwargs, str): + kwargs = json.loads(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): + pick_list = frappe.get_doc(json.loads(pick_list)) + validate_item_locations(pick_list) + + if stock_entry_exists(pick_list.get("name")): + return frappe.msgprint(_("Stock Entry has been already created against this 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") + stock_entry.set_stock_entry_type() + + if 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_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_work_order(pick_list, stock_entry): + work_order = frappe.get_doc("Work Order", pick_list.get("work_order")) + + 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 = pick_list.for_qty + 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: + 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: + 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: + item = frappe._dict() + update_common_item_properties(item, location) + + stock_entry.append("items", item) + + return stock_entry + + +def update_common_item_properties(item, location): + item.item_code = location.item_code + item.s_warehouse = location.warehouse + item.transfer_qty = location.picked_qty + item.qty = flt(location.picked_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 diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js index 750466a4a40..17736ced1f8 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.js +++ b/erpnext/stock/doctype/pick_list/pick_list.js @@ -135,7 +135,12 @@ frappe.ui.form.on("Pick List", { if (frm.doc.purpose === "Delivery") { frm.add_custom_button( __("Delivery Note"), - () => frm.trigger("create_delivery_note"), + () => frm.events.create_delivery(frm, "Delivery Note"), + __("Create") + ); + frm.add_custom_button( + __("Sales Invoice"), + () => frm.events.create_delivery(frm, "Sales Invoice"), __("Create") ); } else { @@ -232,9 +237,16 @@ frappe.ui.form.on("Pick List", { frm.clear_table("locations"); frm.trigger("add_get_items_button"); }, - create_delivery_note: (frm) => { + create_delivery(frm, doctype) { frappe.model.open_mapped_doc({ +<<<<<<< HEAD method: "erpnext.stock.doctype.pick_list.pick_list.create_delivery_note", +======= + method: "erpnext.stock.doctype.pick_list.mapper.create_delivery", + args: { + target: doctype, + }, +>>>>>>> d6a201ed4a (feat: create sales invoice from pick list (#55594)) frm: frm, }); }, diff --git a/erpnext/stock/doctype/pick_list/pick_list_dashboard.py b/erpnext/stock/doctype/pick_list/pick_list_dashboard.py index b6159d2d70c..c722db33d37 100644 --- a/erpnext/stock/doctype/pick_list/pick_list_dashboard.py +++ b/erpnext/stock/doctype/pick_list/pick_list_dashboard.py @@ -7,6 +7,7 @@ def get_data(): "non_standard_fieldnames": { "Stock Reservation Entry": "from_voucher_no", "Delivery Note": "against_pick_list", + "Sales Invoice": "against_pick_list", }, "internal_links": { "Sales Order": ["locations", "sales_order"], @@ -14,7 +15,7 @@ def get_data(): "transactions": [ { "label": _("Sales"), - "items": ["Sales Order", "Delivery Note"], + "items": ["Sales Order", "Delivery Note", "Sales Invoice"], }, { "label": _("Manufacturing"), diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 85a45f1686b..b3785ce0609 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -9,7 +9,15 @@ from erpnext.selling.doctype.sales_order.sales_order import create_pick_list from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import create_item, make_item from erpnext.stock.doctype.packed_item.test_packed_item import create_product_bundle +<<<<<<< HEAD from erpnext.stock.doctype.pick_list.pick_list import create_delivery_note, create_dn_for_pick_lists +======= +from erpnext.stock.doctype.pick_list.mapper import ( + create_delivery, + create_delivery_note, + create_dn_for_pick_lists, +) +>>>>>>> d6a201ed4a (feat: create sales invoice from pick list (#55594)) from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( get_batch_from_bundle, @@ -768,6 +776,198 @@ class TestPickList(ERPNextTestSuite): if dn_item.item_code == "_Test Item 2": self.assertEqual(dn_item.qty, 2) + @ERPNextTestSuite.change_settings("Stock Settings", {"use_serial_batch_fields": 1}) + def test_sales_invoice_from_pick_list_copies_old_batch_serial_fields(self): + warehouse = "_Test Warehouse - _TC" + item = make_item( + f"_Test PLSI Old Fields {frappe.generate_hash(length=8)}", + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "has_serial_no": 1, + "batch_number_series": f"PLSI-OLD-B-{frappe.generate_hash(length=6)}-.#####", + "serial_no_series": f"PLSI-OLD-S-{frappe.generate_hash(length=6)}-.#####", + }, + ).name + + make_stock_entry(item=item, to_warehouse=warehouse, qty=2, basic_rate=100) + sales_order = make_sales_order(item_code=item, warehouse=warehouse, qty=2, rate=100) + + pick_list = create_pick_list(sales_order.name) + pick_list.submit() + pick_list_item = pick_list.locations[0] + + self.assertTrue(pick_list_item.use_serial_batch_fields) + self.assertTrue(pick_list_item.batch_no) + self.assertTrue(pick_list_item.serial_no) + + sales_invoice = create_delivery(pick_list.name, target="Sales Invoice") + sales_invoice_item = sales_invoice.items[0] + + self.assertEqual(sales_invoice.update_stock, 1) + self.assertEqual(sales_invoice_item.against_pick_list, pick_list.name) + self.assertEqual(sales_invoice_item.pick_list_item, pick_list_item.name) + self.assertEqual(sales_invoice_item.use_serial_batch_fields, 1) + self.assertEqual(sales_invoice_item.batch_no, pick_list_item.batch_no) + self.assertEqual( + set(sales_invoice_item.serial_no.split("\n")), set(pick_list_item.serial_no.split("\n")) + ) + + @ERPNextTestSuite.change_settings("Stock Settings", {"use_serial_batch_fields": 0}) + def test_sales_invoice_from_pick_list_copies_serial_and_batch_bundle(self): + frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 0) + + warehouse = "_Test Warehouse - _TC" + item = make_item( + f"_Test PLSI Bundle {frappe.generate_hash(length=8)}", + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "has_serial_no": 1, + "batch_number_series": f"PLSI-BND-B-{frappe.generate_hash(length=6)}-.#####", + "serial_no_series": f"PLSI-BND-S-{frappe.generate_hash(length=6)}-.#####", + }, + ).name + + stock_entry = make_stock_entry(item=item, to_warehouse=warehouse, qty=2, basic_rate=100) + batch_no = get_batch_from_bundle(stock_entry.items[0].serial_and_batch_bundle) + serial_nos = get_serial_nos_from_bundle(stock_entry.items[0].serial_and_batch_bundle) + sales_order = make_sales_order(item_code=item, warehouse=warehouse, qty=2, rate=100) + + pick_list = frappe.get_doc( + { + "doctype": "Pick List", + "company": "_Test Company", + "customer": "_Test Customer", + "items_based_on": "Sales Order", + "purpose": "Delivery", + "pick_manually": 1, + "locations": [ + { + "item_code": item, + "warehouse": warehouse, + "qty": 2, + "stock_qty": 2, + "picked_qty": 2, + "conversion_factor": 1, + "sales_order": sales_order.name, + "sales_order_item": sales_order.items[0].name, + "use_serial_batch_fields": 0, + } + ], + } + ).insert() + + from erpnext.stock.serial_batch_bundle import SerialBatchCreation + + pick_list.locations[0].serial_and_batch_bundle = ( + SerialBatchCreation( + { + "item_code": item, + "warehouse": warehouse, + "voucher_type": "Pick List", + "voucher_no": pick_list.name, + "voucher_detail_no": pick_list.locations[0].name, + "qty": -2, + "batches": frappe._dict({batch_no: 2}), + "serial_nos": serial_nos, + "type_of_transaction": "Outward", + "company": "_Test Company", + "do_not_submit": True, + } + ) + .make_serial_and_batch_bundle() + .name + ) + pick_list.locations[0].db_set( + { + "use_serial_batch_fields": 0, + "batch_no": None, + "serial_no": None, + "serial_and_batch_bundle": pick_list.locations[0].serial_and_batch_bundle, + } + ) + pick_list.reload() + pick_list_item = pick_list.locations[0] + + self.assertFalse(pick_list_item.use_serial_batch_fields) + self.assertTrue(pick_list_item.serial_and_batch_bundle) + + sales_invoice = create_delivery(pick_list.name, target="Sales Invoice") + sales_invoice_item = sales_invoice.items[0] + + self.assertEqual(sales_invoice_item.against_pick_list, pick_list.name) + self.assertEqual(sales_invoice_item.pick_list_item, pick_list_item.name) + self.assertFalse(sales_invoice_item.use_serial_batch_fields) + self.assertTrue(sales_invoice_item.serial_and_batch_bundle) + self.assertNotEqual( + sales_invoice_item.serial_and_batch_bundle, pick_list_item.serial_and_batch_bundle + ) + self.assertEqual( + get_batch_from_bundle(sales_invoice_item.serial_and_batch_bundle), + get_batch_from_bundle(pick_list_item.serial_and_batch_bundle), + ) + self.assertEqual( + set(get_serial_nos_from_bundle(sales_invoice_item.serial_and_batch_bundle)), + set(get_serial_nos_from_bundle(pick_list_item.serial_and_batch_bundle)), + ) + + def test_sales_invoice_from_sales_order_pick_list_updates_sales_order(self): + warehouse = "_Test Warehouse - _TC" + item = make_item().name + + make_stock_entry(item=item, to_warehouse=warehouse, qty=5, basic_rate=100) + sales_order = make_sales_order(item_code=item, warehouse=warehouse, qty=5, rate=100) + + pick_list = create_pick_list(sales_order.name) + pick_list.submit() + + sales_invoice = create_delivery(pick_list.name, target="Sales Invoice") + sales_invoice_item = sales_invoice.items[0] + + self.assertEqual(sales_invoice_item.sales_order, sales_order.name) + self.assertEqual(sales_invoice_item.so_detail, sales_order.items[0].name) + self.assertEqual(sales_invoice_item.against_pick_list, pick_list.name) + self.assertEqual(sales_invoice_item.pick_list_item, pick_list.locations[0].name) + + sales_invoice.submit() + pick_list.reload() + sales_order.reload() + + self.assertEqual(pick_list.locations[0].delivered_qty, pick_list.locations[0].picked_qty) + self.assertEqual(pick_list.per_delivered, 100) + self.assertEqual(pick_list.delivery_status, "Fully Delivered") + self.assertEqual(pick_list.status, "Completed") + + self.assertEqual(sales_order.items[0].picked_qty, 5) + self.assertEqual(sales_order.items[0].delivered_qty, 5) + self.assertEqual(sales_order.per_delivered, 100) + self.assertEqual(sales_order.delivery_status, "Fully Delivered") + self.assertEqual(sales_order.per_billed, 100) + self.assertEqual(sales_order.billing_status, "Fully Billed") + self.assertEqual(sales_order.status, "Completed") + + def test_sales_invoice_against_pick_list_requires_update_stock(self): + warehouse = "_Test Warehouse - _TC" + item = make_item().name + + make_stock_entry(item=item, to_warehouse=warehouse, qty=5, basic_rate=100) + sales_order = make_sales_order(item_code=item, warehouse=warehouse, qty=5, rate=100) + + pick_list = create_pick_list(sales_order.name) + pick_list.submit() + + sales_invoice = create_delivery(pick_list.name, target="Sales Invoice") + sales_invoice.update_stock = 0 + + self.assertRaisesRegex( + frappe.ValidationError, + "Update Stock.*Pick List", + sales_invoice.save, + ) + def test_picklist_reserved_qty_validation(self): from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order