From 648efca940b2598616872b01d6a60be1482a0e65 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Tue, 28 Mar 2023 12:16:27 +0530 Subject: [PATCH] feat: auto create serial and batch bundle --- .../doctype/pricing_rule/pricing_rule.py | 23 - .../doctype/sales_invoice/sales_invoice.py | 4 - erpnext/controllers/stock_controller.py | 20 +- erpnext/selling/sales_common.js | 104 ----- .../setup_wizard/operations/defaults_setup.py | 1 - .../operations/install_fixtures.py | 1 - erpnext/stock/doctype/batch/batch.py | 64 +-- .../doctype/delivery_note/delivery_note.py | 19 + erpnext/stock/doctype/pick_list/pick_list.py | 119 +++-- .../serial_and_batch_bundle.py | 162 +++---- erpnext/stock/doctype/serial_no/serial_no.py | 13 + .../stock/doctype/stock_entry/stock_entry.py | 47 +- .../stock_ledger_entry/stock_ledger_entry.py | 22 +- .../stock_reconciliation.py | 11 +- .../stock_settings/stock_settings.json | 48 +- erpnext/stock/get_item_details.py | 97 +--- .../batch_wise_balance_history.py | 2 +- erpnext/stock/serial_batch_bundle.py | 441 +++++++++++------- 18 files changed, 556 insertions(+), 642 deletions(-) diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py index 2943500cf4..0b7ea2470c 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py @@ -237,10 +237,6 @@ def apply_pricing_rule(args, doc=None): item_list = args.get("items") args.pop("items") - set_serial_nos_based_on_fifo = frappe.db.get_single_value( - "Stock Settings", "automatically_set_serial_nos_based_on_fifo" - ) - item_code_list = tuple(item.get("item_code") for item in item_list) query_items = frappe.get_all( "Item", @@ -258,28 +254,9 @@ def apply_pricing_rule(args, doc=None): data = get_pricing_rule_for_item(args_copy, doc=doc) out.append(data) - if ( - serialized_items.get(item.get("item_code")) - and not item.get("serial_no") - and set_serial_nos_based_on_fifo - and not args.get("is_return") - ): - out[0].update(get_serial_no_for_item(args_copy)) - return out -def get_serial_no_for_item(args): - from erpnext.stock.get_item_details import get_serial_no - - item_details = frappe._dict( - {"doctype": args.doctype, "name": args.name, "serial_no": args.serial_no} - ) - if args.get("parenttype") in ("Sales Invoice", "Delivery Note") and flt(args.stock_qty) > 0: - item_details.serial_no = get_serial_no(args) - return item_details - - def update_pricing_rule_uom(pricing_rule, args): child_doc = {"Item Code": "items", "Item Group": "item_groups", "Brand": "brands"}.get( pricing_rule.apply_on diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 69e0cf2231..e6037095ac 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -36,7 +36,6 @@ from erpnext.controllers.accounts_controller import validate_account_head from erpnext.controllers.selling_controller import SellingController from erpnext.projects.doctype.timesheet.timesheet import get_projectwise_timesheet_data from erpnext.setup.doctype.company.company import update_company_current_month_sales -from erpnext.stock.doctype.batch.batch import set_batch_nos from erpnext.stock.doctype.delivery_note.delivery_note import update_billed_amount_based_on_so from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no, get_serial_nos @@ -125,9 +124,6 @@ class SalesInvoice(SellingController): if not self.is_opening: self.is_opening = "No" - if self._action != "submit" and self.update_stock and not self.is_return: - set_batch_nos(self, "warehouse", True) - if self.redeem_loyalty_points: lp = frappe.get_doc("Loyalty Program", self.loyalty_program) self.loyalty_redemption_account = ( diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 8b9e0aa0f8..d776b79592 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -372,6 +372,16 @@ class StockController(AccountsController): row.db_set("serial_and_batch_bundle", None) + def set_serial_and_batch_bundle(self, table_name=None): + if not table_name: + table_name = "items" + + for row in self.get(table_name): + if row.get("serial_and_batch_bundle"): + frappe.get_doc( + "Serial and Batch Bundle", row.serial_and_batch_bundle + ).set_serial_and_batch_values(self, row) + def make_package_for_transfer( self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None ): @@ -749,16 +759,6 @@ class StockController(AccountsController): message = self.prepare_over_receipt_message(rule, values) frappe.throw(msg=message, title=_("Over Receipt")) - def set_serial_and_batch_bundle(self, table_name=None): - if not table_name: - table_name = "items" - - for row in self.get(table_name): - if row.get("serial_and_batch_bundle"): - frappe.get_doc( - "Serial and Batch Bundle", row.serial_and_batch_bundle - ).set_serial_and_batch_values(self, row) - def prepare_over_receipt_message(self, rule, values): message = _( "{0} qty of Item {1} is being received into Warehouse {2} with capacity {3}." diff --git a/erpnext/selling/sales_common.js b/erpnext/selling/sales_common.js index 2ee197bc85..b607244591 100644 --- a/erpnext/selling/sales_common.js +++ b/erpnext/selling/sales_common.js @@ -196,48 +196,6 @@ erpnext.selling.SellingController = class SellingController extends erpnext.Tran refresh_field("incentives",row.name,row.parentfield); } - warehouse(doc, cdt, cdn) { - var me = this; - var item = frappe.get_doc(cdt, cdn); - - // check if serial nos entered are as much as qty in row - if (item.serial_no) { - let serial_nos = item.serial_no.split(`\n`).filter(sn => sn.trim()); // filter out whitespaces - if (item.qty === serial_nos.length) return; - } - - if (item.serial_no && !item.batch_no) { - item.serial_no = null; - } - - var has_batch_no; - frappe.db.get_value('Item', {'item_code': item.item_code}, 'has_batch_no', (r) => { - has_batch_no = r && r.has_batch_no; - if(item.item_code && item.warehouse) { - return this.frm.call({ - method: "erpnext.stock.get_item_details.get_bin_details_and_serial_nos", - child: item, - args: { - item_code: item.item_code, - warehouse: item.warehouse, - has_batch_no: has_batch_no || 0, - stock_qty: item.stock_qty, - serial_no: item.serial_no || "", - }, - callback:function(r){ - if (in_list(['Delivery Note', 'Sales Invoice'], doc.doctype)) { - if (doc.doctype === 'Sales Invoice' && (!doc.update_stock)) return; - if (has_batch_no) { - me.set_batch_number(cdt, cdn); - me.batch_no(doc, cdt, cdn); - } - } - } - }); - } - }) - } - toggle_editable_price_list_rate() { var df = frappe.meta.get_docfield(this.frm.doc.doctype + " Item", "price_list_rate", this.frm.doc.name); var editable_price_list_rate = cint(frappe.defaults.get_default("editable_price_list_rate")); @@ -298,36 +256,6 @@ erpnext.selling.SellingController = class SellingController extends erpnext.Tran } } - batch_no(doc, cdt, cdn) { - super.batch_no(doc, cdt, cdn); - - var item = frappe.get_doc(cdt, cdn); - - if (item.serial_no) { - return; - } - - item.serial_no = null; - var has_serial_no; - frappe.db.get_value('Item', {'item_code': item.item_code}, 'has_serial_no', (r) => { - has_serial_no = r && r.has_serial_no; - if(item.warehouse && item.item_code && item.batch_no) { - return this.frm.call({ - method: "erpnext.stock.get_item_details.get_batch_qty_and_serial_no", - child: item, - args: { - "batch_no": item.batch_no, - "stock_qty": item.stock_qty || item.qty, //if stock_qty field is not available fetch qty (in case of Packed Items table) - "warehouse": item.warehouse, - "item_code": item.item_code, - "has_serial_no": has_serial_no - }, - "fieldname": "actual_batch_qty" - }); - } - }) - } - set_dynamic_labels() { super.set_dynamic_labels(); this.set_product_bundle_help(this.frm.doc); @@ -388,38 +316,6 @@ erpnext.selling.SellingController = class SellingController extends erpnext.Tran } } - /* Determine appropriate batch number and set it in the form. - * @param {string} cdt - Document Doctype - * @param {string} cdn - Document name - */ - set_batch_number(cdt, cdn) { - const doc = frappe.get_doc(cdt, cdn); - if (doc && doc.has_batch_no && doc.warehouse) { - this._set_batch_number(doc); - } - } - - _set_batch_number(doc) { - if (doc.batch_no) { - return - } - - let args = {'item_code': doc.item_code, 'warehouse': doc.warehouse, 'qty': flt(doc.qty) * flt(doc.conversion_factor)}; - if (doc.has_serial_no && doc.serial_no) { - args['serial_no'] = doc.serial_no - } - - return frappe.call({ - method: 'erpnext.stock.doctype.batch.batch.get_batch_no', - args: args, - callback: function(r) { - if(r.message) { - frappe.model.set_value(doc.doctype, doc.name, 'batch_no', r.message); - } - } - }); - } - pick_serial_and_batch(doc, cdt, cdn) { let item = locals[cdt][cdn]; let me = this; diff --git a/erpnext/setup/setup_wizard/operations/defaults_setup.py b/erpnext/setup/setup_wizard/operations/defaults_setup.py index eed8f73cb4..756409bb74 100644 --- a/erpnext/setup/setup_wizard/operations/defaults_setup.py +++ b/erpnext/setup/setup_wizard/operations/defaults_setup.py @@ -36,7 +36,6 @@ 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.automatically_set_serial_nos_based_on_fifo = 1 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 6bc17718ae..8e61fe2872 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -486,7 +486,6 @@ 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.automatically_set_serial_nos_based_on_fifo = 1 stock_settings.set_qty_in_transactions_based_on_serial_no_input = 1 stock_settings.save() diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 35d862b571..a9df1e81f9 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -2,6 +2,8 @@ # License: GNU General Public License v3. See license.txt +from collections import defaultdict + import frappe from frappe import _ from frappe.model.document import Document @@ -257,54 +259,6 @@ def split_batch(batch_no, item_code, warehouse, qty, new_batch_id=None): return batch.name -def set_batch_nos(doc, warehouse_field, throw=False, child_table="items"): - """Automatically select `batch_no` for outgoing items in item table""" - for d in doc.get(child_table): - qty = d.get("stock_qty") or d.get("transfer_qty") or d.get("qty") or 0 - warehouse = d.get(warehouse_field, None) - if warehouse and qty > 0 and frappe.db.get_value("Item", d.item_code, "has_batch_no"): - if not d.batch_no: - pass - else: - batch_qty = get_batch_qty(batch_no=d.batch_no, warehouse=warehouse) - if flt(batch_qty, d.precision("qty")) < flt(qty, d.precision("qty")): - frappe.throw( - _( - "Row #{0}: The batch {1} has only {2} qty. Please select another batch which has {3} qty available or split the row into multiple rows, to deliver/issue from multiple batches" - ).format(d.idx, d.batch_no, batch_qty, qty) - ) - - -@frappe.whitelist() -def get_batch_no(item_code, warehouse, qty=1, throw=False, serial_no=None): - """ - Get batch number using First Expiring First Out method. - :param item_code: `item_code` of Item Document - :param warehouse: name of Warehouse to check - :param qty: quantity of Items - :return: String represent batch number of batch with sufficient quantity else an empty String - """ - - batch_no = None - batches = get_batches(item_code, warehouse, qty, throw, serial_no) - - for batch in batches: - if flt(qty) <= flt(batch.qty): - batch_no = batch.batch_id - break - - if not batch_no: - frappe.msgprint( - _( - "Please select a Batch for Item {0}. Unable to find a single batch that fulfills this requirement" - ).format(frappe.bold(item_code)) - ) - if throw: - raise UnableToSelectBatchError - - return batch_no - - def get_batches(item_code, warehouse, qty=1, throw=False, serial_no=None): from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -398,3 +352,17 @@ def get_pos_reserved_batch_qty(filters): flt_reserved_batch_qty = flt(reserved_batch_qty[0][0]) return flt_reserved_batch_qty + + +def get_available_batches(kwargs): + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + get_auto_batch_nos, + ) + + batchwise_qty = defaultdict(float) + + batches = get_auto_batch_nos(kwargs) + for batch in batches: + batchwise_qty[batch.get("batch_no")] += batch.get("qty") + + return batchwise_qty diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index ce0684a69b..ea20a26467 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -137,6 +137,7 @@ class DeliveryNote(SellingController): self.validate_uom_is_integer("stock_uom", "stock_qty") self.validate_uom_is_integer("uom", "qty") self.validate_with_previous_doc() + self.set_serial_and_batch_bundle_from_pick_list() from erpnext.stock.doctype.packed_item.packed_item import make_packing_list @@ -187,6 +188,24 @@ class DeliveryNote(SellingController): ] ) + def set_serial_and_batch_bundle_from_pick_list(self): + if not self.pick_list: + return + + for item in self.items: + if item.pick_list_item: + filters = { + "item_code": item.item_code, + "voucher_type": "Pick List", + "voucher_no": self.pick_list, + "voucher_detail_no": item.pick_list_item, + } + + bundle_id = frappe.db.get_value("Serial and Batch Bundle", filters, "name") + + if bundle_id: + item.serial_and_batch_bundle = bundle_id + def validate_proj_cust(self): """check for does customer belong to same project as entered..""" if self.project and self.customer: diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index a9a9a1d664..1ffc4ca3e3 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -12,14 +12,18 @@ from frappe.model.document import Document from frappe.model.mapper import map_child_doc from frappe.query_builder import Case from frappe.query_builder.custom import GROUP_CONCAT -from frappe.query_builder.functions import Coalesce, IfNull, Locate, Replace, Sum -from frappe.utils import cint, floor, flt, today +from frappe.query_builder.functions import Coalesce, Locate, Replace, Sum +from frappe.utils import cint, floor, flt from frappe.utils.nestedset import get_descendants_of from erpnext.selling.doctype.sales_order.sales_order import ( make_delivery_note as create_delivery_note_from_sales_order, ) +from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + get_auto_batch_nos, +) from erpnext.stock.get_item_details import get_conversion_factor +from erpnext.stock.serial_batch_bundle import SerialBatchCreation # TODO: Prioritize SO or WO group warehouse @@ -79,6 +83,7 @@ class PickList(Document): ) def on_submit(self): + self.validate_serial_and_batch_bundle() self.update_status() self.update_bundle_picked_qty() self.update_reference_qty() @@ -90,7 +95,29 @@ class PickList(Document): self.update_reference_qty() self.update_sales_order_picking_status() - def update_status(self, status=None): + def on_update(self): + self.linked_serial_and_batch_bundle() + + def linked_serial_and_batch_bundle(self): + for row in self.locations: + if row.serial_and_batch_bundle: + frappe.get_doc( + "Serial and Batch Bundle", row.serial_and_batch_bundle + ).set_serial_and_batch_values(self, row) + + def remove_serial_and_batch_bundle(self): + for row in self.locations: + if row.serial_and_batch_bundle: + frappe.delete_doc("Serial and Batch Bundle", row.serial_and_batch_bundle) + + def validate_serial_and_batch_bundle(self): + for row in self.locations: + if row.serial_and_batch_bundle: + doc = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle) + if doc.docstatus == 0: + doc.submit() + + def update_status(self, status=None, update_modified=True): if not status: if self.docstatus == 0: status = "Draft" @@ -192,6 +219,7 @@ class PickList(Document): locations_replica = self.get("locations") # reset + self.remove_serial_and_batch_bundle() self.delete_key("locations") updated_locations = frappe._dict() for item_doc in items: @@ -476,18 +504,13 @@ def get_items_with_location_and_quantity(item_doc, item_location_map, docstatus) if not stock_qty: break - serial_nos = None - if item_location.serial_no: - serial_nos = "\n".join(item_location.serial_no[0 : cint(stock_qty)]) - locations.append( frappe._dict( { "qty": qty, "stock_qty": stock_qty, "warehouse": item_location.warehouse, - "serial_no": serial_nos, - "batch_no": item_location.batch_no, + "serial_and_batch_bundle": item_location.serial_and_batch_bundle, } ) ) @@ -553,23 +576,6 @@ def get_available_item_locations( if picked_item_details: for location in list(locations): - key = ( - (location["warehouse"], location["batch_no"]) - if location.get("batch_no") - else location["warehouse"] - ) - - if key in picked_item_details: - picked_detail = picked_item_details[key] - - if picked_detail.get("serial_no") and location.get("serial_no"): - location["serial_no"] = list( - set(location["serial_no"]).difference(set(picked_detail["serial_no"])) - ) - location["qty"] = len(location["serial_no"]) - else: - location["qty"] -= picked_detail.get("picked_qty") - if location["qty"] < 1: locations.remove(location) @@ -620,31 +626,50 @@ def get_available_item_locations_for_serialized_item( def get_available_item_locations_for_batched_item( item_code, from_warehouses, required_qty, company, total_picked_qty=0 ): - sle = frappe.qb.DocType("Stock Ledger Entry") - batch = frappe.qb.DocType("Batch") - - query = ( - frappe.qb.from_(sle) - .from_(batch) - .select(sle.warehouse, sle.batch_no, Sum(sle.actual_qty).as_("qty")) - .where( - (sle.batch_no == batch.name) - & (sle.item_code == item_code) - & (sle.company == company) - & (batch.disabled == 0) - & (sle.is_cancelled == 0) - & (IfNull(batch.expiry_date, "2200-01-01") > today()) + locations = [] + data = get_auto_batch_nos( + frappe._dict( + { + "item_code": item_code, + "warehouse": from_warehouses, + "qty": required_qty + total_picked_qty, + } ) - .groupby(sle.warehouse, sle.batch_no, sle.item_code) - .having(Sum(sle.actual_qty) > 0) - .orderby(IfNull(batch.expiry_date, "2200-01-01"), batch.creation, sle.batch_no, sle.warehouse) - .limit(cint(required_qty + total_picked_qty)) ) - if from_warehouses: - query = query.where(sle.warehouse.isin(from_warehouses)) + warehouse_wise_batches = frappe._dict() + for d in data: + if d.warehouse not in warehouse_wise_batches: + warehouse_wise_batches.setdefault(d.warehouse, defaultdict(float)) - return query.run(as_dict=True) + warehouse_wise_batches[d.warehouse][d.batch_no] += d.qty + + for warehouse, batches in warehouse_wise_batches.items(): + qty = sum(batches.values()) + + bundle_doc = SerialBatchCreation( + { + "item_code": item_code, + "warehouse": warehouse, + "voucher_type": "Pick List", + "total_qty": qty, + "batches": batches, + "type_of_transaction": "Outward", + "company": company, + "do_not_submit": True, + } + ).make_serial_and_batch_bundle() + + locations.append( + { + "qty": qty, + "warehouse": warehouse, + "item_code": item_code, + "serial_and_batch_bundle": bundle_doc.name, + } + ) + + return locations def get_available_item_locations_for_serial_and_batched_item( 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 c4f240ab58..80cbf02b1e 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 @@ -10,7 +10,6 @@ from frappe import _, bold from frappe.model.document import Document from frappe.query_builder.functions import CombineDatetime, Sum from frappe.utils import add_days, cint, flt, get_link_to_form, today -from pypika import Case from erpnext.stock.serial_batch_bundle import BatchNoValuation, SerialNoValuation @@ -24,8 +23,6 @@ class SerialandBatchBundle(Document): self.validate_serial_and_batch_no() self.validate_duplicate_serial_and_batch_no() self.validate_voucher_no() - - def before_save(self): if self.type_of_transaction == "Maintenance": return @@ -168,13 +165,16 @@ class SerialandBatchBundle(Document): if not self.voucher_no or self.voucher_no != row.parent: values_to_set["voucher_no"] = row.parent + if self.voucher_type != parent.doctype: + values_to_set["voucher_type"] = parent.doctype + if not self.voucher_detail_no or self.voucher_detail_no != row.name: values_to_set["voucher_detail_no"] = row.name if parent.get("posting_date") and ( not self.posting_date or self.posting_date != parent.posting_date ): - values_to_set["posting_date"] = parent.posting_date + values_to_set["posting_date"] = parent.posting_date or today() if parent.get("posting_time") and ( not self.posting_time or self.posting_time != parent.posting_time @@ -222,6 +222,9 @@ class SerialandBatchBundle(Document): if self.voucher_no and not frappe.db.exists(self.voucher_type, self.voucher_no): frappe.throw(_(f"The {self.voucher_type} # {self.voucher_no} does not exist")) + if frappe.get_cached_value(self.voucher_type, self.voucher_no, "docstatus") != 1: + frappe.throw(_(f"The {self.voucher_type} # {self.voucher_no} should be submit first.")) + def check_future_entries_exists(self): if not self.has_serial_no: return @@ -681,73 +684,43 @@ def get_auto_batch_nos(kwargs): batches = [] - reserved_batches = get_reserved_batches_for_pos(kwargs) - if reserved_batches: - remove_batches_reserved_for_pos(available_batches, reserved_batches) + stock_ledgers_batches = get_stock_ledgers_batches(kwargs) + if stock_ledgers_batches: + update_available_batches(available_batches, stock_ledgers_batches) + + if not qty: + return batches for batch in available_batches: if qty > 0: batch_qty = flt(batch.qty) if qty > batch_qty: batches.append( - { - "batch_no": batch.batch_no, - "qty": batch_qty, - } + frappe._dict( + { + "batch_no": batch.batch_no, + "qty": batch_qty, + "warehouse": batch.warehouse, + } + ) ) qty -= batch_qty else: batches.append( - { - "batch_no": batch.batch_no, - "qty": qty, - } + frappe._dict( + { + "batch_no": batch.batch_no, + "qty": qty, + "warehouse": batch.warehouse, + } + ) ) qty = 0 return batches -def get_reserved_batches_for_pos(kwargs): - reserved_batches = defaultdict(float) - - pos_invoices = frappe.get_all( - "POS Invoice", - fields=[ - "`tabPOS Invoice Item`.batch_no", - "`tabPOS Invoice Item`.qty", - "`tabPOS Invoice Item`.serial_and_batch_bundle", - ], - filters=[ - ["POS Invoice", "consolidated_invoice", "is", "not set"], - ["POS Invoice", "docstatus", "=", 1], - ["POS Invoice Item", "item_code", "=", kwargs.item_code], - ], - ) - - ids = [ - pos_invoice.serial_and_batch_bundle - for pos_invoice in pos_invoices - if pos_invoice.serial_and_batch_bundle - ] - - for d in get_serial_batch_ledgers(ids, docstatus=1, name=ids): - if not d.batch_no: - continue - - reserved_batches[d.batch_no] += flt(d.qty) - - # Will be deprecated in v16 - for pos_invoice in pos_invoices: - if not pos_invoice.batch_no: - continue - - reserved_batches[pos_invoice.batch_no] += flt(pos_invoice.qty) - - return reserved_batches - - -def remove_batches_reserved_for_pos(available_batches, reserved_batches): +def update_available_batches(available_batches, reserved_batches): for batch in available_batches: if batch.batch_no in reserved_batches: available_batches[batch.batch_no] -= reserved_batches[batch.batch_no] @@ -766,16 +739,28 @@ def get_available_batches(kwargs): .on(batch_ledger.batch_no == batch_table.name) .select( batch_ledger.batch_no, + batch_ledger.warehouse, Sum(batch_ledger.qty).as_("qty"), ) - .where( - (stock_ledger_entry.item_code == kwargs.item_code) - & (stock_ledger_entry.warehouse == kwargs.warehouse) - & ((batch_table.expiry_date >= today()) | (batch_table.expiry_date.isnull())) - ) + .where(((batch_table.expiry_date >= today()) | (batch_table.expiry_date.isnull()))) .groupby(batch_ledger.batch_no) ) + for field in ["warehouse", "item_code"]: + if not kwargs.get(field): + continue + + if isinstance(kwargs.get(field), list): + query = query.where(stock_ledger_entry[field].isin(kwargs.get(field))) + else: + query = query.where(stock_ledger_entry[field] == kwargs.get(field)) + + if kwargs.get("batch_no"): + if isinstance(kwargs.batch_no, list): + query = query.where(batch_ledger.name.isin(kwargs.batch_no)) + else: + query = query.where(batch_ledger.name == kwargs.batch_no) + if kwargs.based_on == "LIFO": query = query.orderby(batch_table.creation, order=frappe.qb.desc) elif kwargs.based_on == "Expiry": @@ -789,6 +774,7 @@ def get_available_batches(kwargs): return data +# For work order and subcontracting def get_voucher_wise_serial_batch_from_bundle(**kwargs) -> Dict[str, Dict]: data = get_ledgers_from_serial_batch_bundle(**kwargs) if not data: @@ -878,42 +864,34 @@ def get_available_serial_nos(item_code, warehouse): return frappe.get_all("Serial No", filters=filters, fields=fields) -def get_available_batch_nos(item_code, warehouse): - sl_entries = get_stock_ledger_entries(item_code, warehouse) - batchwise_qty = defaultdict(float) - - precision = frappe.get_precision("Stock Ledger Entry", "qty") - for entry in sl_entries: - batchwise_qty[entry.batch_no] += flt(entry.qty, precision) - - return batchwise_qty - - -def get_stock_ledger_entries(item_code, warehouse): +def get_stock_ledgers_batches(kwargs): stock_ledger_entry = frappe.qb.DocType("Stock Ledger Entry") - batch_ledger = frappe.qb.DocType("Serial and Batch Entry") - return ( + query = ( frappe.qb.from_(stock_ledger_entry) - .left_join(batch_ledger) - .on(stock_ledger_entry.serial_and_batch_bundle == batch_ledger.parent) .select( stock_ledger_entry.warehouse, stock_ledger_entry.item_code, - Sum( - Case() - .when(stock_ledger_entry.serial_and_batch_bundle, batch_ledger.qty) - .else_(stock_ledger_entry.actual_qty) - .as_("qty") - ), - Case() - .when(stock_ledger_entry.serial_and_batch_bundle, batch_ledger.batch_no) - .else_(stock_ledger_entry.batch_no) - .as_("batch_no"), + Sum(stock_ledger_entry.actual_qty).as_("qty"), + stock_ledger_entry.batch_no, ) - .where( - (stock_ledger_entry.item_code == item_code) - & (stock_ledger_entry.warehouse == warehouse) - & (stock_ledger_entry.is_cancelled == 0) - ) - ).run(as_dict=True) + .where((stock_ledger_entry.is_cancelled == 0)) + .groupby(stock_ledger_entry.batch_no, stock_ledger_entry.warehouse) + ) + + for field in ["warehouse", "item_code"]: + if not kwargs.get(field): + continue + + if isinstance(kwargs.get(field), list): + query = query.where(stock_ledger_entry[field].isin(kwargs.get(field))) + else: + query = query.where(stock_ledger_entry[field] == kwargs.get(field)) + + data = query.run(as_dict=True) + + batches = defaultdict(float) + for d in data: + batches[d.batch_no] += d.qty + + return batches diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py index 5b4f41e926..03c40ebdd6 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.py +++ b/erpnext/stock/doctype/serial_no/serial_no.py @@ -322,3 +322,16 @@ def fetch_serial_numbers(filters, qty, do_not_include=None): serial_numbers = query.run(as_dict=True) return serial_numbers + + +def get_serial_nos_for_outward(kwargs): + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + get_auto_serial_nos, + ) + + serial_nos = get_auto_serial_nos(kwargs) + + if not serial_nos: + return [] + + return [d.serial_no for d in serial_nos] diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 6b0e5ae3c3..8ba8d11f11 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -28,7 +28,7 @@ from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals from erpnext.manufacturing.doctype.bom.bom import add_additional_cost, validate_bom_no from erpnext.setup.doctype.brand.brand import get_brand_defaults from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults -from erpnext.stock.doctype.batch.batch import get_batch_no, get_batch_qty, set_batch_nos +from erpnext.stock.doctype.batch.batch import get_batch_qty from erpnext.stock.doctype.item.item import get_item_defaults from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( @@ -39,7 +39,11 @@ from erpnext.stock.get_item_details import ( get_conversion_factor, get_default_cost_center, ) -from erpnext.stock.serial_batch_bundle import get_empty_batches_based_work_order +from erpnext.stock.serial_batch_bundle import ( + SerialBatchCreation, + get_empty_batches_based_work_order, + get_serial_or_batch_items, +) from erpnext.stock.stock_ledger import NegativeStockError, get_previous_sle, get_valuation_rate from erpnext.stock.utils import get_bin, get_incoming_rate @@ -143,9 +147,6 @@ class StockEntry(StockController): if not self.from_bom: self.fg_completed_qty = 0.0 - if self._action != "submit": - set_batch_nos(self, "s_warehouse") - self.validate_serialized_batch() self.set_actual_qty() self.calculate_rate_and_amount() @@ -242,6 +243,9 @@ class StockEntry(StockController): if self.purpose == "Material Transfer" and self.outgoing_stock_entry: self.set_material_request_transfer_status("In Transit") + def before_save(self): + self.make_serial_and_batch_bundle_for_outward() + def on_update(self): self.set_serial_and_batch_bundle() @@ -894,6 +898,30 @@ class StockEntry(StockController): serial_nos.append(sn) + def make_serial_and_batch_bundle_for_outward(self): + serial_or_batch_items = get_serial_or_batch_items(self.items) + + for row in self.items: + if row.serial_and_batch_bundle or row.item_code not in serial_or_batch_items: + continue + + bundle_doc = SerialBatchCreation( + { + "item_code": row.item_code, + "warehouse": row.s_warehouse, + "posting_date": self.posting_date, + "posting_time": self.posting_time, + "voucher_type": self.doctype, + "voucher_detail_no": row.name, + "total_qty": row.qty, + "type_of_transaction": "Outward", + "company": self.company, + "do_not_submit": True, + } + ).make_serial_and_batch_bundle() + + row.serial_and_batch_bundle = bundle_doc.name + def validate_subcontract_order(self): """Throw exception if more raw material is transferred against Subcontract Order than in the raw materials supplied table""" @@ -1445,15 +1473,6 @@ class StockEntry(StockController): stock_and_rate = get_warehouse_details(args) if args.get("warehouse") else {} ret.update(stock_and_rate) - # automatically select batch for outgoing item - if ( - args.get("s_warehouse", None) - and args.get("qty") - and ret.get("has_batch_no") - and not args.get("batch_no") - ): - args.batch_no = get_batch_no(args["item_code"], args["s_warehouse"], args["qty"]) - if ( self.purpose == "Send to Subcontractor" and self.get(self.subcontract_data.order_field) diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py index 7b3d7f4efb..35d7661c54 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -8,7 +8,7 @@ import frappe from frappe import _ from frappe.core.doctype.role.role import get_users from frappe.model.document import Document -from frappe.utils import add_days, cint, formatdate, get_datetime, get_link_to_form, getdate +from frappe.utils import add_days, cint, formatdate, get_datetime, getdate from erpnext.accounts.utils import get_fiscal_year from erpnext.controllers.item_variant import ItemTemplateCannotHaveStock @@ -51,7 +51,6 @@ class StockLedgerEntry(Document): def on_submit(self): self.check_stock_frozen_date() - self.calculate_batch_qty() if not self.get("via_landed_cost_voucher"): SerialBatchBundle( @@ -63,18 +62,6 @@ class StockLedgerEntry(Document): self.validate_serial_batch_no_bundle() - def calculate_batch_qty(self): - if self.batch_no: - batch_qty = ( - frappe.db.get_value( - "Stock Ledger Entry", - {"docstatus": 1, "batch_no": self.batch_no, "is_cancelled": 0}, - "sum(actual_qty)", - ) - or 0 - ) - frappe.db.set_value("Batch", self.batch_no, "batch_qty", batch_qty) - def validate_mandatory(self): mandatory = ["warehouse", "posting_date", "voucher_type", "voucher_no", "company"] for k in mandatory: @@ -123,12 +110,15 @@ class StockLedgerEntry(Document): ) if bundle_data.docstatus != 1: - link = get_link_to_form("Serial and Batch Bundle", self.serial_and_batch_bundle) - frappe.throw(_(f"Serial and Batch Bundle {link} should be submitted first")) + self.submit_serial_and_batch_bundle() if self.serial_and_batch_bundle and not (item_detail.has_serial_no or item_detail.has_batch_no): frappe.throw(_(f"Serial No and Batch No are not allowed for Item {self.item_code}")) + def submit_serial_and_batch_bundle(self): + doc = frappe.get_doc("Serial and Batch Bundle", self.serial_and_batch_bundle) + doc.submit() + def check_stock_frozen_date(self): stock_settings = frappe.get_cached_doc("Stock Settings") diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 19f48e7224..58484b1bc8 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -13,7 +13,7 @@ from erpnext.accounts.utils import get_company_default from erpnext.controllers.stock_controller import StockController from erpnext.stock.doctype.batch.batch import get_batch_qty from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( - get_available_batch_nos, + get_auto_batch_nos, get_available_serial_nos, ) from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -114,7 +114,14 @@ class StockReconciliation(StockController): ) if item_details.has_batch_no: - batch_nos_details = get_available_batch_nos(item.item_code, item.warehouse) + batch_nos_details = get_auto_batch_nos( + frappe._dict( + { + "item_code": item.item_code, + "warehouse": item.warehouse, + } + ) + ) for batch_no, qty in batch_nos_details.items(): serial_and_batch_bundle.append( diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index a37f671702..948592b75d 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -38,10 +38,11 @@ "allow_partial_reservation", "serial_and_batch_item_settings_tab", "section_break_7", - "automatically_set_serial_nos_based_on_fifo", - "set_qty_in_transactions_based_on_serial_no_input", - "column_break_10", + "auto_create_serial_and_batch_bundle_for_outward", + "pick_serial_and_batch_based_on", + "section_break_plhx", "disable_serial_no_and_batch_selector", + "column_break_mhzc", "use_naming_series", "naming_series_prefix", "stock_planning_tab", @@ -149,22 +150,6 @@ "fieldtype": "Check", "label": "Allow Negative Stock" }, - { - "fieldname": "column_break_10", - "fieldtype": "Column Break" - }, - { - "default": "1", - "fieldname": "automatically_set_serial_nos_based_on_fifo", - "fieldtype": "Check", - "label": "Automatically Set Serial Nos Based on FIFO" - }, - { - "default": "1", - "fieldname": "set_qty_in_transactions_based_on_serial_no_input", - "fieldtype": "Check", - "label": "Set Qty in Transactions Based on Serial No Input" - }, { "fieldname": "auto_material_request", "fieldtype": "Section Break", @@ -376,6 +361,29 @@ "fieldname": "allow_partial_reservation", "fieldtype": "Check", "label": "Allow Partial Reservation" + }, + { + "fieldname": "section_break_plhx", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_mhzc", + "fieldtype": "Column Break" + }, + { + "default": "FIFO", + "depends_on": "auto_create_serial_and_batch_bundle_for_outward", + "fieldname": "pick_serial_and_batch_based_on", + "fieldtype": "Select", + "label": "Pick Serial / Batch Based On", + "mandatory_depends_on": "auto_create_serial_and_batch_bundle_for_outward", + "options": "FIFO\nLIFO\nExpiry" + }, + { + "default": "1", + "fieldname": "auto_create_serial_and_batch_bundle_for_outward", + "fieldtype": "Check", + "label": "Auto Create Serial and Batch Bundle For Outward" } ], "icon": "icon-cog", @@ -383,7 +391,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-05-29 15:09:54.959411", + "modified": "2023-05-29 15:10:54.959411", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 56802d951e..64650bc201 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -8,7 +8,7 @@ import frappe from frappe import _, throw from frappe.model import child_table_fields, default_fields from frappe.model.meta import get_field_precision -from frappe.query_builder.functions import CombineDatetime, IfNull, Sum +from frappe.query_builder.functions import IfNull, Sum from frappe.utils import add_days, add_months, cint, cstr, flt, getdate from erpnext import get_company_currency @@ -1089,28 +1089,6 @@ def get_pos_profile(company, pos_profile=None, user=None): return pos_profile and pos_profile[0] or None -def get_serial_nos_by_fifo(args, sales_order=None): - if frappe.db.get_single_value("Stock Settings", "automatically_set_serial_nos_based_on_fifo"): - sn = frappe.qb.DocType("Serial No") - query = ( - frappe.qb.from_(sn) - .select(sn.name) - .where((sn.item_code == args.item_code) & (sn.warehouse == args.warehouse)) - .orderby(CombineDatetime(sn.purchase_date, sn.purchase_time)) - .limit(abs(cint(args.stock_qty))) - ) - - if sales_order: - query = query.where(sn.sales_order == sales_order) - if args.batch_no: - query = query.where(sn.batch_no == args.batch_no) - - serial_nos = query.run(as_list=True) - serial_nos = [s[0] for s in serial_nos] - - return "\n".join(serial_nos) - - @frappe.whitelist() def get_conversion_factor(item_code, uom): variant_of = frappe.db.get_value("Item", item_code, "variant_of", cache=True) @@ -1176,51 +1154,6 @@ def get_company_total_stock(item_code, company): ).run()[0][0] -@frappe.whitelist() -def get_serial_no_details(item_code, warehouse, stock_qty, serial_no): - args = frappe._dict( - {"item_code": item_code, "warehouse": warehouse, "stock_qty": stock_qty, "serial_no": serial_no} - ) - serial_no = get_serial_no(args) - - return {"serial_no": serial_no} - - -@frappe.whitelist() -def get_bin_details_and_serial_nos( - item_code, warehouse, has_batch_no=None, stock_qty=None, serial_no=None -): - bin_details_and_serial_nos = {} - bin_details_and_serial_nos.update(get_bin_details(item_code, warehouse)) - if flt(stock_qty) > 0: - if has_batch_no: - args = frappe._dict({"item_code": item_code, "warehouse": warehouse, "stock_qty": stock_qty}) - serial_no = get_serial_no(args) - bin_details_and_serial_nos.update({"serial_no": serial_no}) - return bin_details_and_serial_nos - - bin_details_and_serial_nos.update( - get_serial_no_details(item_code, warehouse, stock_qty, serial_no) - ) - - return bin_details_and_serial_nos - - -@frappe.whitelist() -def get_batch_qty_and_serial_no(batch_no, stock_qty, warehouse, item_code, has_serial_no): - batch_qty_and_serial_no = {} - batch_qty_and_serial_no.update(get_batch_qty(batch_no, warehouse, item_code)) - - if (flt(batch_qty_and_serial_no.get("actual_batch_qty")) >= flt(stock_qty)) and has_serial_no: - args = frappe._dict( - {"item_code": item_code, "warehouse": warehouse, "stock_qty": stock_qty, "batch_no": batch_no} - ) - serial_no = get_serial_no(args) - batch_qty_and_serial_no.update({"serial_no": serial_no}) - - return batch_qty_and_serial_no - - @frappe.whitelist() def get_batch_qty(batch_no, warehouse, item_code): from erpnext.stock.doctype.batch import batch @@ -1395,32 +1328,8 @@ def get_gross_profit(out): @frappe.whitelist() def get_serial_no(args, serial_nos=None, sales_order=None): - serial_no = None - if isinstance(args, str): - args = json.loads(args) - args = frappe._dict(args) - if args.get("doctype") == "Sales Invoice" and not args.get("update_stock"): - return "" - if args.get("warehouse") and args.get("stock_qty") and args.get("item_code"): - has_serial_no = frappe.get_value("Item", {"item_code": args.item_code}, "has_serial_no") - if args.get("batch_no") and has_serial_no == 1: - return get_serial_nos_by_fifo(args, sales_order) - elif has_serial_no == 1: - args = json.dumps( - { - "item_code": args.get("item_code"), - "warehouse": args.get("warehouse"), - "stock_qty": args.get("stock_qty"), - } - ) - args = process_args(args) - serial_no = get_serial_nos_by_fifo(args, sales_order) - - if not serial_no and serial_nos: - # For POS - serial_no = serial_nos - - return serial_no + serial_nos = serial_nos or [] + return serial_nos def update_party_blanket_order(args, out): diff --git a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py index 858db81e4b..c07287437a 100644 --- a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py +++ b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py @@ -131,7 +131,7 @@ def get_stock_ledger_entries_for_batch_bundle(filters): & (sle.has_batch_no == 1) & (sle.posting_date <= filters["to_date"]) ) - .groupby(batch_package.batch_no) + .groupby(batch_package.batch_no, batch_package.warehouse) .orderby(sle.item_code, sle.warehouse) ) diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 038cce7ea2..926863eb3c 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -49,103 +49,64 @@ class SerialBatchBundle: if ( not self.sle.is_cancelled and not self.sle.serial_and_batch_bundle - and self.sle.actual_qty > 0 and self.item_details.has_serial_no == 1 - and self.item_details.serial_no_series - and self.allow_to_make_auto_bundle() ): self.make_serial_batch_no_bundle() elif not self.sle.is_cancelled: self.validate_item_and_warehouse() - def auto_create_serial_nos(self, batch_no=None): - sr_nos = [] - serial_nos_details = [] - - for i in range(cint(self.sle.actual_qty)): - serial_no = make_autoname(self.item_details.serial_no_series, "Serial No") - sr_nos.append(serial_no) - serial_nos_details.append( - ( - serial_no, - serial_no, - now(), - now(), - frappe.session.user, - frappe.session.user, - self.warehouse, - self.company, - self.item_code, - self.item_details.item_name, - self.item_details.description, - "Active", - batch_no, - ) - ) - - if serial_nos_details: - fields = [ - "name", - "serial_no", - "creation", - "modified", - "owner", - "modified_by", - "warehouse", - "company", - "item_code", - "item_name", - "description", - "status", - "batch_no", - ] - - frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details)) - - return sr_nos - def make_serial_batch_no_bundle(self): - sn_doc = frappe.new_doc("Serial and Batch Bundle") - sn_doc.item_code = self.item_code - sn_doc.warehouse = self.warehouse - sn_doc.item_name = self.item_details.item_name - sn_doc.item_group = self.item_details.item_group - sn_doc.has_serial_no = self.item_details.has_serial_no - sn_doc.has_batch_no = self.item_details.has_batch_no - sn_doc.voucher_type = self.sle.voucher_type - sn_doc.voucher_no = self.sle.voucher_no - sn_doc.voucher_detail_no = self.sle.voucher_detail_no - sn_doc.total_qty = self.sle.actual_qty - sn_doc.avg_rate = self.sle.incoming_rate - sn_doc.total_amount = flt(self.sle.actual_qty) * flt(self.sle.incoming_rate) - sn_doc.type_of_transaction = "Inward" - sn_doc.posting_date = self.sle.posting_date - sn_doc.posting_time = self.sle.posting_time - sn_doc.is_rejected = self.is_rejected_entry() + self.validate_item() - sn_doc.flags.ignore_mandatory = True - sn_doc.insert() + sn_doc = SerialBatchCreation( + { + "item_code": self.item_code, + "warehouse": self.warehouse, + "posting_date": self.sle.posting_date, + "posting_time": self.sle.posting_time, + "voucher_type": self.sle.voucher_type, + "voucher_no": self.sle.voucher_no, + "voucher_detail_no": self.sle.voucher_detail_no, + "total_qty": self.sle.actual_qty, + "avg_rate": self.sle.incoming_rate, + "total_amount": flt(self.sle.actual_qty) * flt(self.sle.incoming_rate), + "type_of_transaction": "Inward" if self.sle.actual_qty > 0 else "Outward", + "company": self.company, + "is_rejected": self.is_rejected_entry(), + } + ).make_serial_and_batch_bundle() - batch_no = "" - if self.item_details.has_batch_no: - batch_no = self.create_batch() - - incoming_rate = self.sle.incoming_rate - if not incoming_rate: - incoming_rate = frappe.get_cached_value( - self.child_doctype, self.sle.voucher_detail_no, "valuation_rate" - ) - - if self.item_details.has_serial_no: - sr_nos = self.auto_create_serial_nos(batch_no) - self.add_serial_no_to_bundle(sn_doc, sr_nos, incoming_rate, batch_no) - elif self.item_details.has_batch_no: - self.add_batch_no_to_bundle(sn_doc, batch_no, incoming_rate) - - sn_doc.save() - sn_doc.submit() self.set_serial_and_batch_bundle(sn_doc) + def validate_item(self): + msg = "" + if self.sle.actual_qty > 0: + if not self.item_details.has_batch_no and not self.item_details.has_serial_no: + msg = f"Item {self.item_code} is not a batch or serial no item" + + if self.item_details.has_serial_no and not self.item_details.serial_no_series: + msg += f". If you want auto pick serial bundle, then kindly set Serial No Series in Item {self.item_code}" + + if ( + self.item_details.has_batch_no + and not self.item_details.batch_number_series + and not frappe.db.get_single_value("Stock Settings", "naming_series_prefix") + ): + msg += f". If you want auto pick batch bundle, then kindly set Batch Number Series in Item {self.item_code}" + + elif self.sle.actual_qty < 0: + if not frappe.db.get_single_value( + "Stock Settings", "auto_create_serial_and_batch_bundle_for_outward" + ): + msg += ". If you want auto pick serial/batch bundle, then kindly enable 'Auto Create Serial and Batch Bundle' in Stock Settings." + + if msg: + error_msg = ( + f"Serial and Batch Bundle not set for item {self.item_code} in warehouse {self.warehouse}." + + msg + ) + frappe.throw(_(error_msg)) + def set_serial_and_batch_bundle(self, sn_doc): self.sle.db_set("serial_and_batch_bundle", sn_doc.name) @@ -169,72 +130,19 @@ class SerialBatchBundle: def is_rejected_entry(self): return is_rejected(self.sle.voucher_type, self.sle.voucher_detail_no, self.sle.warehouse) - def add_serial_no_to_bundle(self, sn_doc, serial_nos, incoming_rate, batch_no=None): - for serial_no in serial_nos: - sn_doc.append( - "entries", - { - "serial_no": serial_no, - "qty": 1, - "incoming_rate": incoming_rate, - "batch_no": batch_no, - "warehouse": self.warehouse, - "is_outward": 0, - }, - ) - - def add_batch_no_to_bundle(self, sn_doc, batch_no, incoming_rate): - stock_value_difference = flt(self.sle.actual_qty) * flt(incoming_rate) - - if self.sle.actual_qty < 0: - stock_value_difference *= -1 - - sn_doc.append( - "entries", - { - "batch_no": batch_no, - "qty": self.sle.actual_qty, - "incoming_rate": incoming_rate, - "stock_value_difference": stock_value_difference, - }, - ) - - def create_batch(self): - from erpnext.stock.doctype.batch.batch import make_batch - - return make_batch( - frappe._dict( - { - "item": self.item_code, - "reference_doctype": self.sle.voucher_type, - "reference_name": self.sle.voucher_no, - } - ) - ) - def process_batch_no(self): if ( not self.sle.is_cancelled and not self.sle.serial_and_batch_bundle - and self.sle.actual_qty > 0 and self.item_details.has_batch_no == 1 and self.item_details.create_new_batch and self.item_details.batch_number_series - and self.allow_to_make_auto_bundle() ): self.make_serial_batch_no_bundle() elif not self.sle.is_cancelled: self.validate_item_and_warehouse() def validate_item_and_warehouse(self): - - data = frappe.db.get_value( - "Serial and Batch Bundle", - self.sle.serial_and_batch_bundle, - ["item_code", "warehouse", "voucher_no", "name"], - as_dict=1, - ) - if self.sle.serial_and_batch_bundle and not frappe.db.exists( "Serial and Batch Bundle", { @@ -270,18 +178,6 @@ class SerialBatchBundle: {"is_cancelled": 1, "voucher_no": ""}, ) - def allow_to_make_auto_bundle(self): - if self.sle.voucher_type in ["Stock Entry", "Purchase Receipt", "Purchase Invoice"]: - if self.sle.voucher_type == "Stock Entry": - stock_entry_type = frappe.get_cached_value("Stock Entry", self.sle.voucher_no, "purpose") - - if stock_entry_type in ["Material Receipt", "Manufacture", "Repack"]: - return True - - return True - - return False - def post_process(self): if not self.sle.serial_and_batch_bundle: return @@ -296,6 +192,9 @@ class SerialBatchBundle: ): self.set_batch_no_in_serial_nos() + if self.item_details.has_batch_no == 1: + self.update_batch_qty() + def set_warehouse_and_status_in_serial_nos(self): serial_nos = get_serial_nos(self.sle.serial_and_batch_bundle, check_outward=False) warehouse = self.warehouse if self.sle.actual_qty > 0 else None @@ -330,6 +229,20 @@ class SerialBatchBundle: .where(sn_table.name.isin(serial_nos)) ).run() + def update_batch_qty(self): + from erpnext.stock.doctype.batch.batch import get_available_batches + + batches = get_batch_nos(self.sle.serial_and_batch_bundle) + + batches_qty = get_available_batches( + frappe._dict( + {"item_code": self.item_code, "warehouse": self.warehouse, "batch_no": list(batches.keys())} + ) + ) + + for batch_no, qty in batches_qty.items(): + frappe.db.set_value("Batch", batch_no, "batch_qty", qty) + def get_serial_nos(serial_and_batch_bundle, check_outward=True): filters = {"parent": serial_and_batch_bundle} @@ -489,6 +402,7 @@ class BatchNoValuation(DeprecatedBatchNoValuation): self.batch_avg_rate = defaultdict(float) self.available_qty = defaultdict(float) + for ledger in entries: self.batch_avg_rate[ledger.batch_no] += flt(ledger.incoming_rate) / flt(ledger.qty) self.available_qty[ledger.batch_no] += flt(ledger.qty) @@ -502,11 +416,13 @@ class BatchNoValuation(DeprecatedBatchNoValuation): batch_nos = list(self.batch_nos.keys()) - timestamp_condition = CombineDatetime( - parent.posting_date, parent.posting_time - ) < CombineDatetime(self.sle.posting_date, self.sle.posting_time) + timestamp_condition = "" + if self.sle.posting_date and self.sle.posting_time: + timestamp_condition = CombineDatetime( + parent.posting_date, parent.posting_time + ) < CombineDatetime(self.sle.posting_date, self.sle.posting_time) - return ( + query = ( frappe.qb.from_(parent) .inner_join(child) .on(parent.name == child.parent) @@ -524,21 +440,19 @@ class BatchNoValuation(DeprecatedBatchNoValuation): & (parent.is_cancelled == 0) & (parent.type_of_transaction != "Maintenance") ) - .where(timestamp_condition) .groupby(child.batch_no) - ).run(as_dict=True) + ) + + if timestamp_condition: + query.where(timestamp_condition) + + return query.run(as_dict=True) def get_batch_nos(self) -> list: if self.sle.get("batch_nos"): return self.sle.batch_nos - entries = frappe.get_all( - "Serial and Batch Entry", - fields=["batch_no", "qty", "name"], - filters={"parent": self.sle.serial_and_batch_bundle, "is_outward": 1}, - ) - - return {d.batch_no: d for d in entries} + return get_batch_nos(self.sle.serial_and_batch_bundle) def set_stock_value_difference(self): self.stock_value_change = 0 @@ -566,6 +480,16 @@ class BatchNoValuation(DeprecatedBatchNoValuation): return abs(flt(self.stock_value_change) / flt(self.sle.actual_qty)) +def get_batch_nos(serial_and_batch_bundle): + entries = frappe.get_all( + "Serial and Batch Entry", + fields=["batch_no", "qty", "name"], + filters={"parent": serial_and_batch_bundle, "is_outward": 1}, + ) + + return {d.batch_no: d for d in entries} + + def get_empty_batches_based_work_order(work_order, item_code): batches = get_batches_from_work_order(work_order, item_code) if not batches: @@ -631,8 +555,35 @@ def set_batch_details_from_package(ids, batches): class SerialBatchCreation: def __init__(self, args): + self.set(args) + self.set_item_details() + + def set(self, args): + self.__dict__ = {} for key, value in args.items(): setattr(self, key, value) + self.__dict__[key] = value + + def get(self, key): + return self.__dict__.get(key) + + def set_item_details(self): + fields = [ + "has_batch_no", + "has_serial_no", + "item_name", + "item_group", + "serial_no_series", + "create_new_batch", + "batch_number_series", + "description", + ] + + item_details = frappe.get_cached_value("Item", self.item_code, fields, as_dict=1) + for key, value in item_details.items(): + setattr(self, key, value) + + self.__dict__.update(item_details) def duplicate_package(self): if not self.serial_and_batch_bundle: @@ -643,7 +594,167 @@ class SerialBatchCreation: new_package = frappe.copy_doc(package) new_package.type_of_transaction = self.type_of_transaction new_package.returned_against = self.returned_against - print(new_package.voucher_type, new_package.voucher_no) new_package.save() self.serial_and_batch_bundle = new_package.name + + def make_serial_and_batch_bundle(self): + doc = frappe.new_doc("Serial and Batch Bundle") + valid_columns = doc.meta.get_valid_columns() + for key, value in self.__dict__.items(): + if key in valid_columns: + doc.set(key, value) + + if self.type_of_transaction == "Outward": + self.set_auto_serial_batch_entries_for_outward() + elif self.type_of_transaction == "Inward": + self.set_auto_serial_batch_entries_for_inward() + + self.set_serial_batch_entries(doc) + doc.save() + + if not hasattr(self, "do_not_submit") or not self.do_not_submit: + doc.submit() + + return doc + + def set_auto_serial_batch_entries_for_outward(self): + from erpnext.stock.doctype.batch.batch import get_available_batches + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos_for_outward + + kwargs = frappe._dict( + { + "item_code": self.item_code, + "warehouse": self.warehouse, + "qty": abs(self.total_qty), + "based_on": frappe.db.get_single_value("Stock Settings", "pick_serial_and_batch_based_on"), + } + ) + + if self.has_serial_no and not self.get("serial_nos"): + self.serial_nos = get_serial_nos_for_outward(kwargs) + elif self.has_batch_no and not self.get("batches"): + self.batches = get_available_batches(kwargs) + + def set_auto_serial_batch_entries_for_inward(self): + self.batch_no = None + if self.has_batch_no: + self.batch_no = self.create_batch() + + if self.has_serial_no: + self.serial_nos = self.get_auto_created_serial_nos() + else: + self.batches = frappe._dict({self.batch_no: abs(self.total_qty)}) + + def set_serial_batch_entries(self, doc): + if self.get("serial_nos"): + serial_no_wise_batch = frappe._dict({}) + if self.has_batch_no: + serial_no_wise_batch = self.get_serial_nos_batch(self.serial_nos) + + qty = -1 if self.type_of_transaction == "Outward" else 1 + for serial_no in self.serial_nos: + doc.append( + "entries", + { + "serial_no": serial_no, + "qty": qty, + "batch_no": serial_no_wise_batch.get(serial_no) or self.get("batch_no"), + "incoming_rate": self.get("incoming_rate"), + }, + ) + + if self.get("batches"): + for batch_no, batch_qty in self.batches.items(): + doc.append( + "entries", + { + "batch_no": batch_no, + "qty": batch_qty * (-1 if self.type_of_transaction == "Outward" else 1), + "incoming_rate": self.get("incoming_rate"), + }, + ) + + def get_serial_nos_batch(self, serial_nos): + return frappe._dict( + frappe.get_all( + "Serial No", + fields=["name", "batch_no"], + filters={"name": ("in", serial_nos)}, + as_list=1, + ) + ) + + def create_batch(self): + from erpnext.stock.doctype.batch.batch import make_batch + + return make_batch( + frappe._dict( + { + "item": self.item_code, + "reference_doctype": self.voucher_type, + "reference_name": self.voucher_no, + } + ) + ) + + def get_auto_created_serial_nos(self): + sr_nos = [] + serial_nos_details = [] + + for i in range(abs(cint(self.total_qty))): + serial_no = make_autoname(self.serial_no_series, "Serial No") + sr_nos.append(serial_no) + serial_nos_details.append( + ( + serial_no, + serial_no, + now(), + now(), + frappe.session.user, + frappe.session.user, + self.warehouse, + self.company, + self.item_code, + self.item_name, + self.description, + "Active", + self.batch_no, + ) + ) + + if serial_nos_details: + fields = [ + "name", + "serial_no", + "creation", + "modified", + "owner", + "modified_by", + "warehouse", + "company", + "item_code", + "item_name", + "description", + "status", + "batch_no", + ] + + frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details)) + + return sr_nos + + +def get_serial_or_batch_items(items): + serial_or_batch_items = frappe.get_all( + "Item", + filters={"name": ("in", [d.item_code for d in items])}, + or_filters={"has_serial_no": 1, "has_batch_no": 1}, + ) + + if not serial_or_batch_items: + return + else: + serial_or_batch_items = [d.name for d in serial_or_batch_items] + + return serial_or_batch_items