refactor: serial and batch package creation for finished item and cleanup code
diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py
index 789ca6c..b5e780b 100644
--- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py
+++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py
@@ -328,8 +328,6 @@
{
"item_code": self.target_item_code,
"warehouse": self.target_warehouse,
- "batch_no": self.target_batch_no,
- "serial_no": self.target_serial_no,
"actual_qty": flt(self.target_qty),
"incoming_rate": flt(self.target_incoming_rate),
},
diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py
index b55574f..c064e5a 100644
--- a/erpnext/controllers/buying_controller.py
+++ b/erpnext/controllers/buying_controller.py
@@ -5,7 +5,7 @@
import frappe
from frappe import ValidationError, _, msgprint
from frappe.contacts.doctype.address.address import get_address_display
-from frappe.utils import cint, cstr, flt, getdate
+from frappe.utils import cint, flt, getdate
from frappe.utils.data import nowtime
from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget
@@ -497,7 +497,6 @@
d,
{
"actual_qty": flt(pr_qty),
- "serial_no": cstr(d.serial_no).strip(),
"serial_and_batch_bundle": (
d.serial_and_batch_bundle
if not self.is_internal_transfer()
@@ -550,7 +549,6 @@
{
"warehouse": d.rejected_warehouse,
"actual_qty": flt(d.rejected_qty) * flt(d.conversion_factor),
- "serial_no": cstr(d.rejected_serial_no).strip(),
"incoming_rate": 0.0,
"serial_and_batch_bundle": d.rejected_serial_and_batch_bundle,
},
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index 2048a42..8c3bd4d 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -407,7 +407,6 @@
else:
bundle_doc.save(ignore_permissions=True)
- print(bundle_doc.name)
return bundle_doc.name
def get_sl_entries(self, d, args):
@@ -428,7 +427,6 @@
),
"incoming_rate": 0,
"company": self.company,
- "serial_no": d.get("serial_no"),
"project": d.get("project") or self.get("project"),
"is_cancelled": 1 if self.docstatus == 2 else 0,
}
diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py
index 0e666ff..1418e5f 100644
--- a/erpnext/controllers/subcontracting_controller.py
+++ b/erpnext/controllers/subcontracting_controller.py
@@ -8,7 +8,7 @@
import frappe
from frappe import _
from frappe.model.mapper import get_mapped_doc
-from frappe.utils import cint, cstr, flt, get_link_to_form
+from frappe.utils import cint, flt, get_link_to_form
from erpnext.controllers.stock_controller import StockController
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
@@ -768,9 +768,7 @@
scr_qty = flt(item.qty) * flt(item.conversion_factor)
if scr_qty:
- sle = self.get_sl_entries(
- item, {"actual_qty": flt(scr_qty), "serial_no": cstr(item.serial_no).strip()}
- )
+ sle = self.get_sl_entries(item, {"actual_qty": flt(scr_qty)})
rate_db_precision = 6 if cint(self.precision("rate", item)) <= 6 else 9
incoming_rate = flt(item.rate, rate_db_precision)
sle.update(
@@ -788,7 +786,6 @@
{
"warehouse": item.rejected_warehouse,
"actual_qty": flt(item.rejected_qty) * flt(item.conversion_factor),
- "serial_no": cstr(item.rejected_serial_no).strip(),
"incoming_rate": 0.0,
},
)
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json
index d83bd1d..aecace6 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.json
+++ b/erpnext/manufacturing/doctype/work_order/work_order.json
@@ -42,7 +42,6 @@
"has_serial_no",
"has_batch_no",
"column_break_18",
- "serial_no",
"batch_size",
"required_items_section",
"materials_and_operations_tab",
@@ -533,14 +532,6 @@
"read_only": 1
},
{
- "depends_on": "has_serial_no",
- "fieldname": "serial_no",
- "fieldtype": "Small Text",
- "label": "Serial Nos",
- "no_copy": 1,
- "read_only": 1
- },
- {
"default": "0",
"depends_on": "has_batch_no",
"fieldname": "batch_size",
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index e30a302..a5b8972 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -17,6 +17,7 @@
get_datetime,
get_link_to_form,
getdate,
+ now,
nowdate,
time_diff_in_hours,
)
@@ -32,11 +33,7 @@
)
from erpnext.stock.doctype.batch.batch import make_batch
from erpnext.stock.doctype.item.item import get_item_defaults, validate_end_of_life
-from erpnext.stock.doctype.serial_no.serial_no import (
- clean_serial_no_string,
- get_auto_serial_nos,
- get_serial_nos,
-)
+from erpnext.stock.doctype.serial_no.serial_no import get_auto_serial_nos, get_serial_nos
from erpnext.stock.stock_balance import get_planned_qty, update_bin_qty
from erpnext.stock.utils import get_bin, get_latest_stock_qty, validate_warehouse_company
from erpnext.utilities.transaction_base import validate_uom_is_integer
@@ -447,24 +444,53 @@
frappe.delete_doc("Batch", row.name)
def make_serial_nos(self, args):
- self.serial_no = clean_serial_no_string(self.serial_no)
- serial_no_series = frappe.get_cached_value("Item", self.production_item, "serial_no_series")
- if serial_no_series:
- self.serial_no = get_auto_serial_nos(serial_no_series, self.qty)
+ item_details = frappe.get_cached_value(
+ "Item", self.production_item, ["serial_no_series", "item_name", "description"], as_dict=1
+ )
- if self.serial_no:
- args.update({"serial_no": self.serial_no, "actual_qty": self.qty})
- # auto_make_serial_nos(args)
+ serial_nos = []
+ if item_details.serial_no_series:
+ serial_nos = get_auto_serial_nos(item_details.serial_no_series, self.qty)
- serial_nos_length = len(get_serial_nos(self.serial_no))
- if serial_nos_length != self.qty:
- frappe.throw(
- _("{0} Serial Numbers required for Item {1}. You have provided {2}.").format(
- self.qty, self.production_item, serial_nos_length
- ),
- SerialNoQtyError,
+ if not serial_nos:
+ return
+
+ fields = [
+ "name",
+ "serial_no",
+ "creation",
+ "modified",
+ "owner",
+ "modified_by",
+ "company",
+ "item_code",
+ "item_name",
+ "description",
+ "status",
+ "work_order",
+ ]
+
+ serial_nos_details = []
+ for serial_no in serial_nos:
+ serial_nos_details.append(
+ (
+ serial_no,
+ serial_no,
+ now(),
+ now(),
+ frappe.session.user,
+ frappe.session.user,
+ self.company,
+ self.production_item,
+ item_details.item_name,
+ item_details.description,
+ "Inactive",
+ self.name,
+ )
)
+ frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details))
+
def create_job_card(self):
manufacturing_settings_doc = frappe.get_doc("Manufacturing Settings")
@@ -1041,24 +1067,6 @@
bom.set_bom_material_details()
return bom
- def update_batch_produced_qty(self, stock_entry_doc):
- if not cint(
- frappe.db.get_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order")
- ):
- return
-
- for row in stock_entry_doc.items:
- if row.batch_no and (row.is_finished_item or row.is_scrap_item):
- qty = frappe.get_all(
- "Stock Entry Detail",
- filters={"batch_no": row.batch_no, "docstatus": 1},
- or_filters={"is_finished_item": 1, "is_scrap_item": 1},
- fields=["sum(qty)"],
- as_list=1,
- )[0][0]
-
- frappe.db.set_value("Batch", row.batch_no, "produced_qty", flt(qty))
-
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
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 824691c..4969713 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
@@ -27,8 +27,8 @@
self.validate_serial_nos_inventory()
def before_save(self):
- self.set_total_qty()
self.set_is_outward()
+ self.set_total_qty()
self.set_warehouse()
self.set_incoming_rate()
self.validate_qty_and_stock_value_difference()
@@ -51,7 +51,9 @@
)
for serial_no in serial_nos:
- if serial_no_warehouse.get(serial_no) != self.warehouse:
+ if (
+ not serial_no_warehouse.get(serial_no) or serial_no_warehouse.get(serial_no) != self.warehouse
+ ):
frappe.throw(
_(f"Serial No {bold(serial_no)} is not present in the warehouse {bold(self.warehouse)}.")
)
@@ -73,6 +75,9 @@
if d.stock_value_difference and d.stock_value_difference > 0:
d.stock_value_difference *= -1
+ def get_serial_nos(self):
+ return [d.serial_no for d in self.ledgers if d.serial_no]
+
def set_incoming_rate_for_outward_transaction(self, row=None, save=False):
sle = self.get_sle_for_outward_transaction(row)
if self.has_serial_no:
@@ -271,6 +276,11 @@
def set_is_outward(self):
for row in self.ledgers:
+ if self.type_of_transaction == "Outward" and row.qty > 0:
+ row.qty *= -1
+ elif self.type_of_transaction == "Inward" and row.qty < 0:
+ row.qty *= -1
+
row.is_outward = 1 if self.type_of_transaction == "Outward" else 0
@frappe.whitelist()
diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py
index 4c5156c..5b4f41e 100644
--- a/erpnext/stock/doctype/serial_no/serial_no.py
+++ b/erpnext/stock/doctype/serial_no/serial_no.py
@@ -9,10 +9,9 @@
from frappe import ValidationError, _
from frappe.model.naming import make_autoname
from frappe.query_builder.functions import Coalesce
-from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, now, nowdate, safe_json_loads
+from frappe.utils import cint, cstr, getdate, nowdate, safe_json_loads
from erpnext.controllers.stock_controller import StockController
-from erpnext.stock.get_item_details import get_reserved_qty_for_so
class SerialNoCannotCreateDirectError(ValidationError):
@@ -108,384 +107,12 @@
)
-def process_serial_no(sle):
- item_det = get_item_details(sle.item_code)
- validate_serial_no(sle, item_det)
-
-
-def validate_serial_no(sle, item_det):
- serial_nos = get_serial_nos(sle.serial_and_batch_bundle) if sle.serial_and_batch_bundle else []
- validate_material_transfer_entry(sle)
-
- if item_det.has_serial_no == 0:
- if serial_nos:
- frappe.throw(
- _("Item {0} is not setup for Serial Nos. Column must be blank").format(sle.item_code),
- SerialNoNotRequiredError,
- )
- elif not sle.is_cancelled:
- return
- if serial_nos:
- if cint(sle.actual_qty) != flt(sle.actual_qty):
- frappe.throw(
- _("Serial No {0} quantity {1} cannot be a fraction").format(sle.item_code, sle.actual_qty)
- )
-
- if len(serial_nos) and len(serial_nos) != abs(cint(sle.actual_qty)):
- frappe.throw(
- _("{0} Serial Numbers required for Item {1}. You have provided {2}.").format(
- abs(sle.actual_qty), sle.item_code, len(serial_nos)
- ),
- SerialNoQtyError,
- )
-
- if len(serial_nos) != len(set(serial_nos)):
- frappe.throw(
- _("Duplicate Serial No entered for Item {0}").format(sle.item_code), SerialNoDuplicateError
- )
-
- for serial_no in serial_nos:
- if frappe.db.exists("Serial No", serial_no):
- sr = frappe.db.get_value(
- "Serial No",
- serial_no,
- [
- "name",
- "item_code",
- "batch_no",
- "sales_order",
- "delivery_document_no",
- "delivery_document_type",
- "warehouse",
- "purchase_document_type",
- "purchase_document_no",
- "company",
- "status",
- ],
- as_dict=1,
- )
-
- if sr.item_code != sle.item_code:
- if not allow_serial_nos_with_different_item(serial_no, sle):
- frappe.throw(
- _("Serial No {0} does not belong to Item {1}").format(serial_no, sle.item_code),
- SerialNoItemError,
- )
-
- if cint(sle.actual_qty) > 0 and has_serial_no_exists(sr, sle):
- doc_name = frappe.bold(get_link_to_form(sr.purchase_document_type, sr.purchase_document_no))
- frappe.throw(
- _("Serial No {0} has already been received in the {1} #{2}").format(
- frappe.bold(serial_no), sr.purchase_document_type, doc_name
- ),
- SerialNoDuplicateError,
- )
-
- if (
- sr.delivery_document_no
- and sle.voucher_type not in ["Stock Entry", "Stock Reconciliation"]
- and sle.voucher_type == sr.delivery_document_type
- ):
- return_against = frappe.db.get_value(sle.voucher_type, sle.voucher_no, "return_against")
- if return_against and return_against != sr.delivery_document_no:
- frappe.throw(_("Serial no {0} has been already returned").format(sr.name))
-
- if cint(sle.actual_qty) < 0:
- if sr.warehouse != sle.warehouse:
- frappe.throw(
- _("Serial No {0} does not belong to Warehouse {1}").format(serial_no, sle.warehouse),
- SerialNoWarehouseError,
- )
-
- if not sr.purchase_document_no:
- frappe.throw(_("Serial No {0} not in stock").format(serial_no), SerialNoNotExistsError)
-
- if sle.voucher_type in ("Delivery Note", "Sales Invoice"):
-
- if sr.batch_no and sr.batch_no != sle.batch_no:
- frappe.throw(
- _("Serial No {0} does not belong to Batch {1}").format(serial_no, sle.batch_no),
- SerialNoBatchError,
- )
-
- if not sle.is_cancelled and not sr.warehouse:
- frappe.throw(
- _("Serial No {0} does not belong to any Warehouse").format(serial_no),
- SerialNoWarehouseError,
- )
-
- # if Sales Order reference in Serial No validate the Delivery Note or Invoice is against the same
- if sr.sales_order:
- if sle.voucher_type == "Sales Invoice":
- if not frappe.db.exists(
- "Sales Invoice Item",
- {"parent": sle.voucher_no, "item_code": sle.item_code, "sales_order": sr.sales_order},
- ):
- frappe.throw(
- _(
- "Cannot deliver Serial No {0} of item {1} as it is reserved to fullfill Sales Order {2}"
- ).format(sr.name, sle.item_code, sr.sales_order)
- )
- elif sle.voucher_type == "Delivery Note":
- if not frappe.db.exists(
- "Delivery Note Item",
- {
- "parent": sle.voucher_no,
- "item_code": sle.item_code,
- "against_sales_order": sr.sales_order,
- },
- ):
- invoice = frappe.db.get_value(
- "Delivery Note Item",
- {"parent": sle.voucher_no, "item_code": sle.item_code},
- "against_sales_invoice",
- )
- if not invoice or frappe.db.exists(
- "Sales Invoice Item",
- {"parent": invoice, "item_code": sle.item_code, "sales_order": sr.sales_order},
- ):
- frappe.throw(
- _(
- "Cannot deliver Serial No {0} of item {1} as it is reserved to fullfill Sales Order {2}"
- ).format(sr.name, sle.item_code, sr.sales_order)
- )
- # if Sales Order reference in Delivery Note or Invoice validate SO reservations for item
- if sle.voucher_type == "Sales Invoice":
- sales_order = frappe.db.get_value(
- "Sales Invoice Item",
- {"parent": sle.voucher_no, "item_code": sle.item_code},
- "sales_order",
- )
- if sales_order and get_reserved_qty_for_so(sales_order, sle.item_code):
- validate_so_serial_no(sr, sales_order)
- elif sle.voucher_type == "Delivery Note":
- sales_order = frappe.get_value(
- "Delivery Note Item",
- {"parent": sle.voucher_no, "item_code": sle.item_code},
- "against_sales_order",
- )
- if sales_order and get_reserved_qty_for_so(sales_order, sle.item_code):
- validate_so_serial_no(sr, sales_order)
- else:
- sales_invoice = frappe.get_value(
- "Delivery Note Item",
- {"parent": sle.voucher_no, "item_code": sle.item_code},
- "against_sales_invoice",
- )
- if sales_invoice:
- sales_order = frappe.db.get_value(
- "Sales Invoice Item",
- {"parent": sales_invoice, "item_code": sle.item_code},
- "sales_order",
- )
- if sales_order and get_reserved_qty_for_so(sales_order, sle.item_code):
- validate_so_serial_no(sr, sales_order)
- elif cint(sle.actual_qty) < 0:
- # transfer out
- frappe.throw(_("Serial No {0} not in stock").format(serial_no), SerialNoNotExistsError)
- elif cint(sle.actual_qty) < 0 or not item_det.serial_no_series:
- frappe.throw(
- _("Serial Nos Required for Serialized Item {0}").format(sle.item_code), SerialNoRequiredError
- )
- elif serial_nos:
- return
- # SLE is being cancelled and has serial nos
- for serial_no in serial_nos:
- check_serial_no_validity_on_cancel(serial_no, sle)
-
-
-def check_serial_no_validity_on_cancel(serial_no, sle):
- sr = frappe.db.get_value(
- "Serial No", serial_no, ["name", "warehouse", "company", "status"], as_dict=1
- )
- sr_link = frappe.utils.get_link_to_form("Serial No", serial_no)
- doc_link = frappe.utils.get_link_to_form(sle.voucher_type, sle.voucher_no)
- actual_qty = cint(sle.actual_qty)
- is_stock_reco = sle.voucher_type == "Stock Reconciliation"
- msg = None
-
- if sr and (actual_qty < 0 or is_stock_reco) and (sr.warehouse and sr.warehouse != sle.warehouse):
- # receipt(inward) is being cancelled
- msg = _("Cannot cancel {0} {1} as Serial No {2} does not belong to the warehouse {3}").format(
- sle.voucher_type, doc_link, sr_link, frappe.bold(sle.warehouse)
- )
- elif sr and actual_qty > 0 and not is_stock_reco:
- # delivery is being cancelled, check for warehouse.
- if sr.warehouse:
- # serial no is active in another warehouse/company.
- msg = _("Cannot cancel {0} {1} as Serial No {2} is active in warehouse {3}").format(
- sle.voucher_type, doc_link, sr_link, frappe.bold(sr.warehouse)
- )
- elif sr.company != sle.company and sr.status == "Delivered":
- # serial no is inactive (allowed) or delivered from another company (block).
- msg = _("Cannot cancel {0} {1} as Serial No {2} does not belong to the company {3}").format(
- sle.voucher_type, doc_link, sr_link, frappe.bold(sle.company)
- )
-
- if msg:
- frappe.throw(msg, title=_("Cannot cancel"))
-
-
-def validate_material_transfer_entry(sle_doc):
- sle_doc.update({"skip_update_serial_no": False, "skip_serial_no_validaiton": False})
-
- if (
- sle_doc.voucher_type == "Stock Entry"
- and not sle_doc.is_cancelled
- and frappe.get_cached_value("Stock Entry", sle_doc.voucher_no, "purpose") == "Material Transfer"
- ):
- if sle_doc.actual_qty < 0:
- sle_doc.skip_update_serial_no = True
- else:
- sle_doc.skip_serial_no_validaiton = True
-
-
-def validate_so_serial_no(sr, sales_order):
- if not sr.sales_order or sr.sales_order != sales_order:
- msg = _(
- "Sales Order {0} has reservation for the item {1}, you can only deliver reserved {1} against {0}."
- ).format(sales_order, sr.item_code)
-
- frappe.throw(_("""{0} Serial No {1} cannot be delivered""").format(msg, sr.name))
-
-
-def has_serial_no_exists(sn, sle):
- if (
- sn.warehouse and not sle.skip_serial_no_validaiton and sle.voucher_type != "Stock Reconciliation"
- ):
- return True
-
- if sn.company != sle.company:
- return False
-
-
-def allow_serial_nos_with_different_item(sle_serial_no, sle):
- """
- Allows same serial nos for raw materials and finished goods
- in Manufacture / Repack type Stock Entry
- """
- allow_serial_nos = False
- if sle.voucher_type == "Stock Entry" and cint(sle.actual_qty) > 0:
- stock_entry = frappe.get_cached_doc("Stock Entry", sle.voucher_no)
- if stock_entry.purpose in ("Repack", "Manufacture"):
- for d in stock_entry.get("items"):
- if d.serial_no and (d.s_warehouse if not sle.is_cancelled else d.t_warehouse):
- serial_nos = get_serial_nos(d.serial_no)
- if sle_serial_no in serial_nos:
- allow_serial_nos = True
-
- return allow_serial_nos
-
-
-def update_warehouse_in_serial_no(sle, item_det):
- serial_nos = get_serial_nos(sle.serial_and_batch_bundle)
- serial_no_data = get_serial_nos_warehouse(sle.item_code, serial_nos)
-
- if not serial_no_data:
- for serial_no in serial_nos:
- frappe.db.set_value("Serial No", serial_no, "warehouse", None)
-
- else:
- for row in serial_no_data:
- if not row.serial_no:
- continue
-
- warehouse = row.warehouse if row.actual_qty > 0 else None
- frappe.db.set_value("Serial No", row.serial_no, "warehouse", warehouse)
-
-
-def get_serial_nos_warehouse(item_code, serial_nos):
- ledger_table = frappe.qb.DocType("Serial and Batch Ledger")
- sle_table = frappe.qb.DocType("Stock Ledger Entry")
-
- return (
- frappe.qb.from_(ledger_table)
- .inner_join(sle_table)
- .on(ledger_table.parent == sle_table.serial_and_batch_bundle)
- .select(
- ledger_table.serial_no,
- sle_table.actual_qty,
- ledger_table.warehouse,
- )
- .where(
- (ledger_table.serial_no.isin(serial_nos))
- & (sle_table.is_cancelled == 0)
- & (sle_table.item_code == item_code)
- & (sle_table.serial_and_batch_bundle.isnotnull())
- )
- .orderby(sle_table.posting_date, order=frappe.qb.desc)
- .orderby(sle_table.posting_time, order=frappe.qb.desc)
- .orderby(sle_table.creation, order=frappe.qb.desc)
- .groupby(ledger_table.serial_no)
- ).run(as_dict=True)
-
-
-def create_batch_for_serial_no(sle):
- from erpnext.stock.doctype.batch.batch import make_batch
-
- return make_batch(
- frappe._dict(
- {
- "item": sle.item_code,
- "reference_doctype": sle.voucher_type,
- "reference_name": sle.voucher_no,
- }
- )
- )
-
-
-def auto_create_serial_nos(sle, item_details) -> List[str]:
- sr_nos = []
- serial_nos_details = []
- current_series = frappe.db.sql(
- "select current from `tabSeries` where name = %s", item_details.serial_no_series
- )
-
- for i in range(cint(sle.actual_qty)):
- serial_no = make_autoname(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,
- sle.warehouse,
- sle.company,
- sle.item_code,
- item_details.item_name,
- item_details.description,
- )
- )
-
- if serial_nos_details:
- fields = [
- "name",
- "serial_no",
- "creation",
- "modified",
- "owner",
- "modified_by",
- "warehouse",
- "company",
- "item_code",
- "item_name",
- "description",
- ]
-
- frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details))
-
- return sr_nos
-
-
-def get_auto_serial_nos(serial_no_series, qty):
+def get_auto_serial_nos(serial_no_series, qty) -> List[str]:
serial_nos = []
for i in range(cint(qty)):
serial_nos.append(get_new_serial_number(serial_no_series))
- return "\n".join(serial_nos)
+ return serial_nos
def get_new_serial_number(series):
@@ -534,72 +161,6 @@
return "\n".join(serial_no_list)
-def update_serial_nos_after_submit(controller, parentfield):
- return
- stock_ledger_entries = frappe.db.sql(
- """select voucher_detail_no, serial_no, actual_qty, warehouse
- from `tabStock Ledger Entry` where voucher_type=%s and voucher_no=%s""",
- (controller.doctype, controller.name),
- as_dict=True,
- )
-
- if not stock_ledger_entries:
- return
-
- for d in controller.get(parentfield):
- if d.serial_no:
- continue
-
- update_rejected_serial_nos = (
- True
- if (
- controller.doctype in ("Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt")
- and d.rejected_qty
- )
- else False
- )
- accepted_serial_nos_updated = False
-
- if controller.doctype == "Stock Entry":
- warehouse = d.t_warehouse
- qty = d.transfer_qty
- elif controller.doctype in ("Sales Invoice", "Delivery Note"):
- warehouse = d.warehouse
- qty = d.stock_qty
- else:
- warehouse = d.warehouse
- qty = (
- d.qty
- if controller.doctype in ["Stock Reconciliation", "Subcontracting Receipt"]
- else d.stock_qty
- )
- for sle in stock_ledger_entries:
- if sle.voucher_detail_no == d.name:
- if (
- not accepted_serial_nos_updated
- and qty
- and abs(sle.actual_qty) == abs(qty)
- and sle.warehouse == warehouse
- and sle.serial_no != d.serial_no
- ):
- d.serial_no = sle.serial_no
- frappe.db.set_value(d.doctype, d.name, "serial_no", sle.serial_no)
- accepted_serial_nos_updated = True
- if not update_rejected_serial_nos:
- break
- elif (
- update_rejected_serial_nos
- and abs(sle.actual_qty) == d.rejected_qty
- and sle.warehouse == d.rejected_warehouse
- and sle.serial_no != d.rejected_serial_no
- ):
- d.rejected_serial_no = sle.serial_no
- frappe.db.set_value(d.doctype, d.name, "rejected_serial_no", sle.serial_no)
- update_rejected_serial_nos = False
- if accepted_serial_nos_updated:
- break
-
-
def update_maintenance_status():
serial_nos = frappe.db.sql(
"""select name from `tabSerial No` where (amc_expiry_date<%s or
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index 5e8aff3..d71814b 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -4,6 +4,7 @@
import json
from collections import defaultdict
+from typing import List
import frappe
from frappe import _
@@ -37,8 +38,8 @@
get_bin_details,
get_conversion_factor,
get_default_cost_center,
- get_reserved_qty_for_so,
)
+from erpnext.stock.serial_batch_bundle import get_empty_batches_based_work_order
from erpnext.stock.stock_ledger import NegativeStockError, get_previous_sle, get_valuation_rate
from erpnext.stock.utils import get_bin, get_incoming_rate
@@ -203,13 +204,9 @@
self.repost_future_sle_and_gle()
self.update_cost_in_project()
- self.validate_reserved_serial_no_consumption()
self.update_transferred_qty()
self.update_quality_inspection()
- if self.work_order and self.purpose == "Manufacture":
- self.update_so_in_serial_number()
-
if self.purpose == "Material Transfer" and self.add_to_transit:
self.set_material_request_transfer_status("In Transit")
if self.purpose == "Material Transfer" and self.outgoing_stock_entry:
@@ -359,7 +356,6 @@
def validate_item(self):
stock_items = self.get_stock_items()
- serialized_items = self.get_serialized_items()
for item in self.get("items"):
if flt(item.qty) and flt(item.qty) < 0:
frappe.throw(
@@ -401,16 +397,6 @@
flt(item.qty) * flt(item.conversion_factor), self.precision("transfer_qty", item)
)
- # if (
- # self.purpose in ("Material Transfer", "Material Transfer for Manufacture")
- # and not item.serial_and_batch_bundle
- # and item.item_code in serialized_items
- # ):
- # frappe.throw(
- # _("Row #{0}: Please specify Serial No for Item {1}").format(item.idx, item.item_code),
- # frappe.MandatoryError,
- # )
-
def validate_qty(self):
manufacture_purpose = ["Manufacture", "Material Consumption for Manufacture"]
@@ -1352,7 +1338,6 @@
pro_doc.run_method("update_work_order_qty")
if self.purpose == "Manufacture":
pro_doc.run_method("update_planned_qty")
- pro_doc.update_batch_produced_qty(self)
pro_doc.run_method("update_status")
if not pro_doc.operations:
@@ -1479,8 +1464,6 @@
"ste_detail": d.name,
"stock_uom": d.stock_uom,
"conversion_factor": d.conversion_factor,
- "serial_no": d.serial_no,
- "batch_no": d.batch_no,
},
)
@@ -1651,6 +1634,7 @@
if (
self.work_order
and self.pro_doc.has_batch_no
+ and not self.pro_doc.has_serial_no
and cint(
frappe.db.get_single_value(
"Manufacturing Settings", "make_serial_no_batch_from_work_order", cache=True
@@ -1662,42 +1646,34 @@
self.add_finished_goods(args, item)
def set_batchwise_finished_goods(self, args, item):
- filters = {
- "reference_name": self.pro_doc.name,
- "reference_doctype": self.pro_doc.doctype,
- "qty_to_produce": (">", 0),
- "batch_qty": ("=", 0),
- }
+ batches = get_empty_batches_based_work_order(self.work_order, self.pro_doc.production_item)
- fields = ["qty_to_produce as qty", "produced_qty", "name"]
-
- data = frappe.get_all("Batch", filters=filters, fields=fields, order_by="creation asc")
-
- if not data:
+ if not batches:
self.add_finished_goods(args, item)
else:
- self.add_batchwise_finished_good(data, args, item)
+ self.add_batchwise_finished_good(batches, args, item)
- def add_batchwise_finished_good(self, data, args, item):
+ def add_batchwise_finished_good(self, batches, args, item):
qty = flt(self.fg_completed_qty)
+ row = frappe._dict({"batches_to_be_consume": defaultdict(float)})
- for row in data:
- batch_qty = flt(row.qty) - flt(row.produced_qty)
- if not batch_qty:
- continue
+ self.update_batches_to_be_consume(batches, row, qty)
- if qty <= 0:
- break
+ if not row.batches_to_be_consume:
+ return
- fg_qty = batch_qty
- if batch_qty >= qty:
- fg_qty = qty
+ id = create_serial_and_batch_bundle(
+ row,
+ frappe._dict(
+ {
+ "item_code": self.pro_doc.production_item,
+ "warehouse": args.get("to_warehouse"),
+ }
+ ),
+ )
- qty -= batch_qty
- args["qty"] = fg_qty
- args["batch_no"] = row.name
-
- self.add_finished_goods(args, item)
+ args["serial_and_batch_bundle"] = id
+ self.add_finished_goods(args, item)
def add_finished_goods(self, args, item):
self.add_to_stock_entry_detail({item.name: args}, bom_no=self.bom_no)
@@ -1902,27 +1878,8 @@
if row.batch_details:
row.batches_to_be_consume = defaultdict(float)
- batches = sorted(row.batch_details.items(), key=lambda x: x[0])
- qty_to_be_consumed = qty
- for batch_no, batch_qty in batches:
- if qty_to_be_consumed <= 0 or batch_qty <= 0:
- continue
-
- if batch_qty > qty_to_be_consumed:
- batch_qty = qty_to_be_consumed
-
- row.batches_to_be_consume[batch_no] += batch_qty
-
- if batch_no and row.serial_nos:
- serial_nos = self.get_serial_nos_based_on_transferred_batch(batch_no, row.serial_nos)
- serial_nos = serial_nos[0 : cint(batch_qty)]
-
- # remove consumed serial nos from list
- for sn in serial_nos:
- row.serial_nos.remove(sn)
-
- row.batch_details[batch_no] -= batch_qty
- qty_to_be_consumed -= batch_qty
+ batches = row.batch_details
+ self.update_batches_to_be_consume(batches, row, qty)
elif row.serial_nos:
serial_nos = row.serial_nos[0 : cint(qty)]
@@ -1930,6 +1887,32 @@
self.update_item_in_stock_entry_detail(row, item, qty)
+ def update_batches_to_be_consume(self, batches, row, qty):
+ qty_to_be_consumed = qty
+ batches = sorted(batches.items(), key=lambda x: x[0])
+
+ for batch_no, batch_qty in batches:
+ if qty_to_be_consumed <= 0 or batch_qty <= 0:
+ continue
+
+ if batch_qty > qty_to_be_consumed:
+ batch_qty = qty_to_be_consumed
+
+ row.batches_to_be_consume[batch_no] += batch_qty
+
+ if batch_no and row.serial_nos:
+ serial_nos = self.get_serial_nos_based_on_transferred_batch(batch_no, row.serial_nos)
+ serial_nos = serial_nos[0 : cint(batch_qty)]
+
+ # remove consumed serial nos from list
+ for sn in serial_nos:
+ row.serial_nos.remove(sn)
+
+ if "batch_details" in row:
+ row.batch_details[batch_no] -= batch_qty
+
+ qty_to_be_consumed -= batch_qty
+
def update_item_in_stock_entry_detail(self, row, item, qty) -> None:
if not qty:
return
@@ -1939,7 +1922,7 @@
"to_warehouse": "",
"qty": qty,
"item_name": item.item_name,
- "serial_and_batch_bundle": create_serial_and_batch_bundle(row, item),
+ "serial_and_batch_bundle": create_serial_and_batch_bundle(row, item, "Outward"),
"description": item.description,
"stock_uom": item.stock_uom,
"expense_account": item.expense_account,
@@ -2099,8 +2082,6 @@
"expense_account",
"description",
"item_name",
- "serial_no",
- "batch_no",
"serial_and_batch_bundle",
"allow_zero_valuation_rate",
]:
@@ -2210,42 +2191,6 @@
stock_bin = get_bin(item_code, reserve_warehouse)
stock_bin.update_reserved_qty_for_sub_contracting()
- def update_so_in_serial_number(self):
- so_name, item_code = frappe.db.get_value(
- "Work Order", self.work_order, ["sales_order", "production_item"]
- )
- if so_name and item_code:
- qty_to_reserve = get_reserved_qty_for_so(so_name, item_code)
- if qty_to_reserve:
- reserved_qty = frappe.db.sql(
- """select count(name) from `tabSerial No` where item_code=%s and
- sales_order=%s""",
- (item_code, so_name),
- )
- if reserved_qty and reserved_qty[0][0]:
- qty_to_reserve -= reserved_qty[0][0]
- if qty_to_reserve > 0:
- for item in self.items:
- has_serial_no = frappe.get_cached_value("Item", item.item_code, "has_serial_no")
- if item.item_code == item_code and has_serial_no:
- serial_nos = (item.serial_no).split("\n")
- for serial_no in serial_nos:
- if qty_to_reserve > 0:
- frappe.db.set_value("Serial No", serial_no, "sales_order", so_name)
- qty_to_reserve -= 1
-
- def validate_reserved_serial_no_consumption(self):
- for item in self.items:
- if item.s_warehouse and not item.t_warehouse and item.serial_no:
- for sr in get_serial_nos(item.serial_no):
- sales_order = frappe.db.get_value("Serial No", sr, "sales_order")
- if sales_order:
- msg = _(
- "(Serial No: {0}) cannot be consumed as it's reserverd to fullfill Sales Order {1}."
- ).format(sr, sales_order)
-
- frappe.throw(_("Item {0} {1}").format(item.item_code, msg))
-
def update_transferred_qty(self):
if self.purpose == "Material Transfer" and self.outgoing_stock_entry:
stock_entries = {}
@@ -2338,40 +2283,48 @@
frappe.db.set_value("Material Request", material_request, "transfer_status", status)
def set_serial_no_batch_for_finished_good(self):
- serial_nos = []
- if self.pro_doc.serial_no:
- serial_nos = self.get_serial_nos_for_fg() or []
+ if not (
+ (self.pro_doc.has_serial_no or self.pro_doc.has_batch_no)
+ and frappe.db.get_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order")
+ ):
+ return
- for row in self.items:
- if row.is_finished_item and row.item_code == self.pro_doc.production_item:
+ for d in self.items:
+ if d.is_finished_item and d.item_code == self.pro_doc.production_item:
+ serial_nos = self.get_available_serial_nos()
if serial_nos:
- row.serial_no = "\n".join(serial_nos[0 : cint(row.qty)])
+ row = frappe._dict({"serial_nos": serial_nos[0 : cint(d.qty)]})
- def get_serial_nos_for_fg(self):
- fields = [
- "`tabStock Entry`.`name`",
- "`tabStock Entry Detail`.`qty`",
- "`tabStock Entry Detail`.`serial_no`",
- "`tabStock Entry Detail`.`batch_no`",
- ]
+ id = create_serial_and_batch_bundle(
+ row,
+ frappe._dict(
+ {
+ "item_code": d.item_code,
+ "warehouse": d.t_warehouse,
+ }
+ ),
+ )
- filters = [
- ["Stock Entry", "work_order", "=", self.work_order],
- ["Stock Entry", "purpose", "=", "Manufacture"],
- ["Stock Entry", "docstatus", "<", 2],
- ["Stock Entry Detail", "item_code", "=", self.pro_doc.production_item],
- ]
+ d.serial_and_batch_bundle = id
- stock_entries = frappe.get_all("Stock Entry", fields=fields, filters=filters)
- return self.get_available_serial_nos(stock_entries)
+ def get_available_serial_nos(self) -> List[str]:
+ serial_nos = []
+ data = frappe.get_all(
+ "Serial No",
+ filters={
+ "item_code": self.pro_doc.production_item,
+ "warehouse": ("is", "not set"),
+ "status": "Inactive",
+ "work_order": self.pro_doc.name,
+ },
+ fields=["name"],
+ order_by="creation asc",
+ )
- def get_available_serial_nos(self, stock_entries):
- used_serial_nos = []
- for row in stock_entries:
- if row.serial_no:
- used_serial_nos.extend(get_serial_nos(row.serial_no))
+ for row in data:
+ serial_nos.append(row.name)
- return sorted(list(set(get_serial_nos(self.pro_doc.serial_no)) - set(used_serial_nos)))
+ return serial_nos
def update_subcontracting_order_status(self):
if self.subcontracting_order and self.purpose in ["Send to Subcontractor", "Material Transfer"]:
@@ -2847,14 +2800,24 @@
return data
-def create_serial_and_batch_bundle(row, child):
+def create_serial_and_batch_bundle(row, child, type_of_transaction=None):
+ item_details = frappe.get_cached_value(
+ "Item", child.item_code, ["has_serial_no", "has_batch_no"], as_dict=1
+ )
+
+ if not (item_details.has_serial_no or item_details.has_batch_no):
+ return
+
+ if not type_of_transaction:
+ type_of_transaction = "Inward"
+
doc = frappe.get_doc(
{
"doctype": "Serial and Batch Bundle",
"voucher_type": "Stock Entry",
"item_code": child.item_code,
"warehouse": child.warehouse,
- "type_of_transaction": "Outward",
+ "type_of_transaction": type_of_transaction,
}
)
diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py
index 3b01287..56802d9 100644
--- a/erpnext/stock/get_item_details.py
+++ b/erpnext/stock/get_item_details.py
@@ -127,8 +127,6 @@
out.update(data)
- update_stock(args, out)
-
if args.transaction_date and item.lead_time_days:
out.schedule_date = out.lead_time_date = add_days(args.transaction_date, item.lead_time_days)
@@ -150,28 +148,6 @@
return details
-def update_stock(args, out):
- if (
- (
- args.get("doctype") == "Delivery Note"
- or (args.get("doctype") == "Sales Invoice" and args.get("update_stock"))
- )
- and out.warehouse
- and out.stock_qty > 0
- ):
- if out.has_serial_no and args.get("batch_no"):
- reserved_so = get_so_reservation_for_item(args)
- out.batch_no = args.get("batch_no")
- out.serial_no = get_serial_no(out, args.serial_no, sales_order=reserved_so)
-
- elif out.has_serial_no:
- reserved_so = get_so_reservation_for_item(args)
- out.serial_no = get_serial_no(out, args.serial_no, sales_order=reserved_so)
-
- if not out.serial_no:
- out.pop("serial_no", None)
-
-
def set_valuation_rate(out, args):
if frappe.db.exists("Product Bundle", args.item_code, cache=True):
valuation_rate = 0.0
@@ -1490,41 +1466,3 @@
blanket_order_details = blanket_order_details[0] if blanket_order_details else ""
return blanket_order_details
-
-
-def get_so_reservation_for_item(args):
- reserved_so = None
- if args.get("against_sales_order"):
- if get_reserved_qty_for_so(args.get("against_sales_order"), args.get("item_code")):
- reserved_so = args.get("against_sales_order")
- elif args.get("against_sales_invoice"):
- sales_order = frappe.db.get_all(
- "Sales Invoice Item",
- filters={
- "parent": args.get("against_sales_invoice"),
- "item_code": args.get("item_code"),
- "docstatus": 1,
- },
- fields="sales_order",
- )
- if sales_order and sales_order[0]:
- if get_reserved_qty_for_so(sales_order[0].sales_order, args.get("item_code")):
- reserved_so = sales_order[0]
- elif args.get("sales_order"):
- if get_reserved_qty_for_so(args.get("sales_order"), args.get("item_code")):
- reserved_so = args.get("sales_order")
- return reserved_so
-
-
-def get_reserved_qty_for_so(sales_order, item_code):
- reserved_qty = frappe.db.get_value(
- "Sales Order Item",
- filters={
- "parent": sales_order,
- "item_code": item_code,
- "ensure_delivery_based_on_produced_serial_no": 1,
- },
- fieldname="sum(qty)",
- )
-
- return reserved_qty or 0
diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py
index 2b88e8b..e375223 100644
--- a/erpnext/stock/serial_batch_bundle.py
+++ b/erpnext/stock/serial_batch_bundle.py
@@ -586,3 +586,62 @@
def get_incoming_rate(self):
return abs(flt(self.stock_value_change) / flt(self.sle.actual_qty))
+
+
+def get_empty_batches_based_work_order(work_order, item_code):
+ batches = get_batches_from_work_order(work_order)
+ if not batches:
+ return batches
+
+ entries = get_batches_from_stock_entries(work_order)
+ if not entries:
+ return batches
+
+ ids = [d.serial_and_batch_bundle for d in entries if d.serial_and_batch_bundle]
+ if ids:
+ set_batch_details_from_package(ids, batches)
+
+ # Will be deprecated in v16
+ for d in entries:
+ if not d.batch_no:
+ continue
+
+ batches[d.batch_no] -= d.qty
+
+ return batches
+
+
+def get_batches_from_work_order(work_order):
+ return frappe._dict(
+ frappe.get_all(
+ "Batch", fields=["name", "qty_to_produce"], filters={"reference_name": work_order}, as_list=1
+ )
+ )
+
+
+def get_batches_from_stock_entries(work_order):
+ entries = frappe.get_all(
+ "Stock Entry",
+ filters={"work_order": work_order, "docstatus": 1, "purpose": "Manufacture"},
+ fields=["name"],
+ )
+
+ return frappe.get_all(
+ "Stock Entry Detail",
+ fields=["batch_no", "qty", "serial_and_batch_bundle"],
+ filters={
+ "parent": ("in", [d.name for d in entries]),
+ "is_finished_item": 1,
+ },
+ )
+
+
+def set_batch_details_from_package(ids, batches):
+ entries = frappe.get_all(
+ "Serial and Batch Ledger",
+ filters={"parent": ("in", ids), "is_outward": 0},
+ fields=["batch_no", "qty"],
+ )
+
+ for d in entries:
+ batches[d.batch_no] -= d.qty