refactor: serial and batch package creation for finished item and cleanup code

This commit is contained in:
Rohit Waghchaure 2023-03-20 22:56:06 +05:30
parent 86da306cca
commit 16f26fb3d8
11 changed files with 220 additions and 699 deletions

View File

@ -328,8 +328,6 @@ class AssetCapitalization(StockController):
{
"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),
},

View File

@ -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 @@ class BuyingController(SubcontractingController):
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 @@ class BuyingController(SubcontractingController):
{
"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,
},

View File

@ -407,7 +407,6 @@ class StockController(AccountsController):
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 @@ class StockController(AccountsController):
),
"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,
}

View File

@ -8,7 +8,7 @@ from collections import defaultdict
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 @@ class SubcontractingController(StockController):
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 @@ class SubcontractingController(StockController):
{
"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,
},
)

View File

@ -42,7 +42,6 @@
"has_serial_no",
"has_batch_no",
"column_break_18",
"serial_no",
"batch_size",
"required_items_section",
"materials_and_operations_tab",
@ -532,14 +531,6 @@
"label": "Has Batch No",
"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",

View File

@ -17,6 +17,7 @@ from frappe.utils import (
get_datetime,
get_link_to_form,
getdate,
now,
nowdate,
time_diff_in_hours,
)
@ -32,11 +33,7 @@ from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings
)
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 @@ class WorkOrder(Document):
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 @@ class WorkOrder(Document):
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

View File

@ -27,8 +27,8 @@ class SerialandBatchBundle(Document):
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 @@ class SerialandBatchBundle(Document):
)
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 @@ class SerialandBatchBundle(Document):
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 @@ class SerialandBatchBundle(Document):
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()

View File

@ -9,10 +9,9 @@ import frappe
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 @@ class SerialNo(StockController):
)
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 @@ def clean_serial_no_string(serial_no: str) -> str:
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

View File

@ -4,6 +4,7 @@
import json
from collections import defaultdict
from typing import List
import frappe
from frappe import _
@ -37,8 +38,8 @@ from erpnext.stock.get_item_details import (
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 @@ class StockEntry(StockController):
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 @@ class StockEntry(StockController):
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 @@ class StockEntry(StockController):
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 @@ class StockEntry(StockController):
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 @@ class StockEntry(StockController):
"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 @@ class StockEntry(StockController):
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 @@ class StockEntry(StockController):
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 @@ class StockEntry(StockController):
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 @@ class StockEntry(StockController):
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 @@ class StockEntry(StockController):
"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 @@ class StockEntry(StockController):
"expense_account",
"description",
"item_name",
"serial_no",
"batch_no",
"serial_and_batch_bundle",
"allow_zero_valuation_rate",
]:
@ -2210,42 +2191,6 @@ class StockEntry(StockController):
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 @@ class StockEntry(StockController):
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 @@ def get_stock_entry_data(work_order):
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,
}
)

View File

@ -127,8 +127,6 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru
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 @@ def remove_standard_fields(details):
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 @@ def get_blanket_order_details(args):
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

View File

@ -586,3 +586,62 @@ class BatchNoBundleValuation(DeprecatedBatchNoValuation):
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