diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py
index 30f8469ff00..0ab520d8548 100644
--- a/erpnext/controllers/subcontracting_controller.py
+++ b/erpnext/controllers/subcontracting_controller.py
@@ -1403,16 +1403,18 @@ def make_rm_stock_entry(
items_dict = {
rm_item_code: {
rm_detail_field: rm_item.get("name"),
+ "item_code": rm_item_code,
"item_name": rm_item.get("item_name")
or item_wh.get(rm_item_code, {}).get("item_name", ""),
"description": item_wh.get(rm_item_code, {}).get("description", ""),
"qty": qty,
- "from_warehouse": rm_item.get("warehouse")
+ "s_warehouse": rm_item.get("warehouse")
or rm_item.get("reserve_warehouse"),
- "to_warehouse": source_doc.supplier_warehouse,
+ "t_warehouse": source_doc.supplier_warehouse,
"stock_uom": rm_item.get("stock_uom"),
"serial_and_batch_bundle": rm_item.get("serial_and_batch_bundle"),
"main_item_code": fg_item_code,
+ "subcontracted_item": fg_item_code,
"allow_alternative_item": item_wh.get(rm_item_code, {}).get(
"allow_alternative_item"
),
@@ -1426,7 +1428,7 @@ def make_rm_stock_entry(
}
}
- target_doc.add_to_stock_entry_detail(items_dict)
+ target_doc.append("items", items_dict[rm_item_code])
stock_entry = get_mapped_doc(
order_doctype,
diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py
index a0e4d248af8..bbec18036fe 100644
--- a/erpnext/manufacturing/doctype/bom/bom.py
+++ b/erpnext/manufacturing/doctype/bom/bom.py
@@ -2023,10 +2023,10 @@ def get_secondary_items_from_sub_assemblies(bom_no, company, qty, secondary_item
return secondary_items
-def get_backflush_based_on(bom_no):
+def get_backflush_based_on(bom_no=None):
backflush_based_on = None
if bom_no:
- backflush_based_on = frappe.get_cached_value("BOM", bom_no, "backflush_based_on")
+ backflush_based_on = frappe.db.get_value("BOM", bom_no, "backflush_based_on")
if not backflush_based_on:
backflush_based_on = frappe.db.get_single_value(
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py
index b3d4301addf..4e8fbfafcd4 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/job_card.py
@@ -1476,6 +1476,8 @@ class JobCard(Document):
@frappe.whitelist()
def make_stock_entry_for_semi_fg_item(self, auto_submit: bool = False):
+ from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import ManufactureStockEntry
+
def get_consumed_process_loss():
table = frappe.qb.DocType("Stock Entry")
query = (
@@ -1511,9 +1513,7 @@ class JobCard(Document):
ste.stock_entry.flags.ignore_mandatory = True
wo_doc = frappe.get_doc("Work Order", self.work_order)
add_additional_cost(ste.stock_entry, wo_doc, self)
-
- ste.stock_entry.pro_doc = frappe.get_doc("Work Order", self.work_order)
- ste.stock_entry.set_secondary_items_from_job_card()
+ ManufactureStockEntry(ste.stock_entry).add_secondary_items_from_job_card()
for row in ste.stock_entry.items:
if (row.type or row.is_legacy_scrap_item) and not row.t_warehouse:
row.t_warehouse = self.target_warehouse
diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py
index 4e48224032b..4ae120ece7f 100644
--- a/erpnext/manufacturing/doctype/work_order/test_work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py
@@ -31,6 +31,7 @@ from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle
)
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.stock_entry import test_stock_entry
+from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import ManufactureStockEntry
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.utils import get_bin
from erpnext.tests.utils import ERPNextTestSuite
@@ -683,7 +684,10 @@ class TestWorkOrder(ERPNextTestSuite):
def test_cost_center_for_manufacture(self):
wo_order = make_wo_order_test_record()
- ste = make_stock_entry(wo_order.name, "Material Transfer for Manufacture", wo_order.qty)
+ ste = frappe.get_doc(
+ make_stock_entry(wo_order.name, "Material Transfer for Manufacture", wo_order.qty)
+ )
+ ste.save()
self.assertEqual(ste.get("items")[0].get("cost_center"), "_Test Cost Center - _TC")
def test_operation_time_with_batch_size(self):
@@ -1319,7 +1323,6 @@ class TestWorkOrder(ERPNextTestSuite):
stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10))
stock_entry.set_work_order_details()
- stock_entry.set_serial_no_batch_for_finished_good()
for row in stock_entry.items:
if row.item_code == fg_item:
self.assertTrue(row.serial_and_batch_bundle)
@@ -1360,7 +1363,6 @@ class TestWorkOrder(ERPNextTestSuite):
stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10))
stock_entry.set_work_order_details()
- stock_entry.set_serial_no_batch_for_finished_good()
for row in stock_entry.items:
if row.item_code == fg_item:
self.assertTrue(row.serial_and_batch_bundle)
@@ -4291,7 +4293,6 @@ class TestWorkOrder(ERPNextTestSuite):
)
material_transfer_entry.submit()
-
manufacture_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 1))
manufacture_entry.save()
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index 70d9863b876..4f99539a7da 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -2087,8 +2087,9 @@ class WorkOrder(Document):
additional_items = frappe._dict()
for row in stock_entry.items:
- if row.item_code not in required_items:
- additional_items.setdefault(row.item_code, []).append(row)
+ item_code = row.original_item if row.original_item else row.item_code
+ if item_code not in required_items:
+ additional_items.setdefault(item_code, []).append(row)
self.flags.ignore_validate_update_after_submit = True
@@ -2453,10 +2454,6 @@ def make_stock_entry(
stock_entry.set_stock_entry_type()
stock_entry.is_additional_transfer_entry = is_additional_transfer_entry
stock_entry.get_items()
- stock_entry.set_secondary_items_from_job_card()
-
- if purpose != "Disassemble":
- stock_entry.set_serial_no_batch_for_finished_good()
return stock_entry.as_dict()
@@ -2817,11 +2814,9 @@ def get_reserved_qty_for_production(
@frappe.whitelist()
def make_stock_return_entry(work_order: str):
- from erpnext.stock.doctype.stock_entry.stock_entry import get_available_materials
-
- non_consumed_items = get_available_materials(work_order)
- if not non_consumed_items:
- return
+ from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import (
+ ManufactureStockEntry,
+ )
wo_doc = frappe.get_cached_doc("Work Order", work_order)
@@ -2831,9 +2826,11 @@ def make_stock_return_entry(work_order: str):
stock_entry.work_order = work_order
stock_entry.purpose = "Material Transfer for Manufacture"
stock_entry.bom_no = wo_doc.bom_no
- stock_entry.add_transfered_raw_materials_in_items()
stock_entry.set_stock_entry_type()
+ ste_cls = ManufactureStockEntry(stock_entry)
+ ste_cls.add_raw_materials_based_on_transfer()
+ ste_cls.return_available_materials_in_source_wh()
return stock_entry
diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py
index 92d677e6c60..da05691d3fa 100644
--- a/erpnext/projects/doctype/project/project.py
+++ b/erpnext/projects/doctype/project/project.py
@@ -91,14 +91,14 @@ class Project(Document):
def validate(self):
if not self.is_new():
- self.copy_from_template() # nosemgrep
+ self.copy_from_template()
self.send_welcome_email()
self.update_costing()
self.update_percent_complete()
self.validate_from_to_dates("expected_start_date", "expected_end_date")
self.validate_from_to_dates("actual_start_date", "actual_end_date")
- def copy_from_template(self): # nosemgrep
+ def copy_from_template(self, trigger=None):
"""
Copy tasks from template
"""
@@ -107,11 +107,15 @@ class Project(Document):
if not self.expected_start_date:
# project starts today
self.expected_start_date = today()
+ if trigger == "after_insert":
+ self.db_set("expected_start_date", self.expected_start_date)
template = frappe.get_doc("Project Template", self.project_template)
if not self.project_type:
self.project_type = template.project_type
+ if trigger == "after_insert":
+ self.db_set("project_type", self.project_type)
# create tasks from template
project_tasks = []
@@ -164,6 +168,40 @@ class Project(Document):
self.check_depends_on_value(template_task, project_task, project_tasks)
self.check_for_parent_tasks(template_task, project_task, project_tasks)
+ def set_consumed_material_cost(self):
+ parent_doc = frappe.qb.DocType("Stock Entry")
+ child_doc = frappe.qb.DocType("Stock Entry Detail")
+ lcv_doc = frappe.qb.DocType("Landed Cost Taxes and Charges")
+
+ amount = (
+ qb.from_(child_doc)
+ .select(Sum(child_doc.amount))
+ .where(
+ (child_doc.project == self.name)
+ & (child_doc.docstatus == 1)
+ & ((child_doc.t_warehouse.isnull()) | (child_doc.t_warehouse == ""))
+ )
+ ).run(as_list=1)
+
+ amount = flt(amount[0][0]) if amount else 0
+
+ additional_costs = (
+ qb.from_(parent_doc)
+ .join(lcv_doc)
+ .on(parent_doc.name == lcv_doc.parent)
+ .select(Sum(lcv_doc.base_amount))
+ .where(
+ (parent_doc.project == self.name)
+ & (parent_doc.docstatus == 1)
+ & (parent_doc.purpose == "Manufacture")
+ )
+ ).run(as_list=1)
+
+ additional_cost_amt = flt(additional_costs[0][0]) if additional_costs else 0
+
+ amount += additional_cost_amt
+ self.total_consumed_material_cost = amount
+
def check_depends_on_value(self, template_task, project_task, project_tasks):
if template_task.get("depends_on") and not project_task.get("depends_on"):
project_template_map = {pt.template_task: pt for pt in project_tasks}
@@ -201,7 +239,7 @@ class Project(Document):
self.db_update()
def after_insert(self):
- self.copy_from_template() # nosemgrep
+ self.copy_from_template("after_insert")
if self.sales_order:
frappe.db.set_value("Sales Order", self.sales_order, "project", self.name)
diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py
index 2a0d85f66b4..8d8239626a1 100644
--- a/erpnext/stock/doctype/material_request/material_request.py
+++ b/erpnext/stock/doctype/material_request/material_request.py
@@ -768,7 +768,6 @@ def make_stock_entry(source_name: str, target_doc: str | Document | None = None)
target.set_actual_qty()
target.calculate_rate_and_amount(raise_error_if_no_rate=False)
target.stock_entry_type = target.purpose
- target.set_job_card_data()
if source.job_card:
job_card_details = frappe.get_all(
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js
index 5ed90fca743..4e959229e15 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js
@@ -342,7 +342,7 @@ erpnext.stock.PurchaseReceiptController = class PurchaseReceiptController extend
make_retention_stock_entry() {
frappe.call({
- method: "erpnext.stock.doctype.stock_entry.stock_entry.move_sample_to_retention_warehouse",
+ method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing.move_sample_to_retention_warehouse",
args: {
company: cur_frm.doc.company,
items: cur_frm.doc.items,
@@ -455,7 +455,7 @@ var validate_sample_quantity = function (frm, cdt, cdn) {
var d = locals[cdt][cdn];
if (d.sample_quantity && d.qty) {
frappe.call({
- method: "erpnext.stock.doctype.stock_entry.stock_entry.validate_sample_quantity",
+ method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing.validate_sample_quantity",
args: {
batch_no: d.batch_no,
item_code: d.item_code,
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js
index c5db730fdfb..3e5a8a10a8d 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.js
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.js
@@ -496,7 +496,7 @@ frappe.ui.form.on("Stock Entry", {
__("Expired Batches"),
function () {
frappe.call({
- method: "erpnext.stock.doctype.stock_entry.stock_entry.get_expired_batch_items",
+ method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.serial_batch.get_expired_batch_items",
freeze: true,
callback: function (r) {
if (!r.exc && r.message) {
@@ -670,7 +670,7 @@ frappe.ui.form.on("Stock Entry", {
make_retention_stock_entry: function (frm) {
frappe.call({
- method: "erpnext.stock.doctype.stock_entry.stock_entry.move_sample_to_retention_warehouse",
+ method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing.move_sample_to_retention_warehouse",
args: {
company: frm.doc.company,
items: frm.doc.items,
@@ -939,7 +939,7 @@ frappe.ui.form.on("Stock Entry", {
if (frm.doc.purchase_order) {
frm.set_value("subcontracting_order", "");
erpnext.utils.map_current_doc({
- method: "erpnext.stock.doctype.stock_entry.stock_entry.get_items_from_subcontract_order",
+ method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.subcontracting.get_items_from_subcontract_order",
source_name: frm.doc.purchase_order,
target_doc: frm,
freeze: true,
@@ -951,7 +951,7 @@ frappe.ui.form.on("Stock Entry", {
if (frm.doc.subcontracting_order) {
frm.set_value("purchase_order", "");
erpnext.utils.map_current_doc({
- method: "erpnext.stock.doctype.stock_entry.stock_entry.get_items_from_subcontract_order",
+ method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.subcontracting.get_items_from_subcontract_order",
source_name: frm.doc.subcontracting_order,
target_doc: frm,
freeze: true,
@@ -1150,7 +1150,7 @@ var validate_sample_quantity = function (frm, cdt, cdn) {
var d = locals[cdt][cdn];
if (d.sample_quantity && d.transfer_qty && frm.doc.purpose == "Material Receipt") {
frappe.call({
- method: "erpnext.stock.doctype.stock_entry.stock_entry.validate_sample_quantity",
+ method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing.validate_sample_quantity",
args: {
batch_no: d.batch_no,
item_code: d.item_code,
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json
index 81cbad37c24..f6f2d821cc8 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.json
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.json
@@ -46,6 +46,7 @@
"target_address_display",
"sb0",
"scan_barcode",
+ "column_break_menu",
"last_scanned_warehouse",
"items_section",
"items",
@@ -369,6 +370,7 @@
{
"fieldname": "sb0",
"fieldtype": "Section Break",
+ "hide_border": 1,
"options": "Simple"
},
{
@@ -646,7 +648,7 @@
"depends_on": "eval:in_list([\"Material Issue\", \"Manufacture\", \"Repack\", \"Send to Subcontractor\", \"Material Transfer for Manufacture\", \"Material Consumption for Manufacture\", \"Disassemble\"], doc.purpose)",
"fieldname": "bom_info_section",
"fieldtype": "Section Break",
- "label": "BOM Info"
+ "label": "Bill of Materials"
},
{
"collapsible": 1,
@@ -695,8 +697,7 @@
},
{
"fieldname": "items_section",
- "fieldtype": "Section Break",
- "label": "Items"
+ "fieldtype": "Section Break"
},
{
"depends_on": "eval:doc.asset_repair",
@@ -761,6 +762,10 @@
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center"
+ },
+ {
+ "fieldname": "column_break_menu",
+ "fieldtype": "Column Break"
}
],
"grid_page_length": 50,
@@ -769,7 +774,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2026-03-04 19:03:23.426082",
+ "modified": "2026-04-21 13:31:48.817309",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Entry",
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index f10f7db755c..16a8634c416 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -36,12 +36,8 @@ from erpnext.manufacturing.doctype.bom.bom import (
)
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_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 (
- OpeningEntryAccountError,
-)
from erpnext.stock.get_item_details import (
ItemDetailsCtx,
get_barcode_data,
@@ -51,11 +47,27 @@ from erpnext.stock.get_item_details import (
)
from erpnext.stock.serial_batch_bundle import (
SerialBatchCreation,
+ get_batch_nos,
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_combine_datetime, get_incoming_rate
+from erpnext.stock.stock_ledger import get_previous_sle, get_valuation_rate
+from erpnext.stock.utils import get_incoming_rate
+
+from .stock_entry_handler.disassemble import DisassembleStockEntry
+from .stock_entry_handler.manufacturing import (
+ ManufactureStockEntry,
+ MaterialConsumptionForManufactureStockEntry,
+ RepackStockEntry,
+)
+from .stock_entry_handler.material_receipt_issue import MaterialIssueStockEntry, MaterialReceiptStockEntry
+from .stock_entry_handler.material_transfer import (
+ MaterialRequestStockEntry,
+ MaterialTransferForManufactureStockEntry,
+ MaterialTransferStockEntry,
+)
+from .stock_entry_handler.serial_batch import StockEntrySABB
+from .stock_entry_handler.subcontracting import SendToSubcontractorStockEntry
class FinishedGoodError(frappe.ValidationError):
@@ -66,14 +78,6 @@ class IncorrectValuationRateError(frappe.ValidationError):
pass
-class DuplicateEntryForWorkOrderError(frappe.ValidationError):
- pass
-
-
-class OperationsNotCompleteError(frappe.ValidationError):
- pass
-
-
class MaxSampleAlreadyRetainedError(frappe.ValidationError):
pass
@@ -170,8 +174,15 @@ class StockEntry(StockController, SubcontractingInwardController):
work_order: DF.Link | None
# end: auto-generated types
+ def __setattr__(self, name, value):
+ super().__setattr__(name, value)
+ if name == "purpose":
+ self._configure_purpose_class()
+
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
+ self._configure_purpose_class()
+
if self.subcontracting_inward_order:
self.subcontract_data = frappe._dict(
{
@@ -191,6 +202,36 @@ class StockEntry(StockController, SubcontractingInwardController):
}
)
+ def _configure_purpose_class(self):
+ purpose_map = {
+ "Manufacture": ManufactureStockEntry,
+ "Repack": RepackStockEntry,
+ "Material Transfer": MaterialTransferStockEntry,
+ "Material Transfer for Manufacture": MaterialTransferForManufactureStockEntry,
+ "Material Consumption for Manufacture": MaterialConsumptionForManufactureStockEntry,
+ "Disassemble": DisassembleStockEntry,
+ "Send to Subcontractor": SendToSubcontractorStockEntry,
+ "Material Issue": MaterialIssueStockEntry,
+ "Material Receipt": MaterialReceiptStockEntry,
+ }
+
+ self.purpose_cls = purpose_map.get(self.purpose)
+
+ if self.purpose == "Material Transfer" and self.transfer_for_material_request():
+ self.purpose_cls = MaterialRequestStockEntry
+
+ def transfer_for_material_request(self):
+ if self.outgoing_stock_entry and frappe.get_all(
+ "Stock Entry Detail",
+ filters={"parent": self.outgoing_stock_entry, "material_request": ("is", "set")},
+ pluck="name",
+ ):
+ return True
+
+ for item in self.items:
+ if item.material_request:
+ return True
+
def onload(self):
self.update_items_from_bin_details()
@@ -211,6 +252,11 @@ class StockEntry(StockController, SubcontractingInwardController):
def before_validate(self):
from erpnext.stock.doctype.putaway_rule.putaway_rule import apply_putaway_rule
+ if self.purpose_cls and hasattr(self.purpose_cls, "before_validate"):
+ self.purpose_cls(self).before_validate()
+
+ self.set_default_cost_center()
+
apply_rule = self.apply_putaway_rule and (self.purpose in ["Material Transfer", "Material Receipt"])
if self.get("items") and apply_rule:
@@ -224,21 +270,29 @@ class StockEntry(StockController, SubcontractingInwardController):
if not item.project:
item.project = self.project
+ def set_default_cost_center(self):
+ for row in self.items:
+ if not row.cost_center:
+ row.cost_center = get_default_cost_center(
+ row,
+ row,
+ get_item_group_defaults(row.item_code, self.company),
+ get_brand_defaults(row.item_code, self.company),
+ self.company,
+ )
+
def validate(self):
- self.pro_doc = frappe._dict()
- if self.work_order:
- self.pro_doc = frappe.get_doc("Work Order", self.work_order)
+ if self.purpose_cls:
+ self.purpose_cls(self).validate()
self.validate_duplicate_serial_and_batch_bundle("items")
self.validate_posting_time()
- self.validate_purpose()
self.validate_item()
self.validate_customer_provided_item()
self.set_transfer_qty()
self.validate_uom_is_integer("uom", "qty")
self.validate_uom_is_integer("stock_uom", "transfer_qty")
self.validate_warehouse_of_sabb()
- self.validate_work_order()
self.validate_source_stock_entry()
self.validate_bom()
self.set_process_loss_qty()
@@ -251,201 +305,37 @@ class StockEntry(StockController, SubcontractingInwardController):
else:
self.validate_job_card_fg_item()
- self.validate_warehouse()
- self.validate_with_material_request()
self.validate_batch()
self.validate_inspection()
self.validate_fg_completed_qty()
self.validate_difference_account()
- self.set_job_card_data()
self.validate_job_card_item()
self.set_purpose_for_stock_entry()
self.clean_serial_nos()
- self.validate_repack_entry()
-
- if not self.from_bom:
- self.fg_completed_qty = 0.0
-
- self.make_serial_and_batch_bundle_for_outward()
+ self.remove_fg_completed_qty()
self.validate_serialized_batch()
self.calculate_rate_and_amount()
self.validate_putaway_capacity()
- self.validate_component_and_quantities()
-
- if self.get("purpose") != "Manufacture":
- # ignore other item wh difference and empty source/target wh
- # in Manufacture Entry
- self.reset_default_field_value("from_warehouse", "items", "s_warehouse")
- self.reset_default_field_value("to_warehouse", "items", "t_warehouse")
-
- self.validate_same_source_target_warehouse_during_material_transfer()
-
self.validate_closed_subcontracting_order()
- self.validate_subcontract_order()
- self.validate_raw_materials_exists()
-
super().validate_subcontracting_inward()
- def validate_repack_entry(self):
- if self.purpose != "Repack":
- return
+ def remove_fg_completed_qty(self):
+ if not self.from_bom and self.fg_completed_qty:
+ self.fg_completed_qty = 0.0
- fg_items = {row.item_code: row for row in self.items if row.is_finished_item}
-
- if len(fg_items) > 1 and not all(row.set_basic_rate_manually for row in fg_items.values()):
- frappe.throw(
- _(
- "When there are multiple finished goods ({0}) in a Repack stock entry, the basic rate for all finished goods must be set manually. To set rate manually, enable the checkbox 'Set Basic Rate Manually' in the respective finished good row."
- ).format(", ".join(fg_items)),
- title=_("Set Basic Rate Manually"),
- )
-
- def validate_raw_materials_exists(self):
- if self.purpose not in ["Manufacture", "Repack", "Disassemble"]:
- return
-
- if frappe.db.get_single_value("Manufacturing Settings", "material_consumption"):
- return
-
- raw_materials = []
- for row in self.items:
- if row.s_warehouse:
- raw_materials.append(row.item_code)
-
- if not raw_materials:
- frappe.throw(
- _(
- "At least one raw material item must be present in the stock entry for the type {0}"
- ).format(bold(self.purpose)),
- title=_("Raw Materials Missing"),
- )
-
- def set_serial_batch_for_disassembly(self):
- if self.purpose != "Disassemble":
- return
-
- if self.get("source_stock_entry"):
- self._set_serial_batch_for_disassembly_from_stock_entry()
- else:
- self._set_serial_batch_for_disassembly_from_available_materials()
-
- def _set_serial_batch_for_disassembly_from_stock_entry(self):
- from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
- get_voucher_wise_serial_batch_from_bundle,
- )
-
- source_fg_qty = flt(frappe.db.get_value("Stock Entry", self.source_stock_entry, "fg_completed_qty"))
- scale_factor = flt(self.fg_completed_qty) / source_fg_qty if source_fg_qty else 0
-
- bundle_data = get_voucher_wise_serial_batch_from_bundle(voucher_no=[self.source_stock_entry])
- source_rows_by_name = {r.name: r for r in self.get_items_from_manufacture_stock_entry()}
-
- for row in self.items:
- if not row.ste_detail:
- continue
-
- source_row = source_rows_by_name.get(row.ste_detail)
- if not source_row:
- continue
-
- source_warehouse = source_row.s_warehouse or source_row.t_warehouse
- key = (source_row.item_code, source_warehouse, self.source_stock_entry)
- source_bundle = bundle_data.get(key, {})
-
- batches = defaultdict(float)
- serial_nos = []
-
- if source_bundle.get("batch_nos"):
- qty_remaining = row.transfer_qty
- for batch_no, batch_qty in source_bundle["batch_nos"].items():
- if qty_remaining <= 0:
- break
- alloc = min(abs(flt(batch_qty)) * scale_factor, qty_remaining)
- batches[batch_no] = alloc
- qty_remaining -= alloc
- elif source_row.batch_no:
- batches[source_row.batch_no] = row.transfer_qty
-
- if source_bundle.get("serial_nos"):
- serial_nos = get_serial_nos(source_bundle["serial_nos"])[: int(row.transfer_qty)]
- elif source_row.serial_no:
- serial_nos = get_serial_nos(source_row.serial_no)[: int(row.transfer_qty)]
-
- self._set_serial_batch_bundle_for_disassembly_row(row, serial_nos, batches)
-
- def _set_serial_batch_for_disassembly_from_available_materials(self):
- available_materials = get_available_materials(self.work_order, self)
- for row in self.items:
- if row.serial_no or row.batch_no or row.serial_and_batch_bundle:
- continue
-
- warehouse = row.s_warehouse or row.t_warehouse
- materials = available_materials.get((row.item_code, warehouse))
- if not materials:
- continue
-
- batches = defaultdict(float)
- serial_nos = []
- qty = row.transfer_qty
- for batch_no, batch_qty in materials.batch_details.items():
- if qty <= 0:
- break
-
- batch_qty = abs(batch_qty)
- if batch_qty <= qty:
- batches[batch_no] = batch_qty
- qty -= batch_qty
- else:
- batches[batch_no] = qty
- qty = 0
-
- if materials.serial_nos:
- serial_nos = materials.serial_nos[: int(row.transfer_qty)]
-
- self._set_serial_batch_bundle_for_disassembly_row(row, serial_nos, batches)
-
- def _set_serial_batch_bundle_for_disassembly_row(self, row, serial_nos, batches):
- if not serial_nos and not batches:
- return
-
- warehouse = row.s_warehouse or row.t_warehouse
- bundle_doc = SerialBatchCreation(
- {
- "item_code": row.item_code,
- "warehouse": warehouse,
- "posting_datetime": get_combine_datetime(self.posting_date, self.posting_time),
- "voucher_type": self.doctype,
- "voucher_no": self.name,
- "voucher_detail_no": row.name,
- "qty": row.transfer_qty,
- "type_of_transaction": "Inward" if row.t_warehouse else "Outward",
- "company": self.company,
- "do_not_submit": True,
- }
- ).make_serial_and_batch_bundle(serial_nos=serial_nos, batch_nos=batches)
-
- row.serial_and_batch_bundle = bundle_doc.name
- row.use_serial_batch_fields = 0
-
- row.db_set(
- {
- "serial_and_batch_bundle": bundle_doc.name,
- "use_serial_batch_fields": 0,
- }
- )
+ def before_submit(self):
+ StockEntrySABB(self).make_serial_and_batch_bundle_for_outward()
def on_submit(self):
- self.set_serial_batch_for_disassembly()
+ if self.purpose_cls and hasattr(self.purpose_cls, "on_submit"):
+ self.purpose_cls(self).on_submit()
+
self.make_bundle_using_old_serial_batch_fields()
- self.update_work_order()
- self.update_disassembled_order()
self.adjust_stock_reservation_entries_for_return()
self.update_stock_reservation_entries()
self.update_stock_ledger()
self.make_stock_reserve_for_wip_and_fg()
self.reserve_stock_for_subcontracting()
-
- self.update_subcontract_order_supplied_items()
self.update_subcontracting_order_status()
self.update_pick_list_status()
@@ -453,28 +343,21 @@ class StockEntry(StockController, SubcontractingInwardController):
self.repost_future_sle_and_gle()
self.update_cost_in_project()
- self.update_transferred_qty()
self.update_quality_inspection()
-
- 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:
- self.set_material_request_transfer_status("Completed")
-
super().on_submit_subcontracting_inward()
def on_cancel(self):
+ if self.purpose_cls and hasattr(self.purpose_cls, "on_cancel"):
+ self.purpose_cls(self).on_cancel()
+
self.delink_asset_repair_sabb()
self.validate_closed_subcontracting_order()
- self.update_subcontract_order_supplied_items()
self.update_subcontracting_order_status()
self.cancel_stock_reserve_for_wip_and_fg()
if self.work_order and self.purpose == "Material Consumption for Manufacture":
self.validate_work_order_status()
- self.update_work_order()
- self.update_disassembled_order()
self.cancel_stock_reservation_entries_for_inward()
self.update_stock_ledger()
@@ -488,34 +371,17 @@ class StockEntry(StockController, SubcontractingInwardController):
self.make_gl_entries_on_cancel()
self.repost_future_sle_and_gle()
self.update_cost_in_project()
- self.update_transferred_qty()
self.update_quality_inspection()
self.adjust_stock_reservation_entries_for_return()
self.update_stock_reservation_entries()
self.delete_auto_created_batches()
self.delete_linked_stock_entry()
-
- if self.purpose == "Material Transfer" and self.add_to_transit:
- self.set_material_request_transfer_status("Not Started")
- if self.purpose == "Material Transfer" and self.outgoing_stock_entry:
- self.set_material_request_transfer_status("In Transit")
-
super().on_cancel_subcontracting_inward()
def on_update(self):
super().on_update()
self.set_serial_and_batch_bundle()
- def set_job_card_data(self):
- if self.job_card and not self.work_order:
- data = frappe.db.get_value(
- "Job Card", self.job_card, ["for_quantity", "work_order", "bom_no", "semi_fg_bom"], as_dict=1
- )
- self.fg_completed_qty = data.for_quantity
- self.work_order = data.work_order
- self.from_bom = 1
- self.bom_no = data.semi_fg_bom or data.bom_no
-
def validate_job_card_fg_item(self):
if not self.job_card:
return
@@ -526,9 +392,7 @@ class StockEntry(StockController, SubcontractingInwardController):
for row in self.items:
if row.is_finished_item and row.item_code != job_card.finished_good:
- frappe.throw(
- _("Row #{0}: Finished Good must be {1}").format(row.idx, job_card.fininshed_good)
- )
+ frappe.throw(_("Row #{0}: Finished Good must be {1}").format(row.idx, job_card.finished_good))
def validate_job_card_item(self):
if not self.job_card or self.purpose == "Manufacture":
@@ -553,28 +417,6 @@ class StockEntry(StockController, SubcontractingInwardController):
if pro_doc.status == "Completed":
frappe.throw(_("Cannot cancel transaction for Completed Work Order."))
- def validate_purpose(self):
- valid_purposes = [
- "Material Issue",
- "Material Receipt",
- "Material Transfer",
- "Material Transfer for Manufacture",
- "Manufacture",
- "Repack",
- "Send to Subcontractor",
- "Material Consumption for Manufacture",
- "Disassemble",
- "Receive from Customer",
- "Return Raw Material to Customer",
- "Subcontracting Delivery",
- "Subcontracting Return",
- ]
-
- if self.purpose not in valid_purposes:
- frappe.throw(_("Purpose must be one of {0}").format(comma_or(valid_purposes)))
-
- super().validate_purpose()
-
def delete_linked_stock_entry(self):
if self.purpose == "Send to Warehouse":
for d in frappe.get_all(
@@ -592,34 +434,12 @@ class StockEntry(StockController, SubcontractingInwardController):
return
for row in self.items:
- if row.serial_and_batch_bundle:
- voucher_detail_no = frappe.db.get_value(
- "Asset Repair Consumed Item",
- {"parent": self.asset_repair, "serial_and_batch_bundle": row.serial_and_batch_bundle},
- "name",
- )
-
- doc = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle)
- doc.db_set(
- {
- "voucher_type": "Asset Repair",
- "voucher_no": self.asset_repair,
- "voucher_detail_no": voucher_detail_no,
- }
- )
+ row.delink_asset_repair_sabb(self.asset_repair)
def set_transfer_qty(self):
self.validate_qty_is_not_zero()
for item in self.get("items"):
- if not flt(item.conversion_factor):
- frappe.throw(_("Row {0}: UOM Conversion Factor is mandatory").format(item.idx))
- item.transfer_qty = flt(
- flt(item.qty) * flt(item.conversion_factor), self.precision("transfer_qty", item)
- )
- if not flt(item.transfer_qty):
- frappe.throw(
- _("Row {0}: Qty in Stock UOM can not be zero.").format(item.idx), title=_("Zero quantity")
- )
+ item.set_transfer_qty()
def update_cost_in_project(self):
if self.work_order and not frappe.db.get_value(
@@ -629,49 +449,12 @@ class StockEntry(StockController, SubcontractingInwardController):
projects = set(item.project for item in self.items if item.project)
for project in projects:
- amount = frappe.db.sql(
- """ select ifnull(sum(amount), 0)
- from
- `tabStock Entry Detail`
- where
- docstatus = 1 and project = %s
- and (t_warehouse is null or t_warehouse = '')""",
- project,
- as_list=1,
- )
-
- amount = amount[0][0] if amount else 0
- additional_costs = frappe.db.sql(
- """ select ifnull(sum(sed.base_amount), 0)
- from
- `tabStock Entry` se, `tabLanded Cost Taxes and Charges` sed
- where
- se.docstatus = 1 and se.project = %s and sed.parent = se.name
- and se.purpose = 'Manufacture'""",
- project,
- as_list=1,
- )
-
- additional_cost_amt = additional_costs[0][0] if additional_costs else 0
-
- amount += additional_cost_amt
- project = frappe.get_doc("Project", project)
- project.total_consumed_material_cost = amount
- project.save()
+ project_doc = frappe.get_doc("Project", project)
+ project_doc.set_consumed_material_cost()
+ project_doc.save()
def validate_item(self):
- stock_items = self.get_stock_items()
for item in self.get("items"):
- if flt(item.qty) and flt(item.qty) < 0:
- frappe.throw(
- _("Row {0}: The item {1}, quantity must be positive number").format(
- item.idx, frappe.bold(item.item_code)
- )
- )
-
- if item.item_code not in stock_items:
- frappe.throw(_("{0} is not a stock Item").format(item.item_code))
-
item_details = self.get_item_details(
frappe._dict(
{
@@ -686,215 +469,49 @@ class StockEntry(StockController, SubcontractingInwardController):
for_update=True,
)
- reset_fields = ("stock_uom", "item_name")
- for field in reset_fields:
- item.set(field, item_details.get(field))
-
- update_fields = (
- "uom",
- "description",
- "expense_account",
- "cost_center",
- "conversion_factor",
- "barcode",
- )
-
- for field in update_fields:
- if not item.get(field):
- item.set(field, item_details.get(field))
- if field == "conversion_factor" and item.uom == item_details.get("stock_uom"):
- item.set(field, item_details.get(field))
-
- if not item.transfer_qty and item.qty:
- item.transfer_qty = flt(
- flt(item.qty) * flt(item.conversion_factor), self.precision("transfer_qty", item)
- )
-
- if self.purpose == "Subcontracting Delivery":
- item.expense_account = frappe.get_value("Company", self.company, "default_expense_account")
+ item.validate_and_update_item_details(item_details, self.company, self.purpose)
def validate_fg_completed_qty(self):
if self.purpose != "Manufacture" or not self.from_bom:
return
+ fg_qty = self._aggregate_fg_qty()
+ if fg_qty:
+ self._check_process_loss_qty(fg_qty)
+ def _aggregate_fg_qty(self):
fg_qty = defaultdict(float)
for d in self.items:
if d.is_finished_item:
fg_qty[d.item_code] += flt(d.qty)
+ return fg_qty
- if not fg_qty:
- return
-
+ def _check_process_loss_qty(self, fg_qty):
precision = frappe.get_precision("Stock Entry Detail", "qty")
fg_item = next(iter(fg_qty.keys()))
fg_item_qty = flt(fg_qty[fg_item], precision)
fg_completed_qty = flt(self.fg_completed_qty, precision)
-
for d in self.items:
- if not fg_qty.get(d.item_code):
- continue
+ if fg_qty.get(d.item_code):
+ self._validate_fg_qty_with_process_loss(d, fg_item_qty, fg_completed_qty, precision)
- if (fg_completed_qty - fg_item_qty) > 0:
- self.process_loss_qty = fg_completed_qty - fg_item_qty
-
- if not self.process_loss_qty:
- continue
-
- if fg_completed_qty != (flt(fg_item_qty, precision) + flt(self.process_loss_qty, precision)):
- frappe.throw(
- _(
- "Since there is a process loss of {0} units for the finished good {1}, you should reduce the quantity by {0} units for the finished good {1} in the Items Table."
- ).format(frappe.bold(self.process_loss_qty), frappe.bold(d.item_code))
- )
+ def _validate_fg_qty_with_process_loss(self, d, fg_item_qty, fg_completed_qty, precision):
+ if (fg_completed_qty - fg_item_qty) > 0:
+ self.process_loss_qty = fg_completed_qty - fg_item_qty
+ if not self.process_loss_qty:
+ return
+ if fg_completed_qty != (flt(fg_item_qty, precision) + flt(self.process_loss_qty, precision)):
+ frappe.throw(
+ _(
+ "Since there is a process loss of {0} units for the finished good {1}, you should reduce the quantity by {0} units for the finished good {1} in the Items Table."
+ ).format(frappe.bold(self.process_loss_qty), frappe.bold(d.item_code))
+ )
def validate_difference_account(self):
if not cint(erpnext.is_perpetual_inventory_enabled(self.company)):
return
for d in self.get("items"):
- if not d.expense_account:
- frappe.throw(
- _(
- "Please enter Difference Account or set default Stock Adjustment Account for company {0}"
- ).format(frappe.bold(self.company))
- )
-
- acc_details = frappe.get_cached_value(
- "Account",
- d.expense_account,
- ["account_type", "report_type"],
- as_dict=True,
- )
-
- if self.is_opening == "Yes" and acc_details.report_type == "Profit and Loss":
- frappe.throw(
- _(
- "Difference Account must be a Asset/Liability type account (Temporary Opening), since this Stock Entry is an Opening Entry"
- ),
- OpeningEntryAccountError,
- )
-
- if acc_details.account_type == "Stock":
- frappe.throw(
- _(
- "At row #{0}: the Difference Account must not be a Stock type account, please change the Account Type for the account {1} or select a different account"
- ).format(d.idx, get_link_to_form("Account", d.expense_account)),
- title=_("Difference Account in Items Table"),
- )
-
- if (
- self.purpose not in ["Material Issue", "Subcontracting Delivery"]
- and acc_details.account_type == "Cost of Goods Sold"
- ):
- frappe.msgprint(
- _(
- "At row #{0}: you have selected the Difference Account {1}, which is a Cost of Goods Sold type account. Please select a different account"
- ).format(d.idx, bold(get_link_to_form("Account", d.expense_account))),
- title=_("Cost of Goods Sold Account in Items Table"),
- indicator="orange",
- alert=1,
- )
-
- def validate_warehouse(self):
- """perform various (sometimes conditional) validations on warehouse"""
-
- source_mandatory = [
- "Material Issue",
- "Material Transfer",
- "Send to Subcontractor",
- "Material Transfer for Manufacture",
- "Material Consumption for Manufacture",
- "Return Raw Material to Customer",
- "Subcontracting Delivery",
- ]
-
- target_mandatory = [
- "Material Receipt",
- "Material Transfer",
- "Send to Subcontractor",
- "Material Transfer for Manufacture",
- "Receive from Customer",
- "Subcontracting Return",
- ]
-
- has_bom = any([d.bom_no for d in self.get("items")])
-
- if self.purpose in source_mandatory and self.purpose not in target_mandatory:
- self.to_warehouse = None
- for d in self.get("items"):
- d.t_warehouse = None
- elif self.purpose in target_mandatory and self.purpose not in source_mandatory:
- self.from_warehouse = None
- for d in self.get("items"):
- d.s_warehouse = None
-
- for d in self.get("items"):
- if not d.s_warehouse and not d.t_warehouse:
- d.s_warehouse = self.from_warehouse
- d.t_warehouse = self.to_warehouse
-
- if self.purpose in source_mandatory and not d.s_warehouse:
- if self.from_warehouse:
- d.s_warehouse = self.from_warehouse
- else:
- frappe.throw(_("Source warehouse is mandatory for row {0}").format(d.idx))
-
- if self.purpose in target_mandatory and not d.t_warehouse:
- if self.to_warehouse:
- d.t_warehouse = self.to_warehouse
- else:
- frappe.throw(_("Target warehouse is mandatory for row {0}").format(d.idx))
-
- if self.purpose in ["Manufacture", "Repack"]:
- if d.is_finished_item or d.type or d.is_legacy_scrap_item:
- d.s_warehouse = None
- if not d.t_warehouse:
- frappe.throw(_("Target warehouse is mandatory for row {0}").format(d.idx))
- else:
- d.t_warehouse = None
- if not d.s_warehouse:
- frappe.throw(_("Source warehouse is mandatory for row {0}").format(d.idx))
-
- if self.purpose == "Disassemble":
- if has_bom:
- if d.is_finished_item or d.type or d.is_legacy_scrap_item:
- d.t_warehouse = None
- if not d.s_warehouse:
- frappe.throw(_("Source warehouse is mandatory for row {0}").format(d.idx))
- else:
- d.s_warehouse = None
- if not d.t_warehouse:
- frappe.throw(_("Target warehouse is mandatory for row {0}").format(d.idx))
-
- if cstr(d.s_warehouse) == cstr(d.t_warehouse) and self.purpose not in [
- "Material Transfer for Manufacture",
- "Material Transfer",
- ]:
- frappe.throw(_("Source and target warehouse cannot be same for row {0}").format(d.idx))
-
- if not (d.s_warehouse or d.t_warehouse):
- frappe.throw(_("At least one warehouse is mandatory"))
-
- def validate_work_order(self):
- if self.purpose in (
- "Manufacture",
- "Material Transfer for Manufacture",
- "Material Consumption for Manufacture",
- "Disassemble",
- ):
- # check if work order is entered
-
- if (
- (self.purpose == "Manufacture" or self.purpose == "Material Consumption for Manufacture")
- and self.work_order
- and frappe.get_cached_value("Work Order", self.work_order, "track_semi_finished_goods") != 1
- ):
- if not self.fg_completed_qty:
- frappe.throw(_("For Quantity (Manufactured Qty) is mandatory"))
- self.check_if_operations_completed()
- self.check_duplicate_entry_for_work_order()
- elif self.purpose != "Material Transfer":
- self.work_order = None
+ d.validate_expense_account(self.is_opening, self.purpose)
def validate_source_stock_entry(self):
if not self.get("source_stock_entry"):
@@ -910,261 +527,12 @@ class StockEntry(StockController, SubcontractingInwardController):
title=_("Work Order Mismatch"),
)
- from erpnext.manufacturing.doctype.work_order.work_order import get_disassembly_available_qty
-
- available_qty = get_disassembly_available_qty(self.source_stock_entry, self.name)
-
- if flt(self.fg_completed_qty) > available_qty:
- frappe.throw(
- _(
- "Cannot disassemble {0} qty against Stock Entry {1}. Only {2} qty available to disassemble."
- ).format(
- self.fg_completed_qty,
- self.source_stock_entry,
- available_qty,
- ),
- title=_("Excess Disassembly"),
- )
-
- def check_if_operations_completed(self):
- """Check if Time Sheets are completed against before manufacturing to capture operating costs."""
- prod_order = frappe.get_doc("Work Order", self.work_order)
- allowance_percentage = flt(
- frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order")
- )
-
- for d in prod_order.get("operations"):
- total_completed_qty = flt(self.fg_completed_qty) + flt(prod_order.produced_qty)
- completed_qty = (
- d.completed_qty + d.process_loss_qty + (allowance_percentage / 100 * d.completed_qty)
- )
- if flt(total_completed_qty, self.precision("fg_completed_qty")) > flt(
- completed_qty, self.precision("fg_completed_qty")
- ):
- job_card = frappe.db.get_value("Job Card", {"operation_id": d.name}, "name")
- if not job_card:
- frappe.throw(
- _("Work Order {0}: Job Card not found for the operation {1}").format(
- self.work_order, d.operation
- )
- )
-
- work_order_link = get_link_to_form("Work Order", self.work_order)
- job_card_link = get_link_to_form("Job Card", job_card)
- frappe.throw(
- _(
- "Row #{0}: Operation {1} is not completed for {2} qty of finished goods in Work Order {3}. Please update operation status via Job Card {4}."
- ).format(
- d.idx,
- frappe.bold(d.operation),
- frappe.bold(total_completed_qty),
- work_order_link,
- job_card_link,
- ),
- OperationsNotCompleteError,
- )
-
- def check_duplicate_entry_for_work_order(self):
- other_ste = [
- t[0]
- for t in frappe.db.get_values(
- "Stock Entry",
- {
- "work_order": self.work_order,
- "purpose": self.purpose,
- "docstatus": ["!=", 2],
- "name": ["!=", self.name],
- },
- "name",
- )
- ]
-
- if other_ste:
- production_item, qty = frappe.db.get_value(
- "Work Order", self.work_order, ["production_item", "qty"]
- )
- args = [*other_ste, production_item]
- fg_qty_already_entered = frappe.db.sql(
- """select sum(transfer_qty)
- from `tabStock Entry Detail`
- where parent in ({})
- and item_code = {}
- and ifnull(s_warehouse,'')='' """.format(", ".join(["%s" * len(other_ste)]), "%s"),
- args,
- )[0][0]
- if fg_qty_already_entered and fg_qty_already_entered >= qty:
- frappe.throw(
- _("Stock Entries already created for Work Order {0}: {1}").format(
- self.work_order, ", ".join(other_ste)
- ),
- DuplicateEntryForWorkOrderError,
- )
-
def set_actual_qty(self):
- from erpnext.stock.stock_ledger import is_negative_stock_allowed
-
for d in self.get("items"):
- allow_negative_stock = is_negative_stock_allowed(item_code=d.item_code)
- previous_sle = get_previous_sle(
- {
- "item_code": d.item_code,
- "warehouse": d.s_warehouse or d.t_warehouse,
- "posting_date": self.posting_date,
- "posting_time": self.posting_time,
- }
- )
-
- # get actual stock at source warehouse
- d.actual_qty = previous_sle.get("qty_after_transaction") or 0
-
- # validate qty during submit
- if (
- d.docstatus == 1
- and d.s_warehouse
- and not allow_negative_stock
- and flt(d.actual_qty, d.precision("actual_qty"))
- < flt(d.transfer_qty, d.precision("actual_qty"))
- ):
- frappe.throw(
- _(
- "Row {0}: Quantity not available for {4} in warehouse {1} at posting time of the entry ({2} {3})"
- ).format(
- d.idx,
- frappe.bold(d.s_warehouse),
- formatdate(self.posting_date),
- format_time(self.posting_time),
- frappe.bold(d.item_code),
- )
- + "
"
- + _("Available quantity is {0}, you need {1}").format(
- frappe.bold(flt(d.actual_qty, d.precision("actual_qty"))), frappe.bold(d.transfer_qty)
- ),
- NegativeStockError,
- title=_("Insufficient Stock"),
- )
-
- def validate_component_and_quantities(self):
- if self.purpose not in ["Manufacture", "Material Transfer for Manufacture"]:
- return
-
- if not frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"):
- return
-
- if not self.fg_completed_qty:
- return
-
- raw_materials = self.get_bom_raw_materials(self.fg_completed_qty)
-
- precision = frappe.get_precision("Stock Entry Detail", "qty")
- for item_code, details in raw_materials.items():
- item_code = item_code[0] if type(item_code) == tuple else item_code
- if matched_item := self.get_matched_items(item_code):
- if flt(details.get("qty"), precision) != flt(matched_item.qty, precision):
- frappe.throw(
- _(
- "For the item {0}, the consumed quantity should be {1} according to the BOM {2}."
- ).format(
- frappe.bold(item_code),
- flt(details.get("qty")),
- get_link_to_form("BOM", self.bom_no),
- ),
- title=_("Incorrect Component Quantity"),
- )
- else:
- frappe.throw(
- _("According to the BOM {0}, the Item '{1}' is missing in the stock entry.").format(
- get_link_to_form("BOM", self.bom_no), frappe.bold(item_code)
- ),
- title=_("Missing Item"),
- )
-
- def validate_same_source_target_warehouse_during_material_transfer(self):
- """
- Validate Material Transfer entries where source and target warehouses are identical.
-
- For Material Transfer purpose, if an item has the same source and target warehouse,
- require that at least one inventory dimension (if configured) differs between source
- and target to ensure a meaningful transfer is occurring.
-
- Raises:
- frappe.ValidationError: If warehouses are same and no inventory dimensions differ
- """
-
- if frappe.get_single_value("Stock Settings", "validate_material_transfer_warehouses"):
- from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
-
- inventory_dimensions = get_inventory_dimensions()
- if self.purpose == "Material Transfer":
- for item in self.items:
- if cstr(item.s_warehouse) == cstr(item.t_warehouse):
- if not inventory_dimensions:
- frappe.throw(
- _(
- "Row #{0}: Source and Target Warehouse cannot be the same for Material Transfer"
- ).format(item.idx),
- title=_("Invalid Source and Target Warehouse"),
- )
- else:
- difference_found = False
- for dimension in inventory_dimensions:
- fieldname = (
- dimension.source_fieldname
- if dimension.source_fieldname.startswith("to_")
- else f"to_{dimension.source_fieldname}"
- )
- if (
- item.get(dimension.source_fieldname)
- and item.get(fieldname)
- and item.get(dimension.source_fieldname) != item.get(fieldname)
- ):
- difference_found = True
- break
- if not difference_found:
- frappe.throw(
- _(
- "Row #{0}: Source, Target Warehouse and Inventory Dimensions cannot be the exact same for Material Transfer"
- ).format(item.idx),
- title=_("Invalid Source and Target Warehouse"),
- )
-
- def get_matched_items(self, item_code):
- items = [item for item in self.items if item.s_warehouse]
- for row in items or self.get_consumed_items():
- if row.item_code == item_code or row.original_item == item_code:
- return row
-
- return {}
-
- def get_consumed_items(self):
- """Get all raw materials consumed through consumption entries"""
- parent = frappe.qb.DocType("Stock Entry")
- child = frappe.qb.DocType("Stock Entry Detail")
-
- query = (
- frappe.qb.from_(parent)
- .join(child)
- .on(parent.name == child.parent)
- .select(
- child.item_code,
- Sum(child.qty).as_("qty"),
- child.original_item,
- )
- .where(
- (parent.docstatus == 1)
- & (parent.purpose == "Material Consumption for Manufacture")
- & (parent.work_order == self.work_order)
- )
- .groupby(child.item_code, child.original_item)
- )
-
- return query.run(as_dict=True)
+ d.set_actual_qty(self.posting_date, self.posting_time)
@frappe.whitelist()
def get_stock_and_rate(self):
- """
- Updates rate and availability of all the items.
- Called from Update Rate and Availability button.
- """
self.set_work_order_details()
self.set_transfer_qty()
self.set_actual_qty()
@@ -1179,71 +547,66 @@ class StockEntry(StockController, SubcontractingInwardController):
self.set_total_amount()
def set_basic_rate(self, reset_outgoing_rate=True, raise_error_if_no_rate=True):
- """
- Set rate for outgoing, secondary and finished items
- """
- # Set rate for outgoing items
+ """Set rate for outgoing, secondary and finished items."""
outgoing_items_cost = self.set_rate_for_outgoing_items(reset_outgoing_rate, raise_error_if_no_rate)
+ raise_error_if_no_rate = raise_error_if_no_rate and not self.is_new()
- items = []
- # Set basic rate for incoming items
+ zero_valuation_items = []
for d in self.get("items"):
if d.s_warehouse or d.set_basic_rate_manually:
continue
+ self._set_incoming_item_rate(d, outgoing_items_cost, raise_error_if_no_rate, zero_valuation_items)
- if d.allow_zero_valuation_rate and d.basic_rate and self.purpose != "Receive from Customer":
- d.basic_rate = 0.0
- items.append(d.item_code)
- elif d.is_finished_item:
- if self.purpose == "Manufacture":
- d.basic_rate = self.get_basic_rate_for_manufactured_item(
- d.transfer_qty, outgoing_items_cost
- )
- elif self.purpose == "Repack":
- d.basic_rate = self.get_basic_rate_for_repacked_items(d.transfer_qty, outgoing_items_cost)
+ if zero_valuation_items:
+ self._notify_zero_valuation_rate(zero_valuation_items)
- if self.bom_no:
- d.basic_rate *= frappe.get_value("BOM", self.bom_no, "cost_allocation_per") / 100
- elif d.type and d.bom_secondary_item:
- cost_allocation_per = frappe.get_value(
- "BOM Secondary Item", d.bom_secondary_item, "cost_allocation_per"
- )
- d.basic_rate = (outgoing_items_cost * (cost_allocation_per / 100)) / d.transfer_qty
+ def _set_incoming_item_rate(self, d, outgoing_items_cost, raise_error_if_no_rate, zero_valuation_items):
+ if d.allow_zero_valuation_rate and d.basic_rate and self.purpose != "Receive from Customer":
+ d.basic_rate = 0.0
+ zero_valuation_items.append(d.item_code)
+ elif d.is_finished_item:
+ if self.purpose == "Manufacture":
+ d.basic_rate = self.get_basic_rate_for_manufactured_item(d.transfer_qty, outgoing_items_cost)
+ elif self.purpose == "Repack":
+ d.basic_rate = self.get_basic_rate_for_repacked_items(d.transfer_qty, outgoing_items_cost)
- if not d.basic_rate and not d.allow_zero_valuation_rate:
- if self.is_new():
- raise_error_if_no_rate = False
+ if self.bom_no:
+ d.basic_rate *= frappe.get_value("BOM", self.bom_no, "cost_allocation_per") / 100
+ elif d.type and d.bom_secondary_item:
+ cost_allocation_per = frappe.get_value(
+ "BOM Secondary Item", d.bom_secondary_item, "cost_allocation_per"
+ )
+ d.basic_rate = (outgoing_items_cost * (cost_allocation_per / 100)) / d.transfer_qty
- d.basic_rate = get_valuation_rate(
- d.item_code,
- d.t_warehouse,
- self.doctype,
- self.name,
- d.allow_zero_valuation_rate,
- currency=erpnext.get_company_currency(self.company),
- company=self.company,
- raise_error_if_no_rate=raise_error_if_no_rate,
- batch_no=d.batch_no,
- serial_and_batch_bundle=d.serial_and_batch_bundle,
- )
+ if not d.basic_rate and not d.allow_zero_valuation_rate:
+ d.basic_rate = get_valuation_rate(
+ d.item_code,
+ d.t_warehouse,
+ self.doctype,
+ self.name,
+ d.allow_zero_valuation_rate,
+ currency=erpnext.get_company_currency(self.company),
+ company=self.company,
+ raise_error_if_no_rate=raise_error_if_no_rate,
+ batch_no=d.batch_no,
+ serial_and_batch_bundle=d.serial_and_batch_bundle,
+ )
- # do not round off basic rate to avoid precision loss
- d.basic_rate = flt(d.basic_rate)
- d.basic_amount = flt(flt(d.transfer_qty) * flt(d.basic_rate), d.precision("basic_amount"))
+ # do not round off basic rate to avoid precision loss
+ d.basic_rate = flt(d.basic_rate)
+ d.basic_amount = flt(flt(d.transfer_qty) * flt(d.basic_rate), d.precision("basic_amount"))
- if items:
- message = ""
+ def _notify_zero_valuation_rate(self, items):
+ if len(items) > 1:
+ message = _(
+ "Items rate has been updated to zero as Allow Zero Valuation Rate is checked for the following items: {0}"
+ ).format(", ".join(frappe.bold(item) for item in items))
+ else:
+ message = _(
+ "Item rate has been updated to zero as Allow Zero Valuation Rate is checked for item {0}"
+ ).format(frappe.bold(items[0]))
- if len(items) > 1:
- message = _(
- "Items rate has been updated to zero as Allow Zero Valuation Rate is checked for the following items: {0}"
- ).format(", ".join(frappe.bold(item) for item in items))
- else:
- message = _(
- "Item rate has been updated to zero as Allow Zero Valuation Rate is checked for item {0}"
- ).format(frappe.bold(items[0]))
-
- frappe.msgprint(message, alert=True)
+ frappe.msgprint(message, alert=True)
def set_rate_for_outgoing_items(self, reset_outgoing_rate=True, raise_error_if_no_rate=True):
outgoing_items_cost = 0.0
@@ -1295,68 +658,78 @@ class StockEntry(StockController, SubcontractingInwardController):
scrap_items_cost = sum([flt(d.basic_amount) for d in self.get("items") if d.is_legacy_scrap_item])
if settings.material_consumption:
- if settings.get_rm_cost_from_consumption_entry and self.work_order:
- # Validate only if Material Consumption Entry exists for the Work Order.
- if frappe.db.exists(
- "Stock Entry",
- {
- "docstatus": 1,
- "work_order": self.work_order,
- "purpose": "Material Consumption for Manufacture",
- },
- ):
- for item in self.items:
- if not item.is_finished_item and not item.type and not item.is_legacy_scrap_item:
- label = frappe.get_meta(settings.doctype).get_label(
- "get_rm_cost_from_consumption_entry"
- )
- frappe.throw(
- _(
- "Row {0}: As {1} is enabled, raw materials cannot be added to {2} entry. Use {3} entry to consume raw materials."
- ).format(
- item.idx,
- frappe.bold(label),
- frappe.bold(_("Manufacture")),
- frappe.bold(_("Material Consumption for Manufacture")),
- )
- )
-
- if frappe.db.exists(
- "Stock Entry",
- {
- "docstatus": 1,
- "work_order": self.work_order,
- "purpose": "Manufacture",
- "name": ("!=", self.name),
- },
- ):
- frappe.throw(
- _("Only one {0} entry can be created against the Work Order {1}").format(
- frappe.bold(_("Manufacture")), frappe.bold(self.work_order)
- )
- )
-
- SE = frappe.qb.DocType("Stock Entry")
- SE_ITEM = frappe.qb.DocType("Stock Entry Detail")
-
- outgoing_items_cost = (
- frappe.qb.from_(SE)
- .left_join(SE_ITEM)
- .on(SE.name == SE_ITEM.parent)
- .select(Sum(SE_ITEM.valuation_rate * SE_ITEM.transfer_qty))
- .where(
- (SE.docstatus == 1)
- & (SE.work_order == self.work_order)
- & (SE.purpose == "Material Consumption for Manufacture")
- )
- ).run()[0][0] or 0
-
- elif not outgoing_items_cost:
- bom_items = self.get_bom_raw_materials(finished_item_qty)
- outgoing_items_cost = sum([flt(row.qty) * flt(row.rate) for row in bom_items.values()])
+ outgoing_items_cost = self._get_rm_cost_for_manufacture(
+ settings, finished_item_qty, outgoing_items_cost
+ )
return flt((outgoing_items_cost - scrap_items_cost) / finished_item_qty)
+ def _get_rm_cost_for_manufacture(self, settings, finished_item_qty, outgoing_items_cost):
+ if settings.get_rm_cost_from_consumption_entry and self.work_order:
+ if frappe.db.exists(
+ "Stock Entry",
+ {
+ "docstatus": 1,
+ "work_order": self.work_order,
+ "purpose": "Material Consumption for Manufacture",
+ },
+ ):
+ self._validate_no_raw_materials_in_manufacture_entry(settings)
+ self._validate_single_manufacture_entry()
+ return self._fetch_consumption_entry_cost()
+ elif not outgoing_items_cost:
+ bom_items = self.get_bom_raw_materials(finished_item_qty)
+ outgoing_items_cost = sum([flt(row.qty) * flt(row.rate) for row in bom_items.values()])
+
+ return outgoing_items_cost
+
+ def _validate_no_raw_materials_in_manufacture_entry(self, settings):
+ for item in self.items:
+ if not item.is_finished_item and not item.type and not item.is_legacy_scrap_item:
+ label = frappe.get_meta(settings.doctype).get_label("get_rm_cost_from_consumption_entry")
+ frappe.throw(
+ _(
+ "Row {0}: As {1} is enabled, raw materials cannot be added to {2} entry. Use {3} entry to consume raw materials."
+ ).format(
+ item.idx,
+ frappe.bold(label),
+ frappe.bold(_("Manufacture")),
+ frappe.bold(_("Material Consumption for Manufacture")),
+ )
+ )
+
+ def _validate_single_manufacture_entry(self):
+ if frappe.db.exists(
+ "Stock Entry",
+ {
+ "docstatus": 1,
+ "work_order": self.work_order,
+ "purpose": "Manufacture",
+ "name": ("!=", self.name),
+ },
+ ):
+ frappe.throw(
+ _("Only one {0} entry can be created against the Work Order {1}").format(
+ frappe.bold(_("Manufacture")), frappe.bold(self.work_order)
+ )
+ )
+
+ def _fetch_consumption_entry_cost(self):
+ SE = frappe.qb.DocType("Stock Entry")
+ SE_ITEM = frappe.qb.DocType("Stock Entry Detail")
+
+ return (
+ frappe.qb.from_(SE)
+ .left_join(SE_ITEM)
+ .on(SE.name == SE_ITEM.parent)
+ .select(Sum(SE_ITEM.valuation_rate * SE_ITEM.transfer_qty))
+ .where(
+ (SE.docstatus == 1)
+ & (SE.work_order == self.work_order)
+ & (SE.purpose == "Material Consumption for Manufacture")
+ )
+ ).run()[0][0] or 0
+
def distribute_additional_costs(self):
# If no incoming items, set additional costs blank
if not any(d.item_code for d in self.items if d.t_warehouse):
@@ -1421,266 +794,6 @@ class StockEntry(StockController, SubcontractingInwardController):
if self.stock_entry_type and not self.purpose:
self.purpose = frappe.get_cached_value("Stock Entry Type", self.stock_entry_type, "purpose")
- def make_serial_and_batch_bundle_for_outward(self):
- serial_or_batch_items = get_serial_or_batch_items(self.items)
- if not serial_or_batch_items:
- return
-
- serial_nos, batch_nos = self.set_serial_batch_fields_for_subcontracting_inward()
-
- if self.docstatus == 0:
- return
-
- already_picked_serial_nos = []
-
- for row in self.items:
- if row.use_serial_batch_fields:
- continue
-
- if not row.s_warehouse:
- continue
-
- if row.item_code not in serial_or_batch_items:
- continue
-
- bundle_doc = None
- if row.serial_and_batch_bundle and abs(row.transfer_qty) != abs(
- frappe.get_cached_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "total_qty")
- ):
- bundle_doc = SerialBatchCreation(
- {
- "item_code": row.item_code,
- "warehouse": row.s_warehouse,
- "serial_and_batch_bundle": row.serial_and_batch_bundle,
- "type_of_transaction": "Outward",
- "ignore_serial_nos": already_picked_serial_nos,
- "qty": row.transfer_qty * -1,
- }
- ).update_serial_and_batch_entries(
- serial_nos=serial_nos.get(row.name), batch_nos=batch_nos.get(row.name)
- )
- elif not row.serial_and_batch_bundle and frappe.get_single_value(
- "Stock Settings", "auto_create_serial_and_batch_bundle_for_outward"
- ):
- bundle_doc = SerialBatchCreation(
- {
- "item_code": row.item_code,
- "warehouse": row.s_warehouse,
- "posting_datetime": get_combine_datetime(self.posting_date, self.posting_time),
- "voucher_type": self.doctype,
- "voucher_detail_no": row.name,
- "qty": row.transfer_qty * -1,
- "ignore_serial_nos": already_picked_serial_nos,
- "type_of_transaction": "Outward",
- "company": self.company,
- "do_not_submit": True,
- }
- ).make_serial_and_batch_bundle(
- serial_nos=serial_nos.get(row.name), batch_nos=batch_nos.get(row.name)
- )
-
- if not bundle_doc:
- continue
-
- for entry in bundle_doc.entries:
- if not entry.serial_no:
- continue
-
- already_picked_serial_nos.append(entry.serial_no)
-
- row.serial_and_batch_bundle = bundle_doc.name
-
- def set_serial_batch_fields_for_subcontracting_inward(self):
- serial_nos, batch_nos = frappe._dict(), frappe._dict()
- for row in self.items:
- if self.purpose in [
- "Return Raw Material to Customer",
- "Subcontracting Delivery",
- "Subcontracting Return",
- ]:
- if not row.serial_and_batch_bundle:
- serial_nos_list, batch_nos_list = self.get_serial_nos_and_batches_from_sres(
- row.scio_detail, only_pending=self.purpose != "Subcontracting Return"
- )
-
- if len(batch_nos_list) > 1:
- row.use_serial_batch_fields = 0
-
- if row.use_serial_batch_fields:
- if serial_nos_list and not row.serial_no:
- row.serial_no = "\n".join(serial_nos_list)
- if batch_nos_list and not row.batch_no:
- row.batch_no = next(iter(batch_nos_list.keys()))
-
- serial_nos[row.name], batch_nos[row.name] = serial_nos_list, batch_nos_list
-
- return serial_nos, batch_nos
-
- def validate_subcontract_order(self):
- """Throw exception if more raw material is transferred against Subcontract Order than in
- the raw materials supplied table"""
- backflush_raw_materials_based_on = frappe.db.get_single_value(
- "Buying Settings", "backflush_raw_materials_of_subcontract_based_on"
- )
-
- qty_allowance = flt(frappe.db.get_single_value("Buying Settings", "over_transfer_allowance"))
-
- if not (self.purpose == "Send to Subcontractor" and self.get(self.subcontract_data.order_field)):
- return
-
- if backflush_raw_materials_based_on == "BOM":
- subcontract_order = frappe.get_doc(
- self.subcontract_data.order_doctype, self.get(self.subcontract_data.order_field)
- )
- for se_item in self.items:
- item_code = se_item.original_item or se_item.item_code
- precision = cint(frappe.db.get_default("float_precision")) or 3
- required_qty = sum(
- [
- flt(d.required_qty)
- for d in subcontract_order.supplied_items
- if d.rm_item_code == item_code
- ]
- )
-
- total_allowed = required_qty + (required_qty * (qty_allowance / 100))
-
- if not required_qty:
- frappe.db.get_value(
- f"{self.subcontract_data.order_doctype} Item",
- {
- "parent": self.get(self.subcontract_data.order_field),
- "item_code": se_item.subcontracted_item,
- },
- "bom",
- )
-
- if se_item.allow_alternative_item:
- original_item_code = frappe.get_value(
- "Item Alternative", {"alternative_item_code": item_code}, "item_code"
- )
-
- required_qty = sum(
- [
- flt(d.required_qty)
- for d in subcontract_order.supplied_items
- if d.rm_item_code == original_item_code
- ]
- )
-
- total_allowed = required_qty + (required_qty * (qty_allowance / 100))
-
- if not required_qty:
- frappe.throw(
- _("Item {0} not found in 'Raw Materials Supplied' table in {1} {2}").format(
- se_item.item_code,
- self.subcontract_data.order_doctype,
- self.get(self.subcontract_data.order_field),
- )
- )
-
- se = frappe.qb.DocType("Stock Entry")
- se_detail = frappe.qb.DocType("Stock Entry Detail")
-
- total_supplied = (
- frappe.qb.from_(se)
- .inner_join(se_detail)
- .on(se.name == se_detail.parent)
- .select(Sum(se_detail.transfer_qty))
- .where(
- (se.purpose == "Send to Subcontractor")
- & (se.docstatus == 1)
- & (se_detail.item_code == se_item.item_code)
- & (
- (
- (se.purchase_order == self.purchase_order)
- & (se_detail.po_detail == se_item.po_detail)
- )
- if self.subcontract_data.order_doctype == "Purchase Order"
- else (
- (se.subcontracting_order == self.subcontracting_order)
- & (se_detail.sco_rm_detail == se_item.sco_rm_detail)
- )
- )
- )
- ).run()[0][0] or 0
-
- total_returned = 0
- if self.subcontract_data.order_doctype == "Subcontracting Order":
- total_returned = (
- frappe.qb.from_(se)
- .inner_join(se_detail)
- .on(se.name == se_detail.parent)
- .select(Sum(se_detail.transfer_qty))
- .where(
- (se.purpose == "Material Transfer")
- & (se.docstatus == 1)
- & (se.is_return == 1)
- & (se_detail.item_code == se_item.item_code)
- & (se_detail.sco_rm_detail == se_item.sco_rm_detail)
- & (se.subcontracting_order == self.subcontracting_order)
- )
- ).run()[0][0] or 0
-
- if flt(total_supplied + se_item.transfer_qty - total_returned, precision) > flt(
- total_allowed, precision
- ):
- frappe.throw(
- _("Row #{0}: Item {1} cannot be transferred more than {2} against {3} {4}").format(
- se_item.idx,
- se_item.item_code,
- total_allowed,
- self.subcontract_data.order_doctype,
- self.get(self.subcontract_data.order_field),
- )
- )
- elif not se_item.get(self.subcontract_data.rm_detail_field):
- filters = {
- "parent": self.get(self.subcontract_data.order_field),
- "docstatus": 1,
- "rm_item_code": se_item.item_code,
- "main_item_code": se_item.subcontracted_item,
- }
-
- order_rm_detail = frappe.db.get_value(
- self.subcontract_data.order_supplied_items_field, filters, "name"
- )
- if order_rm_detail:
- se_item.db_set(self.subcontract_data.rm_detail_field, order_rm_detail)
- else:
- if not se_item.allow_alternative_item:
- frappe.throw(
- _(
- "Row {0}# Item {1} not found in 'Raw Materials Supplied' table in {2} {3}"
- ).format(
- se_item.idx,
- se_item.item_code,
- self.subcontract_data.order_doctype,
- self.get(self.subcontract_data.order_field),
- )
- )
- elif backflush_raw_materials_based_on == "Material Transferred for Subcontract":
- for row in self.items:
- if not row.subcontracted_item:
- frappe.throw(
- _("Row {0}: Subcontracted Item is mandatory for the raw material {1}").format(
- row.idx, frappe.bold(row.item_code)
- )
- )
- elif not row.get(self.subcontract_data.rm_detail_field):
- filters = {
- "parent": self.get(self.subcontract_data.order_field),
- "docstatus": 1,
- "rm_item_code": row.item_code,
- "main_item_code": row.subcontracted_item,
- }
-
- order_rm_detail = frappe.db.get_value(
- self.subcontract_data.order_supplied_items_field, filters, "name"
- )
- if order_rm_detail:
- row.db_set(self.subcontract_data.rm_detail_field, order_rm_detail)
-
def validate_bom(self):
for d in self.get("items"):
if d.bom_no and d.is_finished_item:
@@ -1937,11 +1050,20 @@ class StockEntry(StockController, SubcontractingInwardController):
total_basic_amount = sum(flt(t.basic_amount) for t in self.get("items") if t.t_warehouse)
divide_based_on = total_basic_amount
-
if self.get("additional_costs") and not total_basic_amount:
- # if total_basic_amount is 0, distribute additional charges based on qty
- divide_based_on = sum(item.qty for item in list(self.get("items")))
+ divide_based_on = sum(item.qty for item in self.get("items"))
+ item_account_wise_additional_cost = self._build_additional_cost_per_item_account(
+ total_basic_amount, divide_based_on
+ )
+
+ if item_account_wise_additional_cost:
+ self._append_additional_cost_gl_entries(gl_entries, item_account_wise_additional_cost)
+
+ self.set_gl_entries_for_landed_cost_voucher(gl_entries, inventory_account_map)
+ return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation)
+
+ def _build_additional_cost_per_item_account(self, total_basic_amount, divide_based_on):
item_account_wise_additional_cost = {}
for t in self.get("additional_costs"):
@@ -1957,56 +1079,44 @@ class StockEntry(StockController, SubcontractingInwardController):
)
multiply_based_on = d.basic_amount if total_basic_amount else d.qty
+ entry = item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account]
+ entry["amount"] += flt(t.amount * multiply_based_on) / divide_based_on
+ entry["base_amount"] += flt(t.base_amount * multiply_based_on) / divide_based_on
- item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account]["amount"] += (
- flt(t.amount * multiply_based_on) / divide_based_on
+ return item_account_wise_additional_cost
+
+ def _append_additional_cost_gl_entries(self, gl_entries, item_account_wise_additional_cost):
+ for d in self.get("items"):
+ for account, amount in item_account_wise_additional_cost.get((d.item_code, d.name), {}).items():
+ if not amount:
+ continue
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": account,
+ "against": d.expense_account,
+ "cost_center": d.cost_center,
+ "remarks": self.get("remarks") or _("Accounting Entry for Stock"),
+ "credit_in_account_currency": flt(amount["amount"]),
+ "credit": flt(amount["base_amount"]),
+ },
+ item=d,
+ )
)
- item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account][
- "base_amount"
- ] += flt(t.base_amount * multiply_based_on) / divide_based_on
-
- if item_account_wise_additional_cost:
- for d in self.get("items"):
- for account, amount in item_account_wise_additional_cost.get(
- (d.item_code, d.name), {}
- ).items():
- if not amount:
- continue
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": account,
- "against": d.expense_account,
- "cost_center": d.cost_center,
- "remarks": self.get("remarks") or _("Accounting Entry for Stock"),
- "credit_in_account_currency": flt(amount["amount"]),
- "credit": flt(amount["base_amount"]),
- },
- item=d,
- )
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": d.expense_account,
+ "against": account,
+ "cost_center": d.cost_center,
+ "remarks": self.get("remarks") or _("Accounting Entry for Stock"),
+ "credit": -1 * amount["base_amount"], # negative credit instead of debit
+ },
+ item=d,
)
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": d.expense_account,
- "against": account,
- "cost_center": d.cost_center,
- "remarks": self.get("remarks") or _("Accounting Entry for Stock"),
- "credit": -1
- * amount[
- "base_amount"
- ], # put it as negative credit instead of debit purposefully
- },
- item=d,
- )
- )
-
- self.set_gl_entries_for_landed_cost_voucher(gl_entries, inventory_account_map)
-
- return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation)
+ )
def set_gl_entries_for_landed_cost_voucher(self, gl_entries, inventory_account_map):
landed_cost_entries = self.get_item_account_wise_lcv_entries()
@@ -2064,52 +1174,12 @@ class StockEntry(StockController, SubcontractingInwardController):
)
)
- def update_work_order(self):
- def _validate_work_order(pro_doc):
- msg, title = "", ""
- if flt(pro_doc.docstatus) != 1:
- msg = f"Work Order {self.work_order} must be submitted"
-
- if pro_doc.status == "Stopped":
- msg = f"Transaction not allowed against stopped Work Order {self.work_order}"
-
- if msg:
- frappe.throw(_(msg), title=title)
-
- if self.job_card:
- job_doc = frappe.get_doc("Job Card", self.job_card)
- if self.purpose != "Manufacture":
- job_doc.set_transferred_qty(update_status=True)
- job_doc.set_transferred_qty_in_job_card_item(self)
- else:
- job_doc.set_consumed_qty_in_job_card_item(self)
- job_doc.set_manufactured_qty()
- job_doc.update_work_order()
-
- if self.work_order:
- pro_doc = frappe.get_doc("Work Order", self.work_order)
- _validate_work_order(pro_doc)
-
- if self.fg_completed_qty:
- if self.docstatus == 1:
- pro_doc.add_additional_items(self)
- else:
- pro_doc.remove_additional_items(self)
-
- pro_doc.run_method("update_work_order_qty")
- if self.purpose == "Manufacture":
- pro_doc.run_method("update_planned_qty")
-
- pro_doc.run_method("update_status")
- if not pro_doc.operations:
- pro_doc.set_actual_dates()
-
- def update_disassembled_order(self):
- if not self.work_order:
- return
- if self.purpose == "Disassemble" and self.fg_completed_qty:
- pro_doc = frappe.get_doc("Work Order", self.work_order)
- pro_doc.run_method("update_disassembled_qty", self.fg_completed_qty, self._action == "cancel")
+ @property
+ def pro_doc(self):
+ if not getattr(self, "_wo_doc", None):
+ if self.work_order:
+ self._wo_doc = frappe.get_doc("Work Order", self.work_order)
+ return getattr(self, "_wo_doc", None)
def make_stock_reserve_for_wip_and_fg(self):
if self.is_stock_reserve_for_work_order():
@@ -2173,48 +1243,68 @@ class StockEntry(StockController, SubcontractingInwardController):
@frappe.whitelist()
def get_item_details(self, args: ItemDetailsCtx | None = None, for_update: bool = False):
- item = frappe.qb.DocType("Item")
+ item = self._fetch_item_data(args)
+ item_group_defaults = get_item_group_defaults(item.name, self.company)
+ brand_defaults = get_brand_defaults(item.name, self.company)
+
+ ret = self._build_item_ret(args, item, item_group_defaults, brand_defaults, for_update)
+ self._apply_account_defaults(ret)
+
+ args["posting_date"] = self.posting_date
+ args["posting_time"] = self.posting_time
+ ret.update(get_warehouse_details(args) if args.get("warehouse") else {})
+
+ if self.purpose == "Send to Subcontractor":
+ self._resolve_subcontract_item(args, ret)
+
+ barcode_data = get_barcode_data(item_code=item.name)
+ if barcode_data and len(barcode_data.get(item.name)) == 1:
+ ret["barcode"] = barcode_data.get(item.name)[0]
+
+ return ret
+
+ def _fetch_item_data(self, args):
+ item_dt = frappe.qb.DocType("Item")
item_default = frappe.qb.DocType("Item Default")
- query = (
- frappe.qb.from_(item)
+ result = (
+ frappe.qb.from_(item_dt)
.left_join(item_default)
- .on((item.name == item_default.parent) & (item_default.company == self.company))
+ .on((item_dt.name == item_default.parent) & (item_default.company == self.company))
.select(
- item.name,
- item.stock_uom,
- item.description,
- item.image,
- item.item_name,
- item.item_group,
- item.has_batch_no,
- item.sample_quantity,
- item.has_serial_no,
- item.allow_alternative_item,
+ item_dt.name,
+ item_dt.stock_uom,
+ item_dt.description,
+ item_dt.image,
+ item_dt.is_stock_item,
+ item_dt.item_name,
+ item_dt.item_group,
+ item_dt.has_batch_no,
+ item_dt.sample_quantity,
+ item_dt.has_serial_no,
+ item_dt.allow_alternative_item,
item_default.expense_account,
item_default.buying_cost_center,
)
.where(
- (item.name == args.get("item_code"))
- & (item.disabled == 0)
+ (item_dt.name == args.get("item_code"))
+ & (item_dt.disabled == 0)
& (
- (item.end_of_life.isnull())
- | (item.end_of_life < "1900-01-01")
- | (item.end_of_life > nowdate())
+ (item_dt.end_of_life.isnull())
+ | (item_dt.end_of_life < "1900-01-01")
+ | (item_dt.end_of_life > nowdate())
)
)
- )
- item = query.run(as_dict=True)
+ ).run(as_dict=True)
- if not item:
+ if not result:
frappe.throw(
_("Item {0} is not active or end of life has been reached").format(args.get("item_code"))
)
- item = item[0]
- item_group_defaults = get_item_group_defaults(item.name, self.company)
- brand_defaults = get_brand_defaults(item.name, self.company)
+ return result[0]
+ def _build_item_ret(self, args, item, item_group_defaults, brand_defaults, for_update):
ret = frappe._dict(
{
"uom": item.stock_uom,
@@ -2234,19 +1324,22 @@ class StockEntry(StockController, SubcontractingInwardController):
"has_batch_no": item.has_batch_no,
"sample_quantity": item.sample_quantity,
"expense_account": item.expense_account or item_group_defaults.get("expense_account"),
+ "is_stock_item": item.is_stock_item,
}
)
if self.purpose == "Send to Subcontractor":
ret["allow_alternative_item"] = item.allow_alternative_item
- # update uom
if args.get("uom") and for_update:
ret.update(get_uom_details(args.get("item_code"), args.get("uom"), args.get("qty")))
if self.purpose == "Material Issue":
ret["expense_account"] = item.get("expense_account") or item_group_defaults.get("expense_account")
+ return ret
+
+ def _apply_account_defaults(self, ret):
if not ret.get("expense_account"):
ret["expense_account"] = frappe.get_cached_value(
"Company", self.company, "stock_adjustment_account"
@@ -2259,34 +1352,21 @@ class StockEntry(StockController, SubcontractingInwardController):
if not ret.get(field):
ret[field] = frappe.get_cached_value("Company", self.company, company_field)
- args["posting_date"] = self.posting_date
- args["posting_time"] = self.posting_time
+ def _resolve_subcontract_item(self, args, ret):
+ if not (self.get(self.subcontract_data.order_field) and args.get("item_code")):
+ return
- stock_and_rate = get_warehouse_details(args) if args.get("warehouse") else {}
- ret.update(stock_and_rate)
+ subcontract_items = frappe.get_all(
+ self.subcontract_data.order_supplied_items_field,
+ {
+ "parent": self.get(self.subcontract_data.order_field),
+ "rm_item_code": args.get("item_code"),
+ },
+ "main_item_code",
+ )
- if (
- self.purpose == "Send to Subcontractor"
- and self.get(self.subcontract_data.order_field)
- and args.get("item_code")
- ):
- subcontract_items = frappe.get_all(
- self.subcontract_data.order_supplied_items_field,
- {
- "parent": self.get(self.subcontract_data.order_field),
- "rm_item_code": args.get("item_code"),
- },
- "main_item_code",
- )
-
- if subcontract_items and len(subcontract_items) == 1:
- ret["subcontracted_item"] = subcontract_items[0].main_item_code
-
- barcode_data = get_barcode_data(item_code=item.name)
- if barcode_data and len(barcode_data.get(item.name)) == 1:
- ret["barcode"] = barcode_data.get(item.name)[0]
-
- return ret
+ if subcontract_items and len(subcontract_items) == 1:
+ ret["subcontracted_item"] = subcontract_items[0].main_item_code
@frappe.whitelist()
def set_items_for_stock_in(self):
@@ -2313,475 +1393,19 @@ class StockEntry(StockController, SubcontractingInwardController):
},
)
- def get_items_for_disassembly(self):
- """Get items for Disassembly Order.
-
- Priority:
- 1. From a specific Manufacture Stock Entry (exact reversal)
- 2. From Work Order Manufacture Stock Entries (averaged reversal)
- 3. From BOM (standalone disassembly)
- """
-
- # Auto-set source_stock_entry if WO has exactly one manufacture entry
- if not self.get("source_stock_entry") and self.work_order:
- manufacture_entries = frappe.get_all(
- "Stock Entry",
- filters={
- "work_order": self.work_order,
- "purpose": "Manufacture",
- "docstatus": 1,
- },
- pluck="name",
- limit_page_length=2,
- )
- if len(manufacture_entries) == 1:
- self.source_stock_entry = manufacture_entries[0]
-
- if self.get("source_stock_entry"):
- return self._add_items_for_disassembly_from_stock_entry()
-
- if self.work_order:
- return self._add_items_for_disassembly_from_work_order()
-
- return self._add_items_for_disassembly_from_bom()
-
- def _add_items_for_disassembly_from_stock_entry(self):
- source_fg_qty = frappe.db.get_value("Stock Entry", self.source_stock_entry, "fg_completed_qty")
- if not source_fg_qty:
- frappe.throw(
- _("Source Stock Entry {0} has no finished goods quantity").format(self.source_stock_entry)
- )
-
- disassemble_qty = flt(self.fg_completed_qty)
- scale_factor = disassemble_qty / flt(source_fg_qty)
-
- self._append_disassembly_row_from_source(
- disassemble_qty=disassemble_qty,
- scale_factor=scale_factor,
- )
-
- def _add_items_for_disassembly_from_work_order(self):
- wo_produced_qty = frappe.db.get_value("Work Order", self.work_order, "produced_qty")
-
- wo_produced_qty = flt(wo_produced_qty)
- if wo_produced_qty <= 0:
- frappe.throw(_("Work Order {0} has no produced qty").format(self.work_order))
-
- disassemble_qty = flt(self.fg_completed_qty)
- if disassemble_qty <= 0:
- frappe.throw(_("Disassemble Qty cannot be less than or equal to 0."))
-
- scale_factor = disassemble_qty / wo_produced_qty
-
- self._append_disassembly_row_from_source(
- disassemble_qty=disassemble_qty,
- scale_factor=scale_factor,
- )
-
- def _append_disassembly_row_from_source(self, disassemble_qty, scale_factor):
- for source_row in self.get_items_from_manufacture_stock_entry():
- if source_row.is_finished_item:
- qty = disassemble_qty
- s_warehouse = self.from_warehouse or source_row.t_warehouse
- t_warehouse = ""
- elif source_row.s_warehouse:
- # RM: was consumed FROM s_warehouse -> return TO s_warehouse
- qty = flt(source_row.qty * scale_factor)
- s_warehouse = ""
- t_warehouse = self.to_warehouse or source_row.s_warehouse
- else:
- # Scrap/secondary: was produced TO t_warehouse -> take FROM t_warehouse
- qty = flt(source_row.qty * scale_factor)
- s_warehouse = source_row.t_warehouse
- t_warehouse = ""
-
- item = {
- "item_code": source_row.item_code,
- "item_name": source_row.item_name,
- "description": source_row.description,
- "stock_uom": source_row.stock_uom,
- "uom": source_row.uom,
- "conversion_factor": source_row.conversion_factor,
- "basic_rate": source_row.basic_rate,
- "qty": qty,
- "s_warehouse": s_warehouse,
- "t_warehouse": t_warehouse,
- "is_finished_item": source_row.is_finished_item,
- "type": source_row.type,
- "is_legacy_scrap_item": source_row.is_legacy_scrap_item,
- "bom_secondary_item": source_row.bom_secondary_item,
- "bom_no": source_row.bom_no,
- # batch and serial bundles built on submit
- "use_serial_batch_fields": 1 if (source_row.batch_no or source_row.serial_no) else 0,
- }
-
- if self.source_stock_entry:
- item.update(
- {
- "against_stock_entry": self.source_stock_entry,
- "ste_detail": source_row.name,
- }
- )
-
- self.append("items", item)
-
- def _add_items_for_disassembly_from_bom(self):
- if not self.bom_no or not self.fg_completed_qty:
- frappe.throw(_("BOM and Finished Good Quantity is mandatory for Disassembly"))
-
- # Raw Materials
- item_dict = self.get_bom_raw_materials(self.fg_completed_qty)
-
- for item_row in item_dict.values():
- item_row["to_warehouse"] = self.to_warehouse
- item_row["from_warehouse"] = ""
- item_row["is_finished_item"] = 0
-
- self.add_to_stock_entry_detail(item_dict)
-
- # Secondary/Scrap items (reverse of what set_secondary_items does for Manufacture)
- secondary_items = self.get_secondary_items(self.fg_completed_qty)
- if secondary_items:
- scrap_warehouse = self.from_warehouse
- if self.work_order:
- wo_values = frappe.db.get_value(
- "Work Order", self.work_order, ["scrap_warehouse", "fg_warehouse"], as_dict=True
- )
- scrap_warehouse = wo_values.scrap_warehouse or scrap_warehouse or wo_values.fg_warehouse
-
- for item in secondary_items.values():
- item["from_warehouse"] = scrap_warehouse
- item["to_warehouse"] = ""
- item["is_finished_item"] = 0
-
- if item.get("process_loss_per"):
- item["qty"] -= flt(
- item["qty"] * (item["process_loss_per"] / 100),
- self.precision("fg_completed_qty"),
- )
-
- self.add_to_stock_entry_detail(secondary_items, bom_no=self.bom_no)
-
- # Finished goods
- self.load_items_from_bom()
-
- def get_items_from_manufacture_stock_entry(self):
- SE = frappe.qb.DocType("Stock Entry")
- SED = frappe.qb.DocType("Stock Entry Detail")
- query = frappe.qb.from_(SED).join(SE).on(SED.parent == SE.name).where(SE.docstatus == 1)
-
- common_fields = [
- SED.item_code,
- SED.item_name,
- SED.description,
- SED.stock_uom,
- SED.uom,
- SED.basic_rate,
- SED.conversion_factor,
- SED.is_finished_item,
- SED.type,
- SED.is_legacy_scrap_item,
- SED.bom_secondary_item,
- SED.batch_no,
- SED.serial_no,
- SED.use_serial_batch_fields,
- SED.s_warehouse,
- SED.t_warehouse,
- SED.bom_no,
- ]
-
- if self.source_stock_entry:
- return (
- query.select(SED.name, SED.qty, SED.transfer_qty, *common_fields)
- .where(SE.name == self.source_stock_entry)
- .orderby(SED.idx)
- .run(as_dict=True)
- )
-
- return (
- query.select(Sum(SED.qty).as_("qty"), Sum(SED.transfer_qty).as_("transfer_qty"), *common_fields)
- .where(SE.purpose == "Manufacture")
- .where(SE.work_order == self.work_order)
- .groupby(SED.item_code)
- .orderby(SED.idx)
- .run(as_dict=True)
- )
-
@frappe.whitelist()
def get_items(self):
self.set("items", [])
- self.validate_work_order()
-
- if self.purpose == "Disassemble":
- return self.get_items_for_disassembly()
-
- if not self.posting_date or not self.posting_time:
- frappe.throw(_("Posting date and posting time is mandatory"))
-
- self.set_work_order_details()
- backflush_based_on = frappe.db.get_single_value(
- "Manufacturing Settings", "backflush_raw_materials_based_on"
- )
-
- if self.bom_no:
- backflush_based_on = self.get_backflush_based_on()
-
- if self.purpose in [
- "Material Issue",
- "Material Transfer",
- "Manufacture",
- "Repack",
- "Send to Subcontractor",
- "Material Transfer for Manufacture",
- "Material Consumption for Manufacture",
- ]:
- if self.work_order and self.purpose == "Material Transfer for Manufacture":
- item_dict = self.get_pending_raw_materials(backflush_based_on)
- if self.to_warehouse and self.pro_doc:
- for item in item_dict.values():
- item["to_warehouse"] = self.pro_doc.wip_warehouse
- self.add_to_stock_entry_detail(item_dict)
-
- elif (
- self.work_order
- and (
- self.purpose == "Manufacture"
- or self.purpose == "Material Consumption for Manufacture"
- )
- and not self.pro_doc.skip_transfer
- and backflush_based_on == "Material Transferred for Manufacture"
- ):
- self.add_transfered_raw_materials_in_items()
-
- elif (
- self.work_order
- and (
- self.purpose == "Manufacture"
- or self.purpose == "Material Consumption for Manufacture"
- )
- and backflush_based_on == "BOM"
- and frappe.db.get_single_value("Manufacturing Settings", "material_consumption") == 1
- ):
- self.get_unconsumed_raw_materials()
-
- else:
- if not self.fg_completed_qty:
- frappe.throw(_("Manufacturing Quantity is mandatory"))
-
- item_dict = self.get_bom_raw_materials(self.fg_completed_qty)
-
- # Get Subcontract Order Supplied Items Details
- if (
- self.get(self.subcontract_data.order_field)
- and self.purpose == "Send to Subcontractor"
- ):
- # Get Subcontract Order Supplied Items Details
- parent = frappe.qb.DocType(self.subcontract_data.order_doctype)
- child = frappe.qb.DocType(self.subcontract_data.order_supplied_items_field)
-
- item_wh = (
- frappe.qb.from_(parent)
- .inner_join(child)
- .on(parent.name == child.parent)
- .select(child.rm_item_code, child.reserve_warehouse)
- .where(parent.name == self.get(self.subcontract_data.order_field))
- ).run(as_list=True)
-
- item_wh = frappe._dict(item_wh)
-
- for original_item, item in item_dict.items():
- if self.pro_doc and cint(self.pro_doc.from_wip_warehouse):
- item["from_warehouse"] = self.pro_doc.wip_warehouse
- # Get Reserve Warehouse from Subcontract Order
- if (
- self.get(self.subcontract_data.order_field)
- and self.purpose == "Send to Subcontractor"
- ):
- item["from_warehouse"] = item_wh.get(item.item_code)
- item["to_warehouse"] = (
- self.to_warehouse if self.purpose == "Send to Subcontractor" else ""
- )
-
- if isinstance(original_item, str) and original_item != item.get("item_code"):
- item["original_item"] = original_item
-
- self.add_to_stock_entry_detail(item_dict)
-
- # fetch the serial_no of the first stock entry for the second stock entry
- if self.work_order and self.purpose == "Manufacture":
- work_order = frappe.get_doc("Work Order", self.work_order)
- add_additional_cost(self, work_order)
-
- # add finished goods item
- if self.purpose in ("Manufacture", "Repack"):
- self.set_process_loss_qty()
- self.load_items_from_bom()
+ if self.purpose_cls and hasattr(self.purpose_cls, "add_items"):
+ self.purpose_cls(self).add_items()
self.set_serial_batch_from_reserved_entry()
- self.set_secondary_items()
self.set_actual_qty()
self.validate_customer_provided_item()
self.calculate_rate_and_amount(raise_error_if_no_rate=False)
def set_serial_batch_from_reserved_entry(self):
- if self.work_order and frappe.get_cached_value("Work Order", self.work_order, "reserve_stock"):
- skip_transfer = frappe.get_cached_value("Work Order", self.work_order, "skip_transfer")
-
- if (
- self.purpose not in ["Material Transfer for Manufacture"]
- and self.get_backflush_based_on() != "BOM"
- and not skip_transfer
- ):
- return
-
- reservation_entries = self.get_available_reserved_materials()
- if not reservation_entries:
- return
-
- new_items_to_add = []
- for d in self.items:
- if d.serial_and_batch_bundle or d.serial_no or d.batch_no:
- continue
-
- key = (d.item_code, d.s_warehouse)
- if details := reservation_entries.get(key):
- original_qty = d.qty
- if batches := details.get("batch_no"):
- for batch_no, qty in batches.items():
- if original_qty <= 0:
- break
-
- if qty <= 0:
- continue
-
- if d.batch_no and original_qty > 0:
- new_row = frappe.copy_doc(d)
- new_row.name = None
- new_row.batch_no = batch_no
- new_row.qty = qty
- new_row.idx = d.idx + 1
- if new_row.batch_no and details.get("batchwise_sn"):
- new_row.serial_no = "\n".join(
- details.get("batchwise_sn")[new_row.batch_no][: cint(new_row.qty)]
- )
-
- new_items_to_add.append(new_row)
- original_qty -= qty
- batches[batch_no] -= qty
-
- if qty >= d.qty and not d.batch_no:
- d.batch_no = batch_no
- batches[batch_no] -= d.qty
- if d.batch_no and details.get("batchwise_sn"):
- d.serial_no = "\n".join(
- details.get("batchwise_sn")[d.batch_no][: cint(d.qty)]
- )
- elif not d.batch_no:
- d.batch_no = batch_no
- d.qty = qty
- original_qty -= qty
- batches[batch_no] = 0
-
- if d.batch_no and details.get("batchwise_sn"):
- d.serial_no = "\n".join(
- details.get("batchwise_sn")[d.batch_no][: cint(d.qty)]
- )
-
- if details.get("serial_no"):
- d.serial_no = "\n".join(details.get("serial_no")[: cint(d.qty)])
-
- d.use_serial_batch_fields = 1
-
- for new_row in new_items_to_add:
- self.append("items", new_row)
-
- sorted_items = sorted(self.items, key=lambda x: x.item_code)
- if self.purpose == "Manufacture":
- # ensure finished item at last
- sorted_items = sorted(sorted_items, key=lambda x: x.t_warehouse)
-
- idx = 0
- for row in sorted_items:
- idx += 1
- row.idx = idx
- self.set("items", sorted_items)
-
- def get_backflush_based_on(self):
- from erpnext.manufacturing.doctype.bom.bom import get_backflush_based_on
-
- return get_backflush_based_on(self.bom_no)
-
- def get_available_reserved_materials(self):
- reserved_entries = self.get_reserved_materials()
- if not reserved_entries:
- return {}
-
- itemwise_serial_batch_qty = frappe._dict()
-
- for d in reserved_entries:
- key = (d.item_code, d.warehouse)
- if key not in itemwise_serial_batch_qty:
- itemwise_serial_batch_qty[key] = frappe._dict(
- {
- "serial_no": [],
- "batch_no": defaultdict(float),
- "batchwise_sn": defaultdict(list),
- }
- )
-
- details = itemwise_serial_batch_qty[key]
- if d.batch_no:
- details.batch_no[d.batch_no] += d.qty
- if d.serial_no:
- details.batchwise_sn[d.batch_no].extend(d.serial_no.split("\n"))
- elif d.serial_no:
- details.serial_no.append(d.serial_no)
-
- return itemwise_serial_batch_qty
-
- def get_reserved_materials(self):
- doctype = frappe.qb.DocType("Stock Reservation Entry")
- serial_batch_doc = frappe.qb.DocType("Serial and Batch Entry")
-
- query = (
- frappe.qb.from_(doctype)
- .inner_join(serial_batch_doc)
- .on(doctype.name == serial_batch_doc.parent)
- .select(
- serial_batch_doc.serial_no,
- serial_batch_doc.batch_no,
- serial_batch_doc.qty,
- doctype.item_code,
- doctype.warehouse,
- doctype.name,
- doctype.transferred_qty,
- doctype.consumed_qty,
- )
- .where(
- (doctype.docstatus == 1)
- & (doctype.voucher_no == (self.work_order or self.subcontracting_order))
- & (serial_batch_doc.delivered_qty < serial_batch_doc.qty)
- )
- .orderby(serial_batch_doc.idx)
- )
-
- return query.run(as_dict=True)
-
- def set_secondary_items(self):
- if self.purpose in ["Manufacture", "Repack"]:
- secondary_items_dict = self.get_secondary_items(self.fg_completed_qty)
- for item in secondary_items_dict.values():
- if self.pro_doc and item.type:
- if self.pro_doc.scrap_warehouse and item.type == "Scrap":
- item["to_warehouse"] = self.pro_doc.scrap_warehouse
-
- if item.process_loss_per:
- item["qty"] -= flt(
- item["qty"] * (item.process_loss_per / 100),
- self.precision("fg_completed_qty"),
- )
-
- self.add_to_stock_entry_detail(secondary_items_dict, bom_no=self.bom_no)
+ StockEntrySABB(self).set_serial_batch_based_on_reservation()
def set_process_loss_qty(self):
if self.purpose not in ("Manufacture", "Repack"):
@@ -2819,108 +1443,14 @@ class StockEntry(StockController, SubcontractingInwardController):
)
def set_work_order_details(self):
- if not getattr(self, "pro_doc", None):
- self.pro_doc = frappe._dict()
-
if self.work_order:
# common validations
- if not self.pro_doc:
- self.pro_doc = frappe.get_doc("Work Order", self.work_order)
-
if self.pro_doc and not self.pro_doc.track_semi_finished_goods:
self.bom_no = self.pro_doc.bom_no
else:
# invalid work order
self.work_order = None
- def load_items_from_bom(self):
- if self.work_order:
- item_code = self.pro_doc.production_item
- to_warehouse = self.pro_doc.fg_warehouse
- else:
- item_code = frappe.db.get_value("BOM", self.bom_no, "item")
- to_warehouse = self.to_warehouse
-
- item = get_item_defaults(item_code, self.company)
-
- if not self.work_order and not to_warehouse:
- # in case of BOM
- to_warehouse = item.get("default_warehouse")
-
- expense_account = item.get("expense_account")
- if not expense_account:
- expense_account = frappe.get_cached_value("Company", self.company, "stock_adjustment_account")
-
- args = {
- "to_warehouse": to_warehouse,
- "from_warehouse": "",
- "qty": flt(self.fg_completed_qty) - flt(self.process_loss_qty),
- "item_name": item.item_name,
- "description": item.description,
- "stock_uom": item.stock_uom,
- "expense_account": expense_account,
- "cost_center": item.get("buying_cost_center"),
- "is_finished_item": 1,
- "sample_quantity": item.get("sample_quantity"),
- }
-
- if self.purpose == "Disassemble":
- args.update(
- {
- "from_warehouse": self.from_warehouse,
- "to_warehouse": "",
- "qty": flt(self.fg_completed_qty),
- }
- )
-
- 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
- )
- )
- ):
- self.set_batchwise_finished_goods(args, item)
- else:
- self.add_finished_goods(args, item)
-
- def set_batchwise_finished_goods(self, args, item):
- batches = get_empty_batches_based_work_order(self.work_order, self.pro_doc.production_item)
-
- if not batches:
- self.add_finished_goods(args, item)
- else:
- self.add_batchwise_finished_good(batches, 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)})
-
- self.update_batches_to_be_consume(batches, row, qty)
-
- if not row.batches_to_be_consume:
- return
-
- id = create_serial_and_batch_bundle(
- self,
- row,
- frappe._dict(
- {
- "item_code": self.pro_doc.production_item,
- "warehouse": args.get("to_warehouse"),
- }
- ),
- )
-
- 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)
-
def get_bom_raw_materials(self, qty):
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
@@ -2969,522 +1499,6 @@ class StockEntry(StockController, SubcontractingInwardController):
return item_dict
- def get_secondary_items(self, qty):
- from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
-
- if (
- frappe.db.get_single_value(
- "Manufacturing Settings", "set_op_cost_and_secondary_items_from_sub_assemblies"
- )
- and self.work_order
- and frappe.get_cached_value("Work Order", self.work_order, "use_multi_level_bom")
- ):
- item_dict = get_secondary_items_from_sub_assemblies(self.bom_no, self.company, qty)
- else:
- # item dict = { item_code: {qty, description, stock_uom} }
- item_dict = (
- get_bom_items_as_dict(
- self.bom_no, self.company, qty=qty, fetch_exploded=0, fetch_secondary_items=1
- )
- or {}
- )
-
- for item in item_dict.values():
- item.from_warehouse = ""
-
- return item_dict
-
- def set_secondary_items_from_job_card(self):
- if self.purpose not in ["Manufacture", "Repack"]:
- return
-
- item_dict = {}
- for row in self.get_secondary_items_from_job_card():
- if row.stock_qty <= 0:
- continue
-
- item_dict[row.item_code] = frappe._dict(
- {
- "uom": row.stock_uom,
- "from_warehouse": "",
- "qty": row.stock_qty,
- "conversion_factor": 1,
- "type": row.type,
- "item_name": row.item_name,
- "description": row.description,
- "bom_secondary_item": row.bom_secondary_item,
- }
- )
-
- for item in item_dict.values():
- item.from_warehouse = ""
-
- self.add_to_stock_entry_detail(item_dict)
-
- def get_secondary_items_from_job_card(self):
- if not hasattr(self, "pro_doc"):
- self.pro_doc = None
-
- if not self.pro_doc:
- self.set_work_order_details()
-
- if not self.pro_doc.operations:
- return []
-
- job_card = frappe.qb.DocType("Job Card")
- job_card_secondary_item = frappe.qb.DocType("Job Card Secondary Item")
-
- other = (
- frappe.qb.from_(job_card)
- .select(
- Sum(job_card_secondary_item.stock_qty).as_("stock_qty"),
- job_card_secondary_item.item_code,
- job_card_secondary_item.item_name,
- job_card_secondary_item.description,
- job_card_secondary_item.stock_uom,
- job_card_secondary_item.type,
- job_card_secondary_item.bom_secondary_item,
- )
- .join(job_card_secondary_item)
- .on(job_card_secondary_item.parent == job_card.name)
- .where(
- (job_card_secondary_item.item_code.isnotnull())
- & (job_card.work_order == self.work_order)
- & (job_card.docstatus == 1)
- )
- .groupby(job_card_secondary_item.item_code, job_card_secondary_item.type)
- .orderby(job_card_secondary_item.idx)
- )
-
- if self.job_card:
- other = other.where(job_card.name == self.job_card)
-
- other = other.run(as_dict=1)
-
- if self.job_card:
- pending_qty = flt(self.fg_completed_qty)
- else:
- pending_qty = flt(self.get_completed_job_card_qty()) - flt(self.pro_doc.produced_qty)
-
- used_secondary_items = self.get_used_secondary_items()
- for row in other:
- row.stock_qty -= flt(used_secondary_items.get(row.item_code))
- row.stock_qty = (row.stock_qty) * flt(self.fg_completed_qty) / flt(pending_qty)
-
- if used_secondary_items.get(row.item_code):
- used_secondary_items[row.item_code] -= row.stock_qty
-
- if cint(frappe.get_cached_value("UOM", row.stock_uom, "must_be_whole_number")):
- row.stock_qty = frappe.utils.ceil(row.stock_qty)
-
- return other
-
- def get_completed_job_card_qty(self):
- return flt(min([d.completed_qty for d in self.pro_doc.operations]))
-
- def get_used_secondary_items(self):
- used_secondary_items = defaultdict(float)
-
- StockEntry = frappe.qb.DocType("Stock Entry")
- StockEntryDetail = frappe.qb.DocType("Stock Entry Detail")
- data = (
- frappe.qb.from_(StockEntry)
- .inner_join(StockEntryDetail)
- .on(StockEntryDetail.parent == StockEntry.name)
- .select(StockEntryDetail.item_code, StockEntryDetail.qty)
- .where(
- (StockEntry.work_order == self.work_order)
- & ((StockEntryDetail.type.isnotnull()) | (StockEntryDetail.is_legacy_scrap_item == 1))
- & (StockEntry.docstatus == 1)
- & (StockEntry.purpose.isin(["Repack", "Manufacture"]))
- )
- ).run(as_dict=1)
-
- for row in data:
- used_secondary_items[row.item_code] += row.qty
-
- return used_secondary_items
-
- def get_unconsumed_raw_materials(self):
- wo = frappe.get_doc("Work Order", self.work_order)
- wo_items = frappe.get_all(
- "Work Order Item",
- filters={"parent": self.work_order},
- fields=["item_code", "source_warehouse", "required_qty", "consumed_qty", "transferred_qty"],
- )
-
- work_order_qty = wo.material_transferred_for_manufacturing or wo.qty
- for item in wo_items:
- item_account_details = get_item_defaults(item.item_code, self.company)
- # Take into account consumption if there are any.
-
- wo_item_qty = item.transferred_qty or item.required_qty
-
- wo_qty_unconsumed = flt(wo_item_qty) - flt(item.consumed_qty)
- wo_qty_to_produce = flt(work_order_qty) - flt(wo.produced_qty)
- bom_qty_per_unit = item.required_qty / wo.qty # per-unit BOM qty
-
- req_qty_each = (wo_qty_unconsumed) / (wo_qty_to_produce or 1)
- req_qty_each = min(req_qty_each, bom_qty_per_unit)
-
- qty = req_qty_each * flt(self.fg_completed_qty)
-
- if qty > 0:
- self.add_to_stock_entry_detail(
- {
- item.item_code: {
- "from_warehouse": wo.wip_warehouse or item.source_warehouse,
- "to_warehouse": "",
- "qty": qty,
- "item_name": item.item_name,
- "description": item.description,
- "stock_uom": item_account_details.stock_uom,
- "expense_account": item_account_details.get("expense_account"),
- "cost_center": item_account_details.get("buying_cost_center"),
- }
- }
- )
-
- def add_transfered_raw_materials_in_items(self) -> None:
- available_materials = get_available_materials(self.work_order)
- wo_data = frappe.db.get_value(
- "Work Order",
- self.work_order,
- ["qty", "produced_qty", "material_transferred_for_manufacturing as trans_qty"],
- as_dict=1,
- )
-
- precision = frappe.get_precision("Stock Entry Detail", "qty")
- for _key, row in available_materials.items():
- remaining_qty_to_produce = flt(wo_data.trans_qty) - flt(wo_data.produced_qty)
- if remaining_qty_to_produce <= 0 and not self.is_return:
- continue
-
- qty = flt(row.qty)
- if not self.is_return:
- qty = (flt(row.qty) * flt(self.fg_completed_qty)) / remaining_qty_to_produce
-
- item = row.item_details
- if cint(frappe.get_cached_value("UOM", item.stock_uom, "must_be_whole_number")):
- qty = frappe.utils.ceil(qty)
-
- if row.batch_details:
- row.batches_to_be_consume = defaultdict(float)
- 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)]
- row.serial_nos = serial_nos
-
- if flt(qty, precision) != 0.0:
- 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
-
- use_serial_batch_fields = frappe.get_single_value("Stock Settings", "use_serial_batch_fields")
-
- ste_item_details = {
- "from_warehouse": item.warehouse,
- "to_warehouse": "",
- "qty": qty,
- "item_name": item.item_name,
- "serial_and_batch_bundle": create_serial_and_batch_bundle(self, row, item, "Outward")
- if not use_serial_batch_fields
- else "",
- "description": item.description,
- "stock_uom": item.stock_uom,
- "expense_account": item.expense_account,
- "cost_center": item.buying_cost_center,
- "original_item": item.original_item,
- "serial_no": "\n".join(row.serial_nos)
- if row.serial_nos and not row.batches_to_be_consume
- else "",
- "use_serial_batch_fields": use_serial_batch_fields,
- }
-
- if self.is_return:
- ste_item_details["to_warehouse"] = item.s_warehouse
-
- if use_serial_batch_fields and not row.serial_no and row.batches_to_be_consume:
- for batch_no, batch_qty in row.batches_to_be_consume.items():
- ste_item_details.update(
- {
- "batch_no": batch_no,
- "qty": batch_qty,
- }
- )
-
- if row.serial_nos:
- serial_nos = row.serial_nos[0 : cint(batch_qty)]
- ste_item_details["serial_no"] = "\n".join(serial_nos)
-
- row.serial_nos = [sn for sn in row.serial_nos if sn not in serial_nos]
-
- self.add_to_stock_entry_detail({item.item_code: ste_item_details})
- else:
- self.add_to_stock_entry_detail({item.item_code: ste_item_details})
-
- @staticmethod
- def get_serial_nos_based_on_transferred_batch(batch_no, serial_nos) -> list:
- serial_nos = frappe.get_all(
- "Serial No",
- filters={"batch_no": batch_no, "name": ("in", serial_nos), "warehouse": ("is", "not set")},
- order_by="creation",
- )
-
- return [d.name for d in serial_nos]
-
- def get_pending_raw_materials(self, backflush_based_on=None):
- """
- issue (item quantity) that is pending to issue or desire to transfer,
- whichever is less
- """
- item_dict = self.get_pro_order_required_items(backflush_based_on)
-
- max_qty = flt(self.pro_doc.qty)
-
- allow_overproduction = False
- overproduction_percentage = flt(
- frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order")
- )
-
- transfer_extra_materials_percentage = flt(
- frappe.db.get_single_value("Manufacturing Settings", "transfer_extra_materials_percentage")
- )
-
- to_transfer_qty = flt(self.pro_doc.material_transferred_for_manufacturing) + flt(
- self.fg_completed_qty
- )
- transfer_limit_qty = max_qty + ((max_qty * overproduction_percentage) / 100)
- if transfer_extra_materials_percentage:
- transfer_limit_qty = max_qty + ((max_qty * transfer_extra_materials_percentage) / 100)
-
- if transfer_limit_qty >= to_transfer_qty:
- allow_overproduction = True
-
- for item, item_details in item_dict.items():
- pending_to_issue = flt(item_details.required_qty) - flt(item_details.transferred_qty)
- desire_to_transfer = flt(self.fg_completed_qty) * flt(item_details.required_qty) / max_qty
-
- if (
- desire_to_transfer <= pending_to_issue
- or (desire_to_transfer > 0 and backflush_based_on == "Material Transferred for Manufacture")
- or allow_overproduction
- ):
- # "No need for transfer but qty still pending to transfer" case can occur
- # when transferring multiple RM in different Stock Entries
- item_dict[item]["qty"] = desire_to_transfer if (desire_to_transfer > 0) else pending_to_issue
- elif pending_to_issue > 0:
- item_dict[item]["qty"] = pending_to_issue
- else:
- item_dict[item]["qty"] = 0
-
- # delete items with 0 qty
- list_of_items = list(item_dict.keys())
- for item in list_of_items:
- if not item_dict[item]["qty"]:
- del item_dict[item]
-
- # show some message
- if not len(item_dict):
- frappe.msgprint(_("""All items have already been transferred for this Work Order."""))
-
- return item_dict
-
- def get_pro_order_required_items(self, backflush_based_on=None):
- """
- Gets Work Order Required Items only if Stock Entry purpose is **Material Transferred for Manufacture**.
- """
- item_dict, job_card_items = frappe._dict(), []
- work_order = frappe.get_doc("Work Order", self.work_order)
-
- consider_job_card = work_order.transfer_material_against == "Job Card" and self.get("job_card")
- if consider_job_card:
- job_card_items = self.get_job_card_item_codes(self.get("job_card"))
-
- if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"):
- wip_warehouse = work_order.wip_warehouse
- else:
- wip_warehouse = None
-
- transfer_extra_materials_percentage = flt(
- frappe.db.get_single_value("Manufacturing Settings", "transfer_extra_materials_percentage")
- )
-
- for d in work_order.get("required_items"):
- if consider_job_card and (d.item_code not in job_card_items):
- continue
-
- additional_qty = 0.0
- if transfer_extra_materials_percentage:
- additional_qty = transfer_extra_materials_percentage * flt(d.required_qty) / 100
-
- transfer_pending = flt(d.required_qty) > flt(d.transferred_qty)
- if additional_qty:
- transfer_pending = (flt(d.required_qty) + additional_qty) > flt(d.transferred_qty)
-
- can_transfer = transfer_pending or (backflush_based_on == "Material Transferred for Manufacture")
-
- if not can_transfer:
- continue
-
- if d.include_item_in_manufacturing:
- item_row = d.as_dict()
- item_row["idx"] = len(item_dict) + 1
-
- if consider_job_card:
- job_card_item = frappe.db.get_value(
- "Job Card Item", {"item_code": d.item_code, "parent": self.get("job_card")}
- )
- item_row["job_card_item"] = job_card_item or None
-
- if d.source_warehouse and not frappe.db.get_value(
- "Warehouse", d.source_warehouse, "is_group"
- ):
- item_row["from_warehouse"] = d.source_warehouse
-
- item_row["to_warehouse"] = wip_warehouse
- if item_row["allow_alternative_item"]:
- item_row["allow_alternative_item"] = work_order.allow_alternative_item
-
- item_dict.setdefault(d.item_code, item_row)
-
- return item_dict
-
- def get_job_card_item_codes(self, job_card=None):
- if not job_card:
- return []
-
- job_card_items = frappe.get_all(
- "Job Card Item", filters={"parent": job_card}, fields=["item_code"], distinct=True
- )
- return [d.item_code for d in job_card_items]
-
- def add_to_stock_entry_detail(self, item_dict, bom_no=None):
- precision = frappe.get_precision("Stock Entry Detail", "qty")
- for d in item_dict:
- item_row = item_dict[d]
-
- child_qty = flt(item_row["qty"], precision)
- if (
- not self.is_return
- and child_qty <= 0
- and not item_row.get("type")
- and not item_row.get("is_legacy_scrap_item")
- ):
- if self.purpose not in ["Receive from Customer", "Send to Subcontractor"]:
- continue
-
- se_child = self.append("items")
- stock_uom = item_row.get("stock_uom") or frappe.db.get_value("Item", d, "stock_uom")
- se_child.s_warehouse = item_row.get("from_warehouse")
- se_child.t_warehouse = item_row.get("to_warehouse")
- se_child.item_code = item_row.get("item_code") or cstr(d)
- se_child.uom = item_row["uom"] if item_row.get("uom") else stock_uom
- se_child.stock_uom = stock_uom
- se_child.qty = child_qty if child_qty > 0 else 0
- se_child.allow_alternative_item = item_row.get("allow_alternative_item", 0)
- se_child.subcontracted_item = item_row.get("main_item_code")
- se_child.cost_center = item_row.get("cost_center") or get_default_cost_center(
- item_row, company=self.company
- )
- se_child.is_finished_item = item_row.get("is_finished_item", 0)
- se_child.po_detail = item_row.get("po_detail")
- se_child.sco_rm_detail = item_row.get("sco_rm_detail")
- se_child.scio_detail = item_row.get("scio_detail")
- se_child.sample_quantity = item_row.get("sample_quantity", 0)
- se_child.type = item_row.get("type")
- se_child.is_legacy_scrap_item = item_row.get("is_legacy")
- se_child.bom_secondary_item = item_row.get("name") or item_row.get("bom_secondary_item")
-
- for field in [
- self.subcontract_data.rm_detail_field,
- "original_item",
- "expense_account",
- "description",
- "item_name",
- "serial_and_batch_bundle",
- "allow_zero_valuation_rate",
- "use_serial_batch_fields",
- "batch_no",
- "serial_no",
- ]:
- if item_row.get(field):
- se_child.set(field, item_row.get(field))
-
- if se_child.s_warehouse is None:
- se_child.s_warehouse = self.from_warehouse
- if se_child.t_warehouse is None:
- se_child.t_warehouse = self.to_warehouse
-
- # in stock uom
- se_child.conversion_factor = flt(item_row.get("conversion_factor")) or 1
- se_child.transfer_qty = flt(
- item_row["qty"] * se_child.conversion_factor, se_child.precision("qty")
- )
-
- se_child.bom_no = bom_no # to be assigned for finished item
- se_child.job_card_item = item_row.get("job_card_item") if self.get("job_card") else None
-
- def validate_with_material_request(self):
- for item in self.get("items"):
- material_request = item.material_request or None
- material_request_item = item.material_request_item or None
- if self.purpose == "Material Transfer" and self.outgoing_stock_entry:
- parent_se = frappe.get_value(
- "Stock Entry Detail",
- item.ste_detail,
- ["material_request", "material_request_item"],
- as_dict=True,
- )
- if parent_se:
- material_request = parent_se.material_request
- material_request_item = parent_se.material_request_item
-
- if material_request:
- mreq_item = frappe.db.get_value(
- "Material Request Item",
- {"name": material_request_item, "parent": material_request},
- ["item_code", "warehouse", "idx"],
- as_dict=True,
- )
- if mreq_item.item_code != item.item_code:
- frappe.throw(
- _("Item for row {0} does not match Material Request").format(item.idx),
- frappe.MappingMismatchError,
- )
- elif self.purpose == "Material Transfer" and self.add_to_transit:
- continue
-
def validate_batch(self):
if self.purpose in [
"Material Transfer for Manufacture",
@@ -3493,133 +1507,7 @@ class StockEntry(StockController, SubcontractingInwardController):
"Send to Subcontractor",
]:
for item in self.get("items"):
- if item.batch_no:
- disabled = frappe.db.get_value("Batch", item.batch_no, "disabled")
- if disabled == 0:
- expiry_date = frappe.db.get_value("Batch", item.batch_no, "expiry_date")
- if expiry_date:
- if getdate(self.posting_date) > getdate(expiry_date):
- frappe.throw(
- _("Batch {0} of Item {1} has expired.").format(
- item.batch_no, item.item_code
- )
- )
- else:
- frappe.throw(
- _("Batch {0} of Item {1} is disabled.").format(item.batch_no, item.item_code)
- )
-
- def update_subcontract_order_supplied_items(self):
- if self.get(self.subcontract_data.order_field) and (
- self.purpose in ["Send to Subcontractor", "Material Transfer"] or self.is_return
- ):
- # Get Subcontract Order Supplied Items Details
- order_supplied_items = frappe.db.get_all(
- self.subcontract_data.order_supplied_items_field,
- filters={"parent": self.get(self.subcontract_data.order_field)},
- fields=["name", "rm_item_code", "reserve_warehouse"],
- )
-
- # Get Items Supplied in Stock Entries against Subcontract Order
- supplied_items = get_supplied_items(
- self.get(self.subcontract_data.order_field),
- self.subcontract_data.rm_detail_field,
- self.subcontract_data.order_field,
- )
-
- for row in order_supplied_items:
- key, item = row.name, {}
- if not supplied_items.get(key):
- # no stock transferred against Subcontract Order Supplied Items row
- item = {"supplied_qty": 0, "returned_qty": 0, "total_supplied_qty": 0}
- else:
- item = supplied_items.get(key)
-
- frappe.db.set_value(self.subcontract_data.order_supplied_items_field, row.name, item)
-
- # RM Item-Reserve Warehouse Dict
- item_wh = {x.get("rm_item_code"): x.get("reserve_warehouse") for x in order_supplied_items}
-
- for d in self.get("items"):
- # Update reserved sub contracted quantity in bin based on Supplied Item Details and
- item_code = d.get("original_item") or d.get("item_code")
- reserve_warehouse = item_wh.get(item_code)
- if not (reserve_warehouse and item_code):
- continue
- stock_bin = get_bin(item_code, reserve_warehouse)
- stock_bin.update_reserved_qty_for_sub_contracting()
-
- def update_transferred_qty(self):
- if self.purpose == "Material Transfer" and self.outgoing_stock_entry:
- stock_entries = {}
- stock_entries_child_list = []
- for d in self.items:
- if not (d.against_stock_entry and d.ste_detail):
- continue
-
- stock_entries_child_list.append(d.ste_detail)
- transferred_qty = frappe.get_all(
- "Stock Entry Detail",
- fields=[{"SUM": "transfer_qty", "as": "qty"}],
- filters={
- "against_stock_entry": d.against_stock_entry,
- "ste_detail": d.ste_detail,
- "docstatus": 1,
- },
- )
-
- if d.docstatus == 1:
- transfer_qty = frappe.get_value("Stock Entry Detail", d.ste_detail, "transfer_qty")
-
- if transferred_qty and transferred_qty[0]:
- if transferred_qty[0].qty > transfer_qty:
- frappe.throw(
- _(
- "Row {0}: Transferred quantity cannot be greater than the requested quantity."
- ).format(d.idx)
- )
-
- stock_entries[(d.against_stock_entry, d.ste_detail)] = (
- transferred_qty[0].qty if transferred_qty and transferred_qty[0] else 0.0
- ) or 0.0
-
- if not stock_entries:
- return None
-
- cond = ""
- for data, transferred_qty in stock_entries.items():
- cond += """ WHEN (parent = {} and name = {}) THEN {}
- """.format(
- frappe.db.escape(data[0]),
- frappe.db.escape(data[1]),
- transferred_qty,
- )
-
- if stock_entries_child_list:
- frappe.db.sql(
- """ UPDATE `tabStock Entry Detail`
- SET
- transferred_qty = CASE {cond} END
- WHERE
- name in ({ste_details}) """.format(
- cond=cond, ste_details=",".join(["%s"] * len(stock_entries_child_list))
- ),
- tuple(stock_entries_child_list),
- )
-
- args = {
- "source_dt": "Stock Entry Detail",
- "target_field": "transferred_qty",
- "target_ref_field": "qty",
- "target_dt": "Stock Entry Detail",
- "join_field": "ste_detail",
- "target_parent_dt": "Stock Entry",
- "target_parent_field": "per_transferred",
- "source_field": "qty",
- "percent_join_field": "against_stock_entry",
- }
-
- self._update_percent_field_in_targets(args, update_modified=True)
+ item.validate_batch()
def update_quality_inspection(self):
if self.inspection_required:
@@ -3636,78 +1524,6 @@ class StockEntry(StockController, SubcontractingInwardController):
{"reference_type": reference_type, "reference_name": reference_name},
)
- def set_material_request_transfer_status(self, status):
- material_requests = []
- if self.outgoing_stock_entry:
- parent_se = frappe.get_value("Stock Entry", self.outgoing_stock_entry, "add_to_transit")
-
- for item in self.items:
- material_request = item.get("material_request")
- if self.purpose == "Material Transfer" and material_request not in material_requests:
- if self.outgoing_stock_entry and parent_se:
- material_request = frappe.get_value(
- "Stock Entry Detail", item.ste_detail, "material_request"
- )
-
- if material_request and material_request not in material_requests:
- material_requests.append(material_request)
- if status == "Completed":
- qty = get_transferred_qty(material_request)
- if qty.get("transfer_qty") > qty.get("transferred_qty"):
- status = "In Transit"
-
- frappe.db.set_value("Material Request", material_request, "transfer_status", status)
-
- def set_serial_no_batch_for_finished_good(self):
- 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 d in self.items:
- if (
- d.is_finished_item
- and d.item_code == self.pro_doc.production_item
- and not d.serial_and_batch_bundle
- ):
- serial_nos = self.get_available_serial_nos()
- if serial_nos:
- row = frappe._dict({"serial_nos": serial_nos[0 : cint(d.qty)]})
-
- id = create_serial_and_batch_bundle(
- self,
- row,
- frappe._dict(
- {
- "item_code": d.item_code,
- "warehouse": d.t_warehouse,
- }
- ),
- )
-
- d.serial_and_batch_bundle = id
- d.use_serial_batch_fields = 0
-
- 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",
- )
-
- for row in data:
- serial_nos.append(row.name)
-
- return serial_nos
-
def update_subcontracting_order_status(self):
if self.subcontracting_order and self.purpose in ["Send to Subcontractor", "Material Transfer"]:
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
@@ -3728,87 +1544,6 @@ class StockEntry(StockController, SubcontractingInwardController):
self.calculate_rate_and_amount()
-@frappe.whitelist()
-def move_sample_to_retention_warehouse(company: str, items: str | list):
- from erpnext.stock.serial_batch_bundle import (
- SerialBatchCreation,
- get_batch_nos,
- )
-
- if isinstance(items, str):
- items = json.loads(items)
-
- retention_warehouse = frappe.get_single_value("Stock Settings", "sample_retention_warehouse")
- stock_entry = frappe.new_doc("Stock Entry")
- stock_entry.company = company
- stock_entry.purpose = "Material Transfer"
- stock_entry.set_stock_entry_type()
- for item in items:
- if item.get("sample_quantity") and item.get("serial_and_batch_bundle"):
- warehouse = item.get("t_warehouse") or item.get("warehouse")
- total_qty = 0
- cls_obj = SerialBatchCreation(
- {
- "type_of_transaction": "Outward",
- "serial_and_batch_bundle": item.get("serial_and_batch_bundle"),
- "item_code": item.get("item_code"),
- "warehouse": warehouse,
- "do_not_save": True,
- }
- )
- sabb = cls_obj.duplicate_package()
- batches = get_batch_nos(item.get("serial_and_batch_bundle"))
- sabe_list = []
- for batch_no in batches.keys():
- sample_quantity = validate_sample_quantity(
- item.get("item_code"),
- item.get("sample_quantity"),
- item.get("transfer_qty") or item.get("qty"),
- batch_no,
- )
-
- sabe = next(item for item in sabb.entries if item.batch_no == batch_no)
- if sample_quantity:
- if sabb.has_serial_no:
- new_sabe = [
- entry
- for entry in sabb.entries
- if entry.batch_no == batch_no
- and frappe.db.exists(
- "Serial No", {"name": entry.serial_no, "warehouse": warehouse}
- )
- ][: int(sample_quantity)]
- sabe_list.extend(new_sabe)
- total_qty += len(new_sabe)
- else:
- total_qty += sample_quantity
- sabe.qty = sample_quantity
- else:
- sabb.entries.remove(sabe)
-
- if total_qty:
- if sabe_list:
- sabb.entries = sabe_list
- sabb.save()
-
- stock_entry.append(
- "items",
- {
- "item_code": item.get("item_code"),
- "s_warehouse": warehouse,
- "t_warehouse": retention_warehouse,
- "qty": total_qty,
- "basic_rate": item.get("valuation_rate"),
- "uom": item.get("uom"),
- "stock_uom": item.get("stock_uom"),
- "conversion_factor": item.get("conversion_factor") or 1.0,
- "serial_and_batch_bundle": sabb.name,
- },
- )
- if stock_entry.get("items"):
- return stock_entry.as_dict()
-
-
@frappe.whitelist()
def make_stock_in_entry(source_name: str, target_doc: str | Document | None = None):
def set_missing_values(source, target):
@@ -3966,27 +1701,6 @@ def get_used_alternative_items(
return used_alternative_items
-def get_valuation_rate_for_finished_good_entry(work_order):
- work_order_qty = flt(
- frappe.get_cached_value("Work Order", work_order, "material_transferred_for_manufacturing")
- )
-
- field = "(SUM(total_outgoing_value) / %s) as valuation_rate" % (work_order_qty)
-
- stock_data = frappe.get_all(
- "Stock Entry",
- fields=field,
- filters={
- "docstatus": 1,
- "purpose": "Material Transfer for Manufacture",
- "work_order": work_order,
- },
- )
-
- if stock_data:
- return stock_data[0].valuation_rate
-
-
@frappe.whitelist()
def get_uom_details(item_code: str, uom: str, qty: float | None):
"""Returns dict `{"conversion_factor": [value], "transfer_qty": qty * [value]}`
@@ -4004,48 +1718,6 @@ def get_uom_details(item_code: str, uom: str, qty: float | None):
return ret
-@frappe.whitelist()
-def get_expired_batch_items():
- from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import get_auto_batch_nos
-
- expired_batches = get_expired_batches()
- if not expired_batches:
- return []
-
- expired_batches_stock = get_auto_batch_nos(
- frappe._dict(
- {
- "batch_no": list(expired_batches.keys()),
- "for_stock_levels": True,
- }
- )
- )
-
- for row in expired_batches_stock:
- row.update(expired_batches.get(row.batch_no))
-
- return expired_batches_stock
-
-
-def get_expired_batches():
- batch = frappe.qb.DocType("Batch")
-
- data = (
- frappe.qb.from_(batch)
- .select(batch.item, batch.name.as_("batch_no"), batch.stock_uom)
- .where((batch.expiry_date <= nowdate()) & (batch.expiry_date.isnotnull()))
- ).run(as_dict=True)
-
- if not data:
- return []
-
- expired_batches = frappe._dict()
- for row in data:
- expired_batches[row.batch_no] = row
-
- return expired_batches
-
-
@frappe.whitelist()
def get_warehouse_details(args: str | dict):
if isinstance(args, str):
@@ -4066,323 +1738,3 @@ def get_warehouse_details(args: str | dict):
"basic_rate": get_incoming_rate(args),
}
return ret
-
-
-@frappe.whitelist()
-def validate_sample_quantity(item_code: str, sample_quantity: int, qty: float, batch_no: str | None = None):
- if cint(qty) < cint(sample_quantity):
- frappe.throw(
- _("Sample quantity {0} cannot be more than received quantity {1}").format(sample_quantity, qty)
- )
- retention_warehouse = frappe.get_single_value("Stock Settings", "sample_retention_warehouse")
- retainted_qty = 0
- if batch_no:
- retainted_qty = get_batch_qty(batch_no, retention_warehouse, item_code)
- max_retain_qty = frappe.get_value("Item", item_code, "sample_quantity")
- if retainted_qty >= max_retain_qty:
- frappe.msgprint(
- _(
- "Maximum Samples - {0} have already been retained for Batch {1} and Item {2} in Batch {3}."
- ).format(retainted_qty, batch_no, item_code, batch_no),
- alert=True,
- )
- sample_quantity = 0
- qty_diff = max_retain_qty - retainted_qty
- if cint(sample_quantity) > cint(qty_diff):
- frappe.msgprint(
- _("Maximum Samples - {0} can be retained for Batch {1} and Item {2}.").format(
- max_retain_qty, batch_no, item_code
- ),
- alert=True,
- )
- sample_quantity = qty_diff
- return sample_quantity
-
-
-def get_supplied_items(
- subcontract_order, rm_detail_field="sco_rm_detail", subcontract_order_field="subcontracting_order"
-):
- fields = [
- "`tabStock Entry Detail`.`transfer_qty`",
- "`tabStock Entry`.`is_return`",
- f"`tabStock Entry Detail`.`{rm_detail_field}`",
- "`tabStock Entry Detail`.`item_code`",
- ]
-
- filters = [
- ["Stock Entry", "docstatus", "=", 1],
- ["Stock Entry", subcontract_order_field, "=", subcontract_order],
- ]
-
- supplied_item_details = {}
- for row in frappe.get_all("Stock Entry", fields=fields, filters=filters):
- if not row.get(rm_detail_field):
- continue
-
- key = row.get(rm_detail_field)
- if key not in supplied_item_details:
- supplied_item_details.setdefault(
- key, frappe._dict({"supplied_qty": 0, "returned_qty": 0, "total_supplied_qty": 0})
- )
-
- supplied_item = supplied_item_details[key]
-
- if row.is_return:
- supplied_item.returned_qty += row.transfer_qty
- else:
- supplied_item.supplied_qty += row.transfer_qty
-
- supplied_item.total_supplied_qty = flt(supplied_item.supplied_qty) - flt(supplied_item.returned_qty)
-
- return supplied_item_details
-
-
-@frappe.whitelist()
-def get_items_from_subcontract_order(source_name: str, target_doc: str | Document | None = None):
- from erpnext.controllers.subcontracting_controller import make_rm_stock_entry
-
- if isinstance(target_doc, str):
- target_doc = frappe.get_doc(json.loads(target_doc))
-
- order_doctype = "Purchase Order" if target_doc.purchase_order else "Subcontracting Order"
- target_doc = make_rm_stock_entry(
- subcontract_order=source_name, order_doctype=order_doctype, target_doc=target_doc
- )
-
- return target_doc
-
-
-def get_available_materials(work_order, stock_entry_doc=None) -> dict:
- data = get_stock_entry_data(work_order, stock_entry_doc=stock_entry_doc)
-
- available_materials = {}
- for row in data:
- key = (row.item_code, row.warehouse)
- if row.purpose != "Material Transfer for Manufacture":
- key = (row.item_code, row.s_warehouse)
-
- if stock_entry_doc and stock_entry_doc.purpose == "Disassemble":
- key = (row.item_code, row.s_warehouse or row.warehouse)
-
- if key not in available_materials:
- available_materials.setdefault(
- key,
- frappe._dict(
- {"item_details": row, "batch_details": defaultdict(float), "qty": 0, "serial_nos": []}
- ),
- )
-
- item_data = available_materials[key]
-
- if row.purpose == "Material Transfer for Manufacture" or (
- stock_entry_doc and stock_entry_doc.purpose == "Disassemble" and row.purpose == "Manufacture"
- ):
- item_data.qty += row.qty
- if row.batch_no:
- item_data.batch_details[row.batch_no] += row.qty
-
- elif row.batch_nos:
- for batch_no, qty in row.batch_nos.items():
- item_data.batch_details[batch_no] += qty
-
- if row.serial_no:
- item_data.serial_nos.extend(get_serial_nos(row.serial_no))
- item_data.serial_nos.sort()
-
- elif row.serial_nos:
- item_data.serial_nos.extend(get_serial_nos(row.serial_nos))
- item_data.serial_nos.sort()
- else:
- # Consume raw material qty in case of 'Manufacture' or 'Material Consumption for Manufacture'
-
- item_data.qty -= row.qty
- if row.batch_no:
- item_data.batch_details[row.batch_no] -= row.qty
-
- elif row.batch_nos:
- for batch_no, qty in row.batch_nos.items():
- item_data.batch_details[batch_no] += qty
-
- if row.serial_no:
- for serial_no in get_serial_nos(row.serial_no):
- if serial_no in item_data.serial_nos:
- item_data.serial_nos.remove(serial_no)
-
- elif row.serial_nos:
- for serial_no in get_serial_nos(row.serial_nos):
- if serial_no in item_data.serial_nos:
- item_data.serial_nos.remove(serial_no)
-
- return available_materials
-
-
-def get_stock_entry_data(work_order, stock_entry_doc=None):
- from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
- get_voucher_wise_serial_batch_from_bundle,
- )
-
- stock_entry = frappe.qb.DocType("Stock Entry")
- stock_entry_detail = frappe.qb.DocType("Stock Entry Detail")
-
- data = (
- frappe.qb.from_(stock_entry)
- .from_(stock_entry_detail)
- .select(
- stock_entry_detail.item_name,
- stock_entry_detail.original_item,
- stock_entry_detail.item_code,
- stock_entry_detail.qty,
- (stock_entry_detail.t_warehouse).as_("warehouse"),
- (stock_entry_detail.s_warehouse).as_("s_warehouse"),
- stock_entry_detail.description,
- stock_entry_detail.stock_uom,
- stock_entry_detail.expense_account,
- stock_entry_detail.cost_center,
- stock_entry_detail.serial_and_batch_bundle,
- stock_entry_detail.batch_no,
- stock_entry_detail.serial_no,
- stock_entry.purpose,
- stock_entry.name,
- )
- .where(
- (stock_entry.name == stock_entry_detail.parent)
- & (stock_entry.work_order == work_order)
- & (stock_entry.docstatus == 1)
- )
- .orderby(stock_entry.creation, stock_entry_detail.item_code, stock_entry_detail.idx)
- )
-
- if stock_entry_doc and stock_entry_doc.purpose == "Disassemble":
- data = data.where(
- stock_entry.purpose.isin(
- [
- "Disassemble",
- "Manufacture",
- ]
- )
- )
-
- data = data.where(stock_entry.name != stock_entry_doc.name)
- else:
- data = data.where(
- stock_entry.purpose.isin(
- [
- "Manufacture",
- "Material Consumption for Manufacture",
- "Material Transfer for Manufacture",
- ]
- )
- )
-
- data = data.where(stock_entry_detail.s_warehouse.isnotnull())
-
- data = data.run(as_dict=1)
-
- if not data:
- return []
-
- voucher_nos = [row.get("name") for row in data if row.get("name")]
- if voucher_nos:
- bundle_data = get_voucher_wise_serial_batch_from_bundle(voucher_no=voucher_nos)
- for row in data:
- key = (row.item_code, row.warehouse, row.name)
- if row.purpose != "Material Transfer for Manufacture":
- key = (row.item_code, row.s_warehouse, row.name)
-
- if stock_entry_doc and stock_entry_doc.purpose == "Disassemble":
- key = (row.item_code, row.s_warehouse or row.warehouse, row.name)
-
- if bundle_data.get(key):
- row.update(bundle_data.get(key))
-
- return data
-
-
-def create_serial_and_batch_bundle(parent_doc, 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": type_of_transaction,
- "posting_date": parent_doc.posting_date,
- "posting_time": parent_doc.posting_time,
- }
- )
-
- precision = frappe.get_precision("Stock Entry Detail", "qty")
- if row.serial_nos and row.batches_to_be_consume:
- doc.has_serial_no = 1
- doc.has_batch_no = 1
- batchwise_serial_nos = get_batchwise_serial_nos(child.item_code, row)
- for batch_no, qty in row.batches_to_be_consume.items():
- while flt(qty, precision) > 0:
- qty -= 1
- doc.append(
- "entries",
- {
- "batch_no": batch_no,
- "serial_no": batchwise_serial_nos.get(batch_no).pop(0),
- "warehouse": row.warehouse,
- "qty": -1,
- },
- )
-
- elif row.serial_nos:
- doc.has_serial_no = 1
- for serial_no in row.serial_nos:
- doc.append("entries", {"serial_no": serial_no, "warehouse": row.warehouse, "qty": -1})
-
- elif row.batches_to_be_consume:
- precision = frappe.get_precision("Serial and Batch Entry", "qty")
- doc.has_batch_no = 1
- for batch_no, qty in row.batches_to_be_consume.items():
- if flt(qty, precision) > 0:
- qty = flt(qty, precision)
- doc.append("entries", {"batch_no": batch_no, "warehouse": row.warehouse, "qty": qty * -1})
-
- if not doc.entries:
- return None
-
- return doc.insert(ignore_permissions=True).name
-
-
-def get_batchwise_serial_nos(item_code, row):
- batchwise_serial_nos = {}
-
- for batch_no in row.batches_to_be_consume:
- serial_nos = frappe.get_all(
- "Serial No",
- filters={"item_code": item_code, "batch_no": batch_no, "name": ("in", row.serial_nos)},
- )
-
- if serial_nos:
- batchwise_serial_nos[batch_no] = sorted([serial_no.name for serial_no in serial_nos])
-
- return batchwise_serial_nos
-
-
-def get_transferred_qty(material_request):
- sed = DocType("Stock Entry Detail")
-
- query = (
- frappe.qb.from_(sed)
- .select(
- Sum(sed.transfer_qty).as_("transfer_qty"),
- Sum(sed.transferred_qty).as_("transferred_qty"),
- )
- .where((sed.material_request == material_request) & (sed.docstatus == 1))
- ).run(as_dict=True)
-
- return query[0]
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_handler/__init__.py b/erpnext/stock/doctype/stock_entry/stock_entry_handler/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_handler/base.py b/erpnext/stock/doctype/stock_entry/stock_entry_handler/base.py
new file mode 100644
index 00000000000..4706be97914
--- /dev/null
+++ b/erpnext/stock/doctype/stock_entry/stock_entry_handler/base.py
@@ -0,0 +1,41 @@
+import frappe
+from frappe import _
+from frappe.utils import flt
+
+from erpnext.manufacturing.doctype.bom.bom import get_backflush_based_on
+
+
+class BaseStockEntry:
+ """Shared foundation for all stock entry purpose handlers.
+
+ Provides common lazy-loaded work order document, backflush configuration,
+ and work order status validation used across multiple handler classes.
+ """
+
+ def __init__(self, se_doc):
+ self.doc = se_doc
+
+ @property
+ def wo_doc(self):
+ if not getattr(self, "_wo_doc", None):
+ if self.doc.work_order:
+ self._wo_doc = frappe.get_doc("Work Order", self.doc.work_order)
+ return getattr(self, "_wo_doc", None)
+
+ @property
+ def backflush_based_on(self):
+ return get_backflush_based_on(self.doc.bom_no)
+
+ def _validate_work_order(self):
+ if not self.wo_doc:
+ return
+
+ msg = ""
+ if flt(self.wo_doc.docstatus) != 1:
+ msg = _("Work Order {0} must be submitted").format(self.doc.work_order)
+
+ if self.wo_doc.status == "Stopped":
+ msg = _("Transaction not allowed against stopped Work Order {0}").format(self.doc.work_order)
+
+ if msg:
+ frappe.throw(msg)
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py b/erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py
new file mode 100644
index 00000000000..a4b2671d484
--- /dev/null
+++ b/erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py
@@ -0,0 +1,529 @@
+from collections import defaultdict
+
+import frappe
+from frappe import _
+from frappe.query_builder.functions import Sum
+from frappe.utils import flt
+
+from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
+from erpnext.stock.serial_batch_bundle import SerialBatchCreation
+from erpnext.stock.utils import get_combine_datetime
+
+from .base import BaseStockEntry
+from .manufacturing import (
+ ceil_qty_if_uom_has_whole_number,
+ get_bom_items,
+ get_production_item_details,
+ get_secondary_items,
+)
+
+
+class DisassembleStockEntry(BaseStockEntry):
+ def validate(self):
+ self.validate_warehouse()
+
+ def validate_warehouse(self):
+ for row in self.doc.items:
+ if not row.s_warehouse and not row.t_warehouse:
+ frappe.throw(_("Source or Target Warehouse is required for item {0}").format(row.item_code))
+
+ def validate_fg_completed_qty(self):
+ if not self.doc.source_stock_entry:
+ return
+
+ from erpnext.manufacturing.doctype.work_order.work_order import get_disassembly_available_qty
+
+ available_qty = get_disassembly_available_qty(self.doc.source_stock_entry, self.doc.name)
+
+ if flt(self.doc.fg_completed_qty) > available_qty:
+ frappe.throw(
+ _(
+ "Cannot disassemble {0} qty against Stock Entry {1}. Only {2} qty available to disassemble."
+ ).format(
+ self.doc.fg_completed_qty,
+ self.doc.source_stock_entry,
+ available_qty,
+ ),
+ title=_("Excess Disassembly"),
+ )
+
+ def add_items(self):
+ """
+ Priority:
+ 1. From a specific Manufacture Stock Entry (exact reversal)
+ 2. From Work Order Manufacture Stock Entries (averaged reversal)
+ 3. From BOM (standalone disassembly)
+ """
+
+ # Auto-set source_stock_entry if WO has exactly one manufacture entry
+ if not self.doc.get("source_stock_entry") and self.doc.work_order:
+ manufacture_entries = frappe.get_all(
+ "Stock Entry",
+ filters={
+ "work_order": self.doc.work_order,
+ "purpose": "Manufacture",
+ "docstatus": 1,
+ },
+ pluck="name",
+ )
+ if len(manufacture_entries) == 1:
+ self.doc.source_stock_entry = manufacture_entries[0]
+
+ if self.doc.get("source_stock_entry"):
+ return self._add_items_for_disassembly_from_stock_entry()
+
+ if self.doc.work_order:
+ return self._add_items_for_disassembly_from_work_order()
+
+ return self._add_items_for_disassembly_from_bom()
+
+ def _add_items_for_disassembly_from_stock_entry(self):
+ source_fg_qty = frappe.db.get_value("Stock Entry", self.doc.source_stock_entry, "fg_completed_qty")
+ if not source_fg_qty:
+ frappe.throw(
+ _("Source Stock Entry {0} has no finished goods quantity").format(self.doc.source_stock_entry)
+ )
+
+ disassemble_qty = flt(self.doc.fg_completed_qty)
+ scale_factor = disassemble_qty / flt(source_fg_qty)
+
+ self._append_disassembly_row_from_source(
+ disassemble_qty=disassemble_qty,
+ scale_factor=scale_factor,
+ )
+
+ def _add_items_for_disassembly_from_work_order(self):
+ wo_produced_qty = frappe.db.get_value("Work Order", self.doc.work_order, "produced_qty")
+
+ wo_produced_qty = flt(wo_produced_qty)
+ if wo_produced_qty <= 0:
+ frappe.throw(_("Work Order {0} has no produced qty").format(self.doc.work_order))
+
+ disassemble_qty = flt(self.doc.fg_completed_qty)
+ if disassemble_qty <= 0:
+ frappe.throw(_("Disassemble Qty cannot be less than or equal to 0."))
+
+ scale_factor = disassemble_qty / wo_produced_qty
+
+ self._append_disassembly_row_from_source(
+ disassemble_qty=disassemble_qty,
+ scale_factor=scale_factor,
+ )
+
+ def _append_disassembly_row_from_source(self, disassemble_qty, scale_factor):
+ for source_row in self.get_items_from_manufacture_stock_entry():
+ self._append_disassembly_item(source_row, disassemble_qty, scale_factor)
+
+ def _get_disassembly_warehouses(self, source_row, disassemble_qty, scale_factor):
+ if source_row.is_finished_item:
+ return disassemble_qty, self.doc.from_warehouse or source_row.t_warehouse, ""
+ elif source_row.s_warehouse:
+ return flt(source_row.qty * scale_factor), "", self.doc.to_warehouse or source_row.s_warehouse
+ else:
+ return flt(source_row.qty * scale_factor), source_row.t_warehouse, ""
+
+ def _build_disassembly_item_dict(self, source_row, qty, s_warehouse, t_warehouse):
+ return {
+ "item_code": source_row.item_code,
+ "item_name": source_row.item_name,
+ "description": source_row.description,
+ "stock_uom": source_row.stock_uom,
+ "uom": source_row.uom,
+ "conversion_factor": source_row.conversion_factor,
+ "basic_rate": source_row.basic_rate,
+ "qty": qty,
+ "s_warehouse": s_warehouse,
+ "t_warehouse": t_warehouse,
+ "is_finished_item": source_row.is_finished_item,
+ "type": source_row.type,
+ "is_legacy_scrap_item": source_row.is_legacy_scrap_item,
+ "bom_secondary_item": source_row.bom_secondary_item,
+ "bom_no": source_row.bom_no,
+ "use_serial_batch_fields": 1 if (source_row.batch_no or source_row.serial_no) else 0,
+ }
+
+ def _append_disassembly_item(self, source_row, disassemble_qty, scale_factor):
+ qty, s_warehouse, t_warehouse = self._get_disassembly_warehouses(
+ source_row, disassemble_qty, scale_factor
+ )
+ item = self._build_disassembly_item_dict(source_row, qty, s_warehouse, t_warehouse)
+ if self.doc.source_stock_entry:
+ item.update({"against_stock_entry": self.doc.source_stock_entry, "ste_detail": source_row.name})
+ self.doc.append("items", item)
+
+ def _add_items_for_disassembly_from_bom(self):
+ if not self.doc.bom_no or not self.doc.fg_completed_qty:
+ frappe.throw(_("BOM and Finished Good Quantity is mandatory for Disassembly"))
+
+ self.add_raw_materials()
+ self.add_secondary_items()
+ self.add_finished_goods()
+
+ def add_raw_materials(self):
+ # Raw materials will be available after disassembly in target warehouse
+ items = get_bom_items(self.doc.bom_no, self.doc.use_multi_level_bom)
+
+ for row in items:
+ row["t_warehouse"] = self.doc.to_warehouse
+ row["from_warehouse"] = ""
+ row["is_finished_item"] = 0
+ row["qty"] = flt(row["qty"]) * flt(self.doc.fg_completed_qty)
+ row["uom"] = row.get("uom") or row.get("stock_uom")
+ self.doc.append("items", row)
+
+ def add_secondary_items(self):
+ # Secondary items will be removed from source warehouse
+
+ secondary_items = get_secondary_items(self.doc.bom_no, self.doc.work_order)
+ for row in secondary_items:
+ item_args = {}
+ fields = [
+ "item_code",
+ "item_name",
+ "uom",
+ "stock_uom",
+ "conversion_factor",
+ "item_group",
+ "description",
+ "type",
+ ]
+ for field in fields:
+ item_args[field] = row.get(field)
+
+ item_args["is_legacy_scrap_item"] = row.get("is_legacy")
+ item_args["s_warehouse"] = self.doc.from_warehouse
+ item_args["uom"] = item_args.get("uom") or item_args.get("stock_uom")
+ item_args["bom_secondary_item"] = row.get("name")
+
+ row.qty = row.qty * self.doc.fg_completed_qty
+ if row.get("process_loss_per"):
+ row.qty -= flt(row.qty * row.get("process_loss_per") / 100)
+ item_args["qty"] = ceil_qty_if_uom_has_whole_number(row.qty, item_args["uom"])
+
+ self.doc.append("items", item_args)
+
+ def add_finished_goods(self):
+ item_details = get_production_item_details(self.doc.work_order, self.doc.bom_no)
+
+ item_details.update(
+ {
+ "conversion_factor": 1,
+ "uom": item_details.stock_uom,
+ "qty": self.doc.fg_completed_qty,
+ "t_warehouse": None,
+ "s_warehouse": self.doc.from_warehouse,
+ "is_finished_item": 1,
+ }
+ )
+
+ item_details["item_code"] = item_details["name"]
+ del item_details["name"]
+
+ self.doc.append("items", item_details)
+
+ def get_items_from_manufacture_stock_entry(self):
+ SE = frappe.qb.DocType("Stock Entry")
+ SED = frappe.qb.DocType("Stock Entry Detail")
+ query = frappe.qb.from_(SED).join(SE).on(SED.parent == SE.name).where(SE.docstatus == 1)
+
+ common_fields = [
+ SED.item_code,
+ SED.item_name,
+ SED.description,
+ SED.stock_uom,
+ SED.uom,
+ SED.basic_rate,
+ SED.conversion_factor,
+ SED.is_finished_item,
+ SED.type,
+ SED.is_legacy_scrap_item,
+ SED.bom_secondary_item,
+ SED.batch_no,
+ SED.serial_no,
+ SED.use_serial_batch_fields,
+ SED.s_warehouse,
+ SED.t_warehouse,
+ SED.bom_no,
+ ]
+
+ if self.doc.source_stock_entry:
+ return (
+ query.select(SED.name, SED.qty, SED.transfer_qty, *common_fields)
+ .where(SE.name == self.doc.source_stock_entry)
+ .orderby(SED.idx)
+ .run(as_dict=True)
+ )
+
+ return (
+ query.select(Sum(SED.qty).as_("qty"), Sum(SED.transfer_qty).as_("transfer_qty"), *common_fields)
+ .where(SE.purpose == "Manufacture")
+ .where(SE.work_order == self.doc.work_order)
+ .groupby(SED.item_code)
+ .orderby(SED.idx)
+ .run(as_dict=True)
+ )
+
+ def on_submit(self):
+ self.set_serial_batch_for_disassembly()
+ self.update_disassembled_order()
+
+ def on_cancel(self):
+ self.update_disassembled_order()
+
+ def set_serial_batch_for_disassembly(self):
+ if self.doc.get("source_stock_entry"):
+ self._set_serial_batch_for_disassembly_from_stock_entry()
+ else:
+ self._set_serial_batch_for_disassembly_from_available_materials()
+
+ def _set_serial_batch_for_disassembly_from_stock_entry(self):
+ from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
+ get_voucher_wise_serial_batch_from_bundle,
+ )
+
+ source_fg_qty = flt(
+ frappe.db.get_value("Stock Entry", self.doc.source_stock_entry, "fg_completed_qty")
+ )
+ scale_factor = flt(self.doc.fg_completed_qty) / source_fg_qty if source_fg_qty else 0
+ bundle_data = get_voucher_wise_serial_batch_from_bundle(voucher_no=[self.doc.source_stock_entry])
+ source_rows_by_name = {r.name: r for r in self.get_items_from_manufacture_stock_entry()}
+ for row in self.doc.items:
+ if not row.ste_detail:
+ continue
+ source_row = source_rows_by_name.get(row.ste_detail)
+ if source_row:
+ self._apply_bundle_to_disassembly_row(row, source_row, bundle_data, scale_factor)
+
+ def _apply_bundle_to_disassembly_row(self, row, source_row, bundle_data, scale_factor):
+ source_warehouse = source_row.s_warehouse or source_row.t_warehouse
+ key = (source_row.item_code, source_warehouse, self.doc.source_stock_entry)
+ source_bundle = bundle_data.get(key, {})
+ batches = self._extract_batches(source_row, source_bundle, row, scale_factor)
+ serial_nos = self._extract_serial_nos(source_row, source_bundle, row)
+ self._set_serial_batch_bundle_for_disassembly_row(row, serial_nos, batches)
+
+ def _extract_batches(self, source_row, source_bundle, row, scale_factor):
+ batches = defaultdict(float)
+ if source_bundle.get("batch_nos"):
+ self._allocate_batches(batches, source_bundle["batch_nos"], row.transfer_qty, scale_factor)
+ elif source_row.batch_no:
+ batches[source_row.batch_no] = row.transfer_qty
+ return batches
+
+ def _allocate_batches(self, batches, batch_nos, transfer_qty, scale_factor):
+ qty_remaining = transfer_qty
+ for batch_no, batch_qty in batch_nos.items():
+ if qty_remaining <= 0:
+ break
+ alloc = min(abs(flt(batch_qty)) * scale_factor, qty_remaining)
+ batches[batch_no] = alloc
+ qty_remaining -= alloc
+
+ def _extract_serial_nos(self, source_row, source_bundle, row):
+ if source_bundle.get("serial_nos"):
+ return get_serial_nos(source_bundle["serial_nos"])[: int(row.transfer_qty)]
+ elif source_row.serial_no:
+ return get_serial_nos(source_row.serial_no)[: int(row.transfer_qty)]
+ return []
+
+ def _set_serial_batch_for_disassembly_from_available_materials(self):
+ available_materials = get_available_materials(self.doc.work_order, self.doc)
+ for row in self.doc.items:
+ warehouse = row.s_warehouse or row.t_warehouse
+ materials = available_materials.get((row.item_code, warehouse))
+ if materials:
+ self._apply_available_material_bundle(row, materials)
+
+ def _apply_available_material_bundle(self, row, materials):
+ batches = self._collect_available_batches(materials.batch_details, row.transfer_qty)
+ serial_nos = materials.serial_nos[: int(row.transfer_qty)] if materials.serial_nos else []
+ self._set_serial_batch_bundle_for_disassembly_row(row, serial_nos, batches)
+
+ def _collect_available_batches(self, batch_details, transfer_qty):
+ batches, qty = defaultdict(float), transfer_qty
+ for batch_no, batch_qty in batch_details.items():
+ if qty <= 0:
+ break
+ batch_qty = abs(batch_qty)
+ if batch_qty <= qty:
+ batches[batch_no], qty = batch_qty, qty - batch_qty
+ else:
+ batches[batch_no], qty = qty, 0
+ return batches
+
+ def _set_serial_batch_bundle_for_disassembly_row(self, row, serial_nos, batches):
+ if not serial_nos and not batches:
+ return
+
+ warehouse = row.s_warehouse or row.t_warehouse
+ bundle_doc = SerialBatchCreation(
+ {
+ "item_code": row.item_code,
+ "warehouse": warehouse,
+ "posting_datetime": get_combine_datetime(self.doc.posting_date, self.doc.posting_time),
+ "voucher_type": self.doc.doctype,
+ "voucher_no": self.doc.name,
+ "voucher_detail_no": row.name,
+ "qty": row.transfer_qty,
+ "type_of_transaction": "Inward" if row.t_warehouse else "Outward",
+ "company": self.doc.company,
+ "do_not_submit": True,
+ }
+ ).make_serial_and_batch_bundle(serial_nos=serial_nos, batch_nos=batches)
+
+ row.serial_and_batch_bundle = bundle_doc.name
+ row.use_serial_batch_fields = 0
+
+ def update_disassembled_order(self):
+ if not self.doc.work_order:
+ return
+
+ if self.doc.fg_completed_qty:
+ pro_doc = frappe.get_doc("Work Order", self.doc.work_order)
+ pro_doc.run_method(
+ "update_disassembled_qty", self.doc.fg_completed_qty, self.doc._action == "cancel"
+ )
+
+
+def get_available_materials(work_order, stock_entry_doc=None) -> dict:
+ data = get_stock_entry_data(work_order, stock_entry_doc=stock_entry_doc)
+ available_materials = {}
+ for row in data:
+ key = _get_material_key(row, stock_entry_doc)
+ if key not in available_materials:
+ available_materials[key] = frappe._dict(
+ {"item_details": row, "batch_details": defaultdict(float), "qty": 0, "serial_nos": []}
+ )
+ _update_material_qty(available_materials[key], row, stock_entry_doc)
+ return available_materials
+
+
+def _get_material_key(row, stock_entry_doc):
+ if stock_entry_doc and stock_entry_doc.purpose == "Disassemble":
+ return (row.item_code, row.s_warehouse or row.warehouse)
+ if row.purpose != "Material Transfer for Manufacture":
+ return (row.item_code, row.s_warehouse)
+ return (row.item_code, row.warehouse)
+
+
+def _update_material_qty(item_data, row, stock_entry_doc):
+ is_inward = row.purpose == "Material Transfer for Manufacture" or (
+ stock_entry_doc and stock_entry_doc.purpose == "Disassemble" and row.purpose == "Manufacture"
+ )
+ if is_inward:
+ _add_inward_material_qty(item_data, row)
+ else:
+ _deduct_consumed_material_qty(item_data, row)
+
+
+def _add_inward_material_qty(item_data, row):
+ item_data.qty += row.qty
+ if row.batch_no:
+ item_data.batch_details[row.batch_no] += row.qty
+ elif row.batch_nos:
+ for batch_no, qty in row.batch_nos.items():
+ item_data.batch_details[batch_no] += qty
+ _extend_serial_nos_from_row(item_data, row)
+
+
+def _extend_serial_nos_from_row(item_data, row):
+ sn = row.serial_no or row.serial_nos
+ if sn:
+ item_data.serial_nos.extend(get_serial_nos(sn))
+ item_data.serial_nos.sort()
+
+
+def _deduct_consumed_material_qty(item_data, row):
+ item_data.qty -= row.qty
+ if row.batch_no:
+ item_data.batch_details[row.batch_no] -= row.qty
+ elif row.batch_nos:
+ for batch_no, qty in row.batch_nos.items():
+ item_data.batch_details[batch_no] += qty
+ _remove_serial_nos_from_available(item_data, row)
+
+
+def _remove_serial_nos_from_available(item_data, row):
+ sn = row.serial_no or row.serial_nos
+ if not sn:
+ return
+ for serial_no in get_serial_nos(sn):
+ if serial_no in item_data.serial_nos:
+ item_data.serial_nos.remove(serial_no)
+
+
+def get_stock_entry_data(work_order, stock_entry_doc=None):
+ data = _run_stock_entry_query(work_order, stock_entry_doc)
+ if not data:
+ return []
+ _enrich_with_bundle_data(data, stock_entry_doc)
+ return data
+
+
+def _run_stock_entry_query(work_order, stock_entry_doc):
+ se = frappe.qb.DocType("Stock Entry")
+ sed = frappe.qb.DocType("Stock Entry Detail")
+ query = _build_stock_entry_base_query(se, sed, work_order)
+ query = _apply_stock_entry_purpose_filter(query, se, sed, stock_entry_doc)
+ return query.run(as_dict=1)
+
+
+def _build_stock_entry_base_query(se, sed, work_order):
+ return (
+ frappe.qb.from_(se)
+ .from_(sed)
+ .select(
+ sed.item_name,
+ sed.original_item,
+ sed.item_code,
+ sed.qty,
+ sed.t_warehouse.as_("warehouse"),
+ sed.s_warehouse.as_("s_warehouse"),
+ sed.description,
+ sed.stock_uom,
+ sed.expense_account,
+ sed.cost_center,
+ sed.serial_and_batch_bundle,
+ sed.batch_no,
+ sed.serial_no,
+ se.purpose,
+ se.name,
+ )
+ .where((se.name == sed.parent) & (se.work_order == work_order) & (se.docstatus == 1))
+ .orderby(se.creation, sed.item_code, sed.idx)
+ )
+
+
+def _apply_stock_entry_purpose_filter(query, se, sed, stock_entry_doc):
+ if stock_entry_doc and stock_entry_doc.purpose == "Disassemble":
+ query = query.where(se.purpose.isin(["Disassemble", "Manufacture"]))
+ return query.where(se.name != stock_entry_doc.name)
+ query = query.where(
+ se.purpose.isin(
+ ["Manufacture", "Material Consumption for Manufacture", "Material Transfer for Manufacture"]
+ )
+ )
+ return query.where(sed.s_warehouse.isnotnull())
+
+
+def _enrich_with_bundle_data(data, stock_entry_doc):
+ from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
+ get_voucher_wise_serial_batch_from_bundle,
+ )
+
+ voucher_nos = [row.get("name") for row in data if row.get("name")]
+ if not voucher_nos:
+ return
+ bundle_data = get_voucher_wise_serial_batch_from_bundle(voucher_no=voucher_nos)
+ for row in data:
+ key = _get_bundle_key(row, stock_entry_doc)
+ if bundle_data.get(key):
+ row.update(bundle_data.get(key))
+
+
+def _get_bundle_key(row, stock_entry_doc):
+ if stock_entry_doc and stock_entry_doc.purpose == "Disassemble":
+ return (row.item_code, row.s_warehouse or row.warehouse, row.name)
+ if row.purpose != "Material Transfer for Manufacture":
+ return (row.item_code, row.s_warehouse, row.name)
+ return (row.item_code, row.warehouse, row.name)
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_handler/manufacturing.py b/erpnext/stock/doctype/stock_entry/stock_entry_handler/manufacturing.py
new file mode 100644
index 00000000000..231664f954d
--- /dev/null
+++ b/erpnext/stock/doctype/stock_entry/stock_entry_handler/manufacturing.py
@@ -0,0 +1,1055 @@
+import json
+from collections import defaultdict
+
+import frappe
+from frappe import _, bold
+from frappe.query_builder.functions import Sum
+from frappe.utils import ceil, cint, flt, get_link_to_form
+
+from erpnext.manufacturing.doctype.bom.bom import add_additional_cost
+from erpnext.stock.serial_batch_bundle import (
+ SerialBatchCreation,
+ get_batch_nos,
+ get_empty_batches_based_work_order,
+)
+
+from .base import BaseStockEntry
+from .serial_batch import create_serial_and_batch_bundle
+
+
+class BaseManufactureStockEntry(BaseStockEntry):
+ def set_default_warehouse(self):
+ for row in self.doc.items:
+ if (
+ not row.s_warehouse
+ and self.doc.from_warehouse
+ and not row.is_finished_item
+ and not row.is_legacy_scrap_item
+ and not row.type
+ ):
+ row.s_warehouse = self.doc.from_warehouse
+ row.t_warehouse = None
+
+ elif (
+ not row.t_warehouse
+ and self.doc.to_warehouse
+ and (row.is_finished_item or row.is_legacy_scrap_item or row.type)
+ ):
+ row.t_warehouse = self.doc.to_warehouse
+ row.s_warehouse = None
+
+ def validate_warehouse(self):
+ for row in self.doc.items:
+ if not row.s_warehouse and not row.t_warehouse:
+ frappe.throw(_("Source or Target Warehouse is required for item {0}").format(row.item_code))
+
+ def validate_raw_materials_exists(self):
+ if frappe.db.get_single_value("Manufacturing Settings", "material_consumption"):
+ return
+
+ raw_materials = []
+ for row in self.doc.items:
+ if row.s_warehouse:
+ raw_materials.append(row.item_code)
+
+ if not raw_materials:
+ frappe.throw(
+ _(
+ "At least one raw material item must be present in the stock entry for the type {0}"
+ ).format(bold(self.doc.purpose)),
+ title=_("Raw Materials Missing"),
+ )
+
+ def get_item_dict(self, row):
+ item_args = {}
+ fields = [
+ "item_code",
+ "item_name",
+ "item_group",
+ "description",
+ "uom",
+ "stock_uom",
+ "conversion_factor",
+ "allow_alternative_item",
+ ]
+ for field in fields:
+ if row.get(field):
+ item_args[field] = row.get(field)
+
+ return item_args
+
+ def add_secondary_items(self):
+ secondary_items = get_secondary_items(self.doc.bom_no, self.doc.work_order)
+ for row in secondary_items:
+ item_args = self.get_item_dict(row)
+ item_args["is_legacy_scrap_item"] = bool(row.get("is_legacy"))
+ item_args["type"] = row.type
+ item_args["bom_secondary_item"] = row.name
+
+ if row.type == "Scrap" and self.wo_doc and self.wo_doc.get("scrap_warehouse"):
+ item_args["t_warehouse"] = self.wo_doc.scrap_warehouse
+ else:
+ item_args["t_warehouse"] = self.doc.to_warehouse
+
+ row.qty = row.qty * self.doc.fg_completed_qty
+ if row.get("process_loss_per"):
+ row.qty -= flt(
+ row.qty * row.get("process_loss_per") / 100, self.doc.precision("fg_completed_qty")
+ )
+
+ item_args["qty"] = ceil_qty_if_uom_has_whole_number(row.qty, row.uom)
+ item_args["transfer_qty"] = item_args["qty"]
+ self.doc.append("items", item_args)
+
+ def set_process_loss_qty(self):
+ precision = self.doc.precision("process_loss_qty")
+ if self.doc.work_order:
+ data = frappe.get_all(
+ "Work Order Operation",
+ filters={"parent": self.doc.work_order},
+ fields=[{"MAX": "process_loss_qty", "as": "process_loss_qty"}],
+ )
+
+ if data and data[0].process_loss_qty:
+ process_loss_qty = data[0].process_loss_qty
+ if flt(self.doc.process_loss_qty, precision) != flt(process_loss_qty, precision):
+ self.doc.process_loss_qty = flt(process_loss_qty, precision)
+
+ frappe.msgprint(
+ _("The Process Loss Qty has reset as per job cards Process Loss Qty"), alert=True
+ )
+
+ if not self.doc.process_loss_percentage and not self.doc.process_loss_qty:
+ self.doc.process_loss_percentage = frappe.get_cached_value(
+ "BOM", self.doc.bom_no, "process_loss_percentage"
+ )
+
+ if self.doc.process_loss_percentage and not self.doc.process_loss_qty:
+ self.doc.process_loss_qty = flt(
+ (flt(self.doc.fg_completed_qty) * flt(self.doc.process_loss_percentage)) / 100
+ )
+ elif self.doc.process_loss_qty and not self.doc.process_loss_percentage:
+ self.doc.process_loss_percentage = flt(
+ (flt(self.doc.process_loss_qty) / flt(self.doc.fg_completed_qty)) * 100
+ )
+
+ def add_finished_goods(self):
+ item_details = get_production_item_details(self.doc.work_order, self.doc.bom_no)
+ fg_item_qty = flt(self.doc.fg_completed_qty) - flt(self.doc.process_loss_qty)
+
+ item_details.update(
+ {
+ "conversion_factor": 1,
+ "uom": item_details.stock_uom,
+ "qty": ceil_qty_if_uom_has_whole_number(fg_item_qty, item_details.stock_uom),
+ "t_warehouse": self.doc.to_warehouse,
+ "s_warehouse": None,
+ "is_finished_item": 1,
+ }
+ )
+
+ item_details["item_code"] = item_details["name"]
+ del item_details["name"]
+
+ item_details["transfer_qty"] = item_details["qty"]
+
+ if self.wo_doc and cint(
+ frappe.db.get_single_value(
+ "Manufacturing Settings", "make_serial_no_batch_from_work_order", cache=True
+ )
+ ):
+ if self.wo_doc.has_serial_no:
+ self.set_serial_nos_for_finished_good(item_details)
+ elif self.wo_doc.has_batch_no:
+ self.set_batchwise_finished_goods(item_details)
+ else:
+ self.doc.append("items", item_details)
+
+ def set_serial_nos_for_finished_good(self, item_details):
+ serial_nos = self.get_available_serial_nos_for_fg(item_details.item_code)
+ if serial_nos:
+ row = frappe._dict({"serial_nos": serial_nos[0 : cint(item_details.qty)]})
+
+ _id = create_serial_and_batch_bundle(
+ self.doc,
+ row,
+ frappe._dict(
+ {
+ "item_code": item_details.item_code,
+ "warehouse": item_details.t_warehouse,
+ }
+ ),
+ )
+
+ item_details.serial_and_batch_bundle = _id
+ item_details.use_serial_batch_fields = 0
+
+ self.doc.append("items", item_details)
+
+ def get_available_serial_nos_for_fg(self, item_code) -> list[str]:
+ return frappe.get_all(
+ "Serial No",
+ filters={
+ "item_code": item_code,
+ "warehouse": ("is", "not set"),
+ "status": "Inactive",
+ "work_order": self.wo_doc.name,
+ },
+ pluck="name",
+ order_by="creation asc",
+ )
+
+ def set_batchwise_finished_goods(self, item_details):
+ batches = get_empty_batches_based_work_order(self.doc.work_order, self.doc.pro_doc.production_item)
+
+ if not batches:
+ self.doc.append("items", item_details)
+ else:
+ self.add_batchwise_finished_good(batches, item_details)
+
+ def add_batchwise_finished_good(self, batches, item_details):
+ qty = flt(self.doc.fg_completed_qty)
+ row = frappe._dict({"batches_to_be_consume": defaultdict(float)})
+ self.update_batches_to_be_consume(batches, row, qty)
+ if row.batches_to_be_consume:
+ self._link_fg_bundle_and_append(item_details, row)
+
+ def _link_fg_bundle_and_append(self, item_details, row):
+ _id = create_serial_and_batch_bundle(
+ self.doc,
+ row,
+ frappe._dict(
+ {"item_code": self.wo_doc.production_item, "warehouse": item_details.get("t_warehouse")}
+ ),
+ )
+ item_details["serial_and_batch_bundle"] = _id
+ self.doc.append("items", item_details)
+
+ def update_batches_to_be_consume(self, batches, row, qty):
+ qty_to_be_consumed = qty
+ for batch_no, batch_qty in sorted(batches.items(), key=lambda x: x[0]):
+ if qty_to_be_consumed <= 0 or batch_qty <= 0:
+ continue
+ batch_qty = min(batch_qty, qty_to_be_consumed)
+ self._consume_batch(row, batch_no, batch_qty)
+ qty_to_be_consumed -= batch_qty
+
+ def _consume_batch(self, row, batch_no, batch_qty):
+ 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)
+ for sn in serial_nos[: cint(batch_qty)]:
+ row.serial_nos.remove(sn)
+ if "batch_details" in row:
+ row.batch_details[batch_no] -= batch_qty
+
+
+class ManufactureStockEntry(BaseManufactureStockEntry):
+ def before_validate(self):
+ self.set_default_warehouse()
+ self.set_job_card_data()
+
+ def validate(self):
+ self.validate_warehouse()
+ self.validate_raw_materials_exists()
+ self.validate_component_and_quantities()
+
+ def set_job_card_data(self):
+ if self.doc.job_card and not self.doc.work_order:
+ data = frappe.db.get_value(
+ "Job Card",
+ self.doc.job_card,
+ ["for_quantity", "work_order", "bom_no", "semi_fg_bom"],
+ as_dict=1,
+ )
+ self.doc.fg_completed_qty = data.for_quantity
+ self.doc.work_order = data.work_order
+ self.doc.from_bom = 1
+ self.doc.bom_no = data.semi_fg_bom or data.bom_no
+
+ def validate_component_and_quantities(self):
+ if not frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"):
+ return
+
+ if not self.doc.fg_completed_qty:
+ return
+
+ rm_items = [item for item in self.doc.items if item.s_warehouse]
+ if not rm_items:
+ return
+
+ _check_bom_component_qty(self.doc, get_bom_items(self.doc.bom_no, self.doc.use_multi_level_bom))
+
+ def validate_work_order(self):
+ if not self.doc.work_order:
+ frappe.throw(_("Work Order is mandatory"))
+
+ def add_items(self):
+ self.add_raw_materials()
+ self.set_process_loss_qty()
+ self.add_finished_goods()
+ self.add_secondary_items()
+ self.add_additional_cost()
+ self.add_secondary_items_from_job_card()
+
+ def add_raw_materials(self):
+ if not frappe.db.get_single_value("Manufacturing Settings", "material_consumption"):
+ if self.backflush_based_on == "BOM" or self.wo_doc.skip_transfer:
+ self.add_raw_materials_based_on_work_order()
+ else:
+ self.add_raw_materials_based_on_transfer()
+ elif self.backflush_based_on == "BOM":
+ self.add_unconsumed_raw_materials()
+ else:
+ self.add_raw_materials_based_on_transfer()
+
+ def add_unconsumed_raw_materials(self):
+ wo = self.wo_doc
+ if not wo:
+ return
+ work_order_qty = flt(wo.material_transferred_for_manufacturing) or flt(wo.qty)
+ wo_qty_to_produce = work_order_qty - flt(wo.produced_qty)
+ for item in wo.get("required_items"):
+ self._append_unconsumed_item(item, wo, wo_qty_to_produce)
+
+ def _append_unconsumed_item(self, item, wo, wo_qty_to_produce):
+ wo_item_qty = flt(item.transferred_qty) or flt(item.required_qty)
+ wo_qty_unconsumed = wo_item_qty - flt(item.consumed_qty)
+ bom_qty_per_unit = flt(item.required_qty) / flt(wo.qty)
+ req_qty_each = min(wo_qty_unconsumed / (wo_qty_to_produce or 1), bom_qty_per_unit)
+ qty = req_qty_each * flt(self.doc.fg_completed_qty)
+ if qty <= 0:
+ return
+ item_args = self.get_item_dict(item)
+ item_args.update(
+ {
+ "conversion_factor": 1,
+ "s_warehouse": wo.wip_warehouse or item.source_warehouse,
+ "uom": item.stock_uom,
+ "qty": ceil_qty_if_uom_has_whole_number(qty, item.stock_uom),
+ }
+ )
+ item_args["transfer_qty"] = item_args["qty"]
+ self.doc.append("items", item_args)
+
+ def add_raw_materials_based_on_work_order(self):
+ bom_items = (
+ self.wo_doc.get("required_items")
+ if self.wo_doc
+ else get_bom_items(self.doc.bom_no, self.doc.use_multi_level_bom)
+ )
+ alternative_items = self.get_alternative_items(bom_items)
+ for row in bom_items:
+ self._append_wo_raw_material(row, alternative_items)
+
+ def _append_wo_raw_material(self, row, alternative_items):
+ item_args = self.get_item_dict(row)
+ item_args.update(
+ {
+ "conversion_factor": 1,
+ "item_group": row.get("item_group"),
+ "s_warehouse": self._resolve_rm_warehouse(row),
+ "uom": row.stock_uom,
+ }
+ )
+ qty = (
+ (row.required_qty / self.wo_doc.qty) * self.doc.fg_completed_qty
+ if self.wo_doc
+ else flt(row.qty) * self.doc.fg_completed_qty
+ )
+ item_args["qty"] = ceil_qty_if_uom_has_whole_number(qty, row.stock_uom)
+ item_args["transfer_qty"] = item_args["qty"]
+ if alt := alternative_items.get(row.item_code):
+ self.set_alternative_item_details(item_args, alt)
+ self.doc.append("items", item_args)
+
+ def _resolve_rm_warehouse(self, row):
+ if self.doc.from_warehouse:
+ return self.doc.from_warehouse
+ if self.wo_doc.from_wip_warehouse:
+ return self.wo_doc.wip_warehouse
+ return row.get("source_warehouse")
+
+ def get_alternative_items(self, bom_items):
+ item_codes_in_bom = [row.item_code for row in bom_items]
+ data = self._query_alternative_items(item_codes_in_bom)
+ if not data:
+ return frappe._dict()
+ return self._index_alternative_items(data)
+
+ def _query_alternative_items(self, item_codes_in_bom):
+ doctype = frappe.qb.DocType("Stock Entry")
+ child_doc = frappe.qb.DocType("Stock Entry Detail")
+ query = (
+ frappe.qb.from_(child_doc)
+ .inner_join(doctype)
+ .on(child_doc.parent == doctype.name)
+ .select(
+ child_doc.item_code,
+ child_doc.uom,
+ child_doc.stock_uom,
+ child_doc.conversion_factor,
+ child_doc.item_name,
+ child_doc.item_group,
+ child_doc.description,
+ child_doc.original_item,
+ )
+ .where(
+ (doctype.work_order == self.doc.work_order)
+ & (doctype.purpose == "Material Transfer for Manufacture")
+ & (doctype.docstatus == 1)
+ )
+ )
+ if item_codes_in_bom:
+ query = query.where(child_doc.original_item.isin(item_codes_in_bom))
+ return query.run(as_dict=1)
+
+ def _index_alternative_items(self, data):
+ alternative_items = frappe._dict()
+ for row in data:
+ alternative_items[row.original_item] = row
+ alternative_items[row.original_item].original_item = None
+ return alternative_items
+
+ def set_alternative_item_details(self, row, alternative_item_details):
+ if self.doc.work_order and row.get("allow_alternative_item") is None:
+ row["allow_alternative_item"] = self.wo_doc.allow_alternative_item
+
+ if row["allow_alternative_item"]:
+ original_item = row["item_code"]
+ row.update(alternative_item_details)
+ row["original_item"] = original_item
+
+ def add_raw_materials_based_on_transfer(self):
+ self.prepare_available_materials_based_on_transfer()
+ pending_qty_to_mfg = flt(self.wo_doc.material_transferred_for_manufacturing) - flt(
+ self.wo_doc.produced_qty
+ )
+ if pending_qty_to_mfg <= 0 and not self.doc.get("is_return"):
+ return
+ for key in self.available_materials:
+ self._append_transfer_based_rm(self.available_materials[key], pending_qty_to_mfg)
+
+ def _append_transfer_based_rm(self, row, pending_qty_to_mfg):
+ item_args = self.get_item_dict(row)
+ is_return = self.doc.get("is_return")
+ qty = row.qty if is_return else (flt(row.qty) * flt(self.doc.fg_completed_qty)) / pending_qty_to_mfg
+ item_args["qty"] = ceil_qty_if_uom_has_whole_number(qty, row.uom)
+ item_args["transfer_qty"] = item_args["qty"]
+ if is_return:
+ item_args["s_warehouse"], item_args["t_warehouse"] = row.s_warehouse, row.t_warehouse
+ else:
+ item_args["t_warehouse"], item_args["s_warehouse"] = None, row.warehouse
+ if row.serial_nos or row.batches:
+ self.assign_serial_batches_to_materials(item_args, row, qty)
+ else:
+ self.doc.append("items", item_args)
+
+ def assign_serial_batches_to_materials(self, item_args, row, qty):
+ if row.serial_nos:
+ self._append_with_serial_nos(item_args, row, qty)
+ elif len(row.batches) == 1:
+ self._append_with_single_batch(item_args, row)
+ elif row.batches:
+ self.split_items_based_on_batches(qty, item_args, row)
+
+ def _append_with_serial_nos(self, item_args, row, qty):
+ if serial_nos := row.serial_nos[: cint(qty)]:
+ item_args["serial_no"] = "\n".join(serial_nos)
+ if not item_args.get("uom"):
+ item_args["uom"] = row.stock_uom
+ item_args["use_serial_batch_fields"] = 1
+ self.doc.append("items", item_args)
+
+ def _append_with_single_batch(self, item_args, row):
+ item_args["batch_no"] = next(iter(row.batches.keys()))
+ if not item_args.get("uom"):
+ item_args["uom"] = row.stock_uom
+ item_args["use_serial_batch_fields"] = 1
+ self.doc.append("items", item_args)
+
+ def split_items_based_on_batches(self, qty, item_args, row):
+ for batch_no, batch_qty in row.batches.items():
+ if qty <= 0:
+ return
+ qty = self._append_batch_split_item(item_args, row, batch_no, batch_qty, qty)
+
+ def _append_batch_split_item(self, item_args, row, batch_no, batch_qty, qty):
+ if batch_qty >= qty:
+ item_args["qty"], qty = qty, 0
+ else:
+ item_args["qty"] = batch_qty
+ qty -= batch_qty
+ row.batches[batch_no] -= batch_qty
+ if not item_args.get("uom"):
+ item_args["uom"] = row.stock_uom
+ item_args["batch_no"] = batch_no
+ item_args["transfer_qty"] = item_args["qty"]
+ item_args["use_serial_batch_fields"] = 1
+ self.doc.append("items", item_args)
+ return qty
+
+ def prepare_available_materials_based_on_transfer(self):
+ self.available_materials = frappe._dict()
+ self._transfer_entries = self.get_transfer_entries()
+ if not self._transfer_entries:
+ return
+
+ self.add_materials_from_transfer()
+ self._consumption_entries = self.get_consumption_entries()
+ if not self._consumption_entries:
+ return
+
+ self.remove_consumed_materials_from_available()
+
+ def return_available_materials_in_source_wh(self):
+ for row in self.doc.items:
+ row.s_warehouse, row.t_warehouse = row.t_warehouse, row.s_warehouse
+
+ def get_transfer_entries(self):
+ stock_entry = frappe.qb.DocType("Stock Entry")
+ stock_entry_detail = frappe.qb.DocType("Stock Entry Detail")
+
+ return (
+ frappe.qb.from_(stock_entry)
+ .inner_join(stock_entry_detail)
+ .on(stock_entry.name == stock_entry_detail.parent)
+ .select(stock_entry_detail.star)
+ .where(
+ (stock_entry.work_order == self.doc.work_order)
+ & (stock_entry.purpose == "Material Transfer for Manufacture")
+ & (stock_entry.docstatus == 1)
+ )
+ .orderby(stock_entry_detail.idx)
+ ).run(as_dict=1)
+
+ def add_materials_from_transfer(self):
+ for row in self._transfer_entries:
+ row.warehouse = row.t_warehouse
+ key = (row.item_code, row.warehouse)
+ if key not in self.available_materials:
+ self.available_materials[key] = frappe._dict(row)
+ else:
+ self.available_materials[key].qty += row.qty
+
+ if row.serial_and_batch_bundle:
+ self.available_materials[key].update(self.get_sabb_details(row.serial_and_batch_bundle))
+
+ def get_consumption_entries(self):
+ stock_entry = frappe.qb.DocType("Stock Entry")
+ stock_entry_detail = frappe.qb.DocType("Stock Entry Detail")
+
+ return (
+ frappe.qb.from_(stock_entry)
+ .inner_join(stock_entry_detail)
+ .on(stock_entry.name == stock_entry_detail.parent)
+ .select(stock_entry_detail.star)
+ .where(
+ (stock_entry.work_order == self.doc.work_order)
+ & (stock_entry_detail.s_warehouse.isnotnull())
+ & (stock_entry.purpose == "Manufacture")
+ & (stock_entry.docstatus == 1)
+ )
+ .orderby(stock_entry_detail.idx)
+ ).run(as_dict=1)
+
+ def remove_consumed_materials_from_available(self):
+ for row in self._consumption_entries:
+ row.warehouse = row.s_warehouse
+ key = (row.item_code, row.warehouse)
+ self.available_materials[key].qty -= row.qty
+ if row.serial_and_batch_bundle:
+ self._deduct_consumed_serial_batch(key, row.serial_and_batch_bundle)
+
+ def _deduct_consumed_serial_batch(self, key, sabb_name):
+ _details = self.get_sabb_details(sabb_name)
+ if _details.serial_nos:
+ for sn in _details.serial_nos:
+ self.available_materials[key].serial_nos.remove(sn)
+ elif _details.batches:
+ for batch_no, qty in _details.batches.items():
+ # qty is negative, so add instead of subtract
+ self.available_materials[key].batches[batch_no] += qty
+
+ def add_additional_cost(self):
+ if not self.wo_doc:
+ return
+
+ add_additional_cost(self.doc, self.wo_doc)
+
+ def add_secondary_items_from_job_card(self):
+ if not self.wo_doc:
+ return
+
+ secondary_items = self.get_secondary_items_from_job_card()
+ for row in secondary_items:
+ row.uom = row.uom or row.stock_uom
+ row.qty = ceil_qty_if_uom_has_whole_number(row.stock_qty, row.stock_uom)
+ row.transfer_qty = row.qty
+ row.s_warehouse = None
+ row.t_warehouse = row.warehouse or self.doc.to_warehouse
+ row.is_legacy_scrap_item = row.is_legacy
+ row.type = row.get("type")
+
+ self.doc.append("items", row)
+
+ def get_secondary_items_from_job_card(self):
+ if not self.wo_doc.operations:
+ return []
+ secondary_items = get_secondary_items_from_job_card(self.doc.work_order, self.doc.job_card)
+ pending_qty = self._get_pending_secondary_qty()
+ used_secondary_items = self.get_used_secondary_items()
+ self._adjust_secondary_item_qtys(secondary_items, used_secondary_items, pending_qty)
+ return secondary_items
+
+ def _get_pending_secondary_qty(self):
+ if self.doc.job_card:
+ return flt(self.doc.fg_completed_qty)
+ return flt(self.get_completed_job_card_qty()) - flt(self.wo_doc.produced_qty)
+
+ def _adjust_secondary_item_qtys(self, secondary_items, used_secondary_items, pending_qty):
+ for row in secondary_items:
+ row.stock_qty -= flt(used_secondary_items.get(row.item_code))
+ row.stock_qty = row.stock_qty * flt(self.doc.fg_completed_qty) / flt(pending_qty)
+ if used_secondary_items.get(row.item_code):
+ used_secondary_items[row.item_code] -= row.stock_qty
+
+ def get_used_secondary_items(self):
+ data = self._query_used_secondary_items()
+ used_secondary_items = defaultdict(float)
+ for row in data:
+ used_secondary_items[row.item_code] += row.qty
+ return used_secondary_items
+
+ def _query_used_secondary_items(self):
+ se = frappe.qb.DocType("Stock Entry")
+ sed = frappe.qb.DocType("Stock Entry Detail")
+ return (
+ frappe.qb.from_(se)
+ .inner_join(sed)
+ .on(sed.parent == se.name)
+ .select(sed.item_code, sed.qty)
+ .where(
+ (se.work_order == self.doc.work_order)
+ & ((sed.type.isnotnull()) | (sed.is_legacy_scrap_item == 1))
+ & (se.docstatus == 1)
+ & (se.purpose.isin(["Repack", "Manufacture"]))
+ )
+ ).run(as_dict=1)
+
+ def get_completed_job_card_qty(self):
+ return flt(min([d.completed_qty for d in self.wo_doc.operations]))
+
+ def get_sabb_details(self, sabb):
+ sabb_entries = frappe.get_all(
+ "Serial and Batch Entry",
+ filters={"parent": sabb, "docstatus": 1, "is_cancelled": 0},
+ fields=["serial_no", "batch_no", "qty"],
+ order_by="idx",
+ )
+
+ serial_nos = []
+ batches = defaultdict(float)
+
+ for row in sabb_entries:
+ if row.serial_no:
+ serial_nos.append(row.serial_no)
+ else:
+ batches[row.batch_no] += row.qty
+
+ return frappe._dict({"serial_nos": serial_nos, "batches": batches})
+
+ def on_submit(self):
+ self.update_job_card_and_work_order()
+
+ def on_cancel(self):
+ self.update_job_card_and_work_order()
+
+ def update_job_card_and_work_order(self):
+ if self.doc.job_card:
+ self._update_job_card_on_manufacture()
+ if self.doc.work_order:
+ self._update_work_order_on_manufacture()
+
+ def _update_job_card_on_manufacture(self):
+ job_doc = frappe.get_doc("Job Card", self.doc.job_card)
+ job_doc.set_consumed_qty_in_job_card_item(self.doc)
+ job_doc.set_manufactured_qty()
+ job_doc.update_work_order()
+
+ def _update_work_order_on_manufacture(self):
+ self._validate_work_order()
+ if self.doc.fg_completed_qty:
+ self.wo_doc.run_method("update_work_order_qty")
+ self.wo_doc.run_method("update_planned_qty")
+ self.wo_doc.run_method("update_status")
+ if not self.wo_doc.operations:
+ self.wo_doc.set_actual_dates()
+
+
+class RepackStockEntry(BaseManufactureStockEntry):
+ def before_validate(self):
+ self.set_default_warehouse()
+
+ def validate(self):
+ self.validate_raw_materials_exists()
+ self.validate_repack_entry()
+
+ def validate_repack_entry(self):
+ fg_items = {row.item_code: row for row in self.doc.items if row.is_finished_item}
+
+ if len(fg_items) > 1 and not all(row.set_basic_rate_manually for row in fg_items.values()):
+ frappe.throw(
+ _(
+ "When there are multiple finished goods ({0}) in a Repack stock entry, the basic rate for all finished goods must be set manually. To set rate manually, enable the checkbox 'Set Basic Rate Manually' in the respective finished good row."
+ ).format(", ".join(fg_items)),
+ title=_("Set Basic Rate Manually"),
+ )
+
+ def add_items(self):
+ self.add_raw_materials_based_on_bom()
+ self.set_process_loss_qty()
+ self.add_finished_goods()
+ self.add_secondary_items()
+
+ def add_raw_materials_based_on_bom(self):
+ bom_items = get_bom_items(self.doc.bom_no, self.doc.use_multi_level_bom)
+
+ for row in bom_items:
+ row.s_warehouse = self.doc.from_warehouse
+ row.qty = row.qty * self.doc.fg_completed_qty
+ row.transfer_qty = row.qty
+ if not row.uom:
+ row.uom = row.stock_uom
+
+ self.doc.append("items", row)
+
+
+class MaterialConsumptionForManufactureStockEntry(ManufactureStockEntry):
+ def before_validate(self):
+ self.set_default_warehouse()
+
+ def validate(self):
+ self.validate_work_order()
+
+ def add_items(self):
+ if self.backflush_based_on == "BOM" or self.wo_doc.skip_transfer:
+ self.add_raw_materials_based_on_work_order()
+ else:
+ self.add_raw_materials_based_on_transfer()
+
+
+def get_production_item_details(work_order=None, bom_no=None):
+ production_item = (
+ frappe.get_cached_value("Work Order", work_order, "production_item")
+ if work_order
+ else frappe.get_cached_value("BOM", bom_no, "item")
+ )
+ return frappe.get_cached_value(
+ "Item",
+ production_item,
+ ["item_name", "item_group", "description", "stock_uom", "name"],
+ as_dict=1,
+ )
+
+
+def _check_bom_component_qty(doc, bom_items):
+ """Validate that stock entry items match BOM quantities."""
+ precision = frappe.get_precision("Stock Entry Detail", "qty")
+ for row in bom_items:
+ row.qty = row.qty * doc.fg_completed_qty
+ matched_item = next(
+ (
+ item
+ for item in doc.items
+ if item.s_warehouse
+ and (item.item_code == row.item_code or item.original_item == row.item_code)
+ ),
+ None,
+ )
+ if matched_item:
+ if flt(row.qty, precision) != flt(matched_item.qty, precision):
+ frappe.throw(
+ _(
+ "For the item {0}, the consumed quantity should be {1} according to the BOM {2}."
+ ).format(
+ bold(row.item_code),
+ flt(row.qty),
+ get_link_to_form("BOM", doc.bom_no),
+ ),
+ title=_("Incorrect Component Quantity"),
+ )
+ else:
+ frappe.throw(
+ _("According to the BOM {0}, the Item '{1}' is missing in the stock entry.").format(
+ get_link_to_form("BOM", doc.bom_no), bold(row.item_code)
+ ),
+ title=_("Missing Item"),
+ )
+
+
+def get_bom_items(bom_no, use_multi_level_bom=None, qty=None, fetch_secondary_items=False):
+ if use_multi_level_bom is None:
+ use_multi_level_bom = frappe.get_cached_value("BOM", bom_no, "use_multi_level_bom")
+ qty = qty or 1
+
+ if fetch_secondary_items:
+ table_name = "BOM Secondary Item"
+ else:
+ table_name = "BOM Explosion Item" if use_multi_level_bom else "BOM Item"
+
+ items = _run_bom_items_query(bom_no, table_name, qty)
+ return _deduplicate_bom_items(items)
+
+
+def _run_bom_items_query(bom_no, table_name, qty):
+ bom_doc = frappe.qb.DocType("BOM")
+ doctype = frappe.qb.DocType(table_name)
+ query = (
+ frappe.qb.from_(doctype)
+ .inner_join(bom_doc)
+ .on(doctype.parent == bom_doc.name)
+ .select(
+ doctype.item_code,
+ doctype.item_name,
+ doctype.stock_uom,
+ doctype.description,
+ (doctype.stock_qty / bom_doc.quantity.as_("qty") * qty).as_("qty"),
+ doctype.rate.as_("basic_rate"),
+ )
+ .where((bom_doc.name == bom_no) & (bom_doc.docstatus == 1))
+ .orderby(doctype.idx)
+ )
+ return _add_bom_table_specific_fields(query, doctype, table_name).run(as_dict=1)
+
+
+def _add_bom_table_specific_fields(query, doctype, table_name):
+ if table_name == "BOM Secondary Item":
+ return query.select(
+ doctype.name,
+ doctype.cost_allocation_per,
+ doctype.uom,
+ doctype.process_loss_per,
+ doctype.type,
+ doctype.is_legacy,
+ doctype.conversion_factor,
+ )
+ if table_name == "BOM Item":
+ return query.select(
+ doctype.allow_alternative_item, doctype.uom, doctype.conversion_factor, doctype.bom_no
+ )
+ return query
+
+
+def _deduplicate_bom_items(items):
+ item_dict = {}
+ for item in items:
+ if item.item_code in item_dict:
+ item_dict[item.item_code].qty += item.qty
+ else:
+ item_dict[item.item_code] = item
+ return list(item_dict.values())
+
+
+def get_secondary_items(bom_no, work_order=None):
+ if (
+ frappe.db.get_single_value(
+ "Manufacturing Settings", "set_op_cost_and_secondary_items_from_sub_assemblies"
+ )
+ and work_order
+ and frappe.get_cached_value("Work Order", work_order, "use_multi_level_bom")
+ ):
+ return get_secondary_items_from_sub_assemblies(bom_no)
+ else:
+ return get_bom_items(bom_no, fetch_secondary_items=True)
+
+
+def get_secondary_items_from_sub_assemblies(bom_no):
+ items = []
+ bom_items = get_bom_items(bom_no)
+ for row in bom_items:
+ if not row.bom_no:
+ continue
+
+ items.extend(get_bom_items(row.bom_no, qty=row.qty, fetch_secondary_items=True))
+ items.extend(get_secondary_items_from_sub_assemblies(row.bom_no))
+
+ return items
+
+
+def get_secondary_items_from_job_card(work_order, jc_name=None):
+ job_card = frappe.qb.DocType("Job Card")
+ job_card_secondary_item = frappe.qb.DocType("Job Card Secondary Item")
+
+ secondary_items = (
+ frappe.qb.from_(job_card)
+ .select(
+ Sum(job_card_secondary_item.stock_qty).as_("stock_qty"),
+ job_card_secondary_item.item_code,
+ job_card_secondary_item.item_name,
+ job_card_secondary_item.description,
+ job_card_secondary_item.stock_uom,
+ job_card_secondary_item.type,
+ job_card_secondary_item.bom_secondary_item,
+ )
+ .join(job_card_secondary_item)
+ .on(job_card_secondary_item.parent == job_card.name)
+ .where(
+ (job_card_secondary_item.item_code.isnotnull())
+ & (job_card.work_order == work_order)
+ & (job_card.docstatus == 1)
+ )
+ .groupby(job_card_secondary_item.item_code, job_card_secondary_item.type)
+ .orderby(job_card_secondary_item.idx)
+ )
+
+ if jc_name:
+ secondary_items = secondary_items.where(job_card.name == jc_name)
+
+ return secondary_items.run(as_dict=1)
+
+
+def ceil_qty_if_uom_has_whole_number(qty, stock_uom):
+ if cint(frappe.get_cached_value("UOM", stock_uom, "must_be_whole_number")):
+ qty = ceil(qty)
+
+ return qty
+
+
+@frappe.whitelist()
+def move_sample_to_retention_warehouse(company: str, items: str | list):
+ if isinstance(items, str):
+ items = json.loads(items)
+
+ retention_warehouse = frappe.get_single_value("Stock Settings", "sample_retention_warehouse")
+ stock_entry = frappe.new_doc("Stock Entry")
+ stock_entry.company = company
+ stock_entry.purpose = "Material Transfer"
+ stock_entry.set_stock_entry_type()
+
+ for item in items:
+ if item.get("sample_quantity") and item.get("serial_and_batch_bundle"):
+ _process_sample_item(stock_entry, item, retention_warehouse)
+
+ if stock_entry.get("items"):
+ return stock_entry.as_dict()
+
+
+def _process_sample_item(stock_entry, item, retention_warehouse):
+ warehouse = item.get("t_warehouse") or item.get("warehouse")
+ sabb = _duplicate_sample_bundle(item, warehouse)
+ total_qty, sabe_list = _collect_sample_batches(sabb, item, warehouse)
+ if total_qty:
+ _append_sample_entry(stock_entry, sabb, item, warehouse, retention_warehouse, total_qty, sabe_list)
+
+
+def _duplicate_sample_bundle(item, warehouse):
+ return SerialBatchCreation(
+ {
+ "type_of_transaction": "Outward",
+ "serial_and_batch_bundle": item.get("serial_and_batch_bundle"),
+ "item_code": item.get("item_code"),
+ "warehouse": warehouse,
+ "do_not_save": True,
+ }
+ ).duplicate_package()
+
+
+def _collect_sample_batches(sabb, item, warehouse):
+ batches = get_batch_nos(item.get("serial_and_batch_bundle"))
+ sabe_list, total_qty = [], 0
+ for batch_no in batches.keys():
+ qty, entries = _process_sample_batch(sabb, item, warehouse, batch_no)
+ total_qty += qty
+ sabe_list.extend(entries)
+ return total_qty, sabe_list
+
+
+def _process_sample_batch(sabb, item, warehouse, batch_no):
+ sample_quantity = validate_sample_quantity(
+ item.get("item_code"),
+ item.get("sample_quantity"),
+ item.get("transfer_qty") or item.get("qty"),
+ batch_no,
+ )
+ sabe = next(entry for entry in sabb.entries if entry.batch_no == batch_no)
+ if not sample_quantity:
+ sabb.entries.remove(sabe)
+ return 0, []
+ return _apply_sample_quantity(sabb, sabe, warehouse, batch_no, sample_quantity)
+
+
+def _apply_sample_quantity(sabb, sabe, warehouse, batch_no, sample_quantity):
+ if sabb.has_serial_no:
+ entries = [
+ e
+ for e in sabb.entries
+ if e.batch_no == batch_no
+ and frappe.db.exists("Serial No", {"name": e.serial_no, "warehouse": warehouse})
+ ][: int(sample_quantity)]
+ return len(entries), entries
+ sabe.qty = sample_quantity
+ return sample_quantity, []
+
+
+def _append_sample_entry(stock_entry, sabb, item, warehouse, retention_warehouse, total_qty, sabe_list):
+ if sabe_list:
+ sabb.entries = sabe_list
+ sabb.save()
+ stock_entry.append(
+ "items",
+ {
+ "item_code": item.get("item_code"),
+ "s_warehouse": warehouse,
+ "t_warehouse": retention_warehouse,
+ "qty": total_qty,
+ "basic_rate": item.get("valuation_rate"),
+ "uom": item.get("uom"),
+ "stock_uom": item.get("stock_uom"),
+ "conversion_factor": item.get("conversion_factor") or 1.0,
+ "serial_and_batch_bundle": sabb.name,
+ },
+ )
+
+
+@frappe.whitelist()
+def validate_sample_quantity(item_code: str, sample_quantity: int, qty: float, batch_no: str | None = None):
+ from erpnext.stock.doctype.batch.batch import get_batch_qty
+
+ if cint(qty) < cint(sample_quantity):
+ frappe.throw(
+ _("Sample quantity {0} cannot be more than received quantity {1}").format(sample_quantity, qty)
+ )
+ return _adjust_sample_quantity(item_code, sample_quantity, batch_no, get_batch_qty)
+
+
+def _adjust_sample_quantity(item_code, sample_quantity, batch_no, get_batch_qty):
+ retention_warehouse = frappe.get_single_value("Stock Settings", "sample_retention_warehouse")
+ retainted_qty = get_batch_qty(batch_no, retention_warehouse, item_code) if batch_no else 0
+ max_retain_qty = frappe.get_value("Item", item_code, "sample_quantity")
+ if retainted_qty >= max_retain_qty:
+ _warn_max_retained(retainted_qty, batch_no, item_code)
+ return 0
+ return _cap_sample_quantity(sample_quantity, max_retain_qty, retainted_qty, batch_no, item_code)
+
+
+def _warn_max_retained(retainted_qty, batch_no, item_code):
+ frappe.msgprint(
+ _("Maximum Samples - {0} have already been retained for Batch {1} and Item {2} in Batch {3}.").format(
+ retainted_qty, batch_no, item_code, batch_no
+ ),
+ alert=True,
+ )
+
+
+def _cap_sample_quantity(sample_quantity, max_retain_qty, retainted_qty, batch_no, item_code):
+ qty_diff = max_retain_qty - retainted_qty
+ if cint(sample_quantity) > cint(qty_diff):
+ frappe.msgprint(
+ _("Maximum Samples - {0} can be retained for Batch {1} and Item {2}.").format(
+ max_retain_qty, batch_no, item_code
+ ),
+ alert=True,
+ )
+ return qty_diff
+ return sample_quantity
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_handler/material_receipt_issue.py b/erpnext/stock/doctype/stock_entry/stock_entry_handler/material_receipt_issue.py
new file mode 100644
index 00000000000..7ae15ed2ea2
--- /dev/null
+++ b/erpnext/stock/doctype/stock_entry/stock_entry_handler/material_receipt_issue.py
@@ -0,0 +1,83 @@
+import frappe
+from frappe import _
+from frappe.query_builder.functions import Sum
+
+from .base import BaseStockEntry
+from .manufacturing import get_bom_items
+
+
+class MaterialReceiptStockEntry(BaseStockEntry):
+ def before_validate(self):
+ self.set_default_warehouse()
+
+ def validate(self):
+ self.validate_warehouse()
+
+ def set_default_warehouse(self):
+ for row in self.doc.items:
+ row.s_warehouse = None
+ if not row.t_warehouse and self.doc.to_warehouse:
+ row.t_warehouse = self.doc.to_warehouse
+
+ def validate_warehouse(self):
+ for row in self.doc.items:
+ if not row.t_warehouse:
+ frappe.throw(_("Target Warehouse is required for item {0}").format(row.item_code))
+
+
+class BaseMaterialIssueStockEntry(BaseStockEntry):
+ def set_default_warehouse(self):
+ for row in self.doc.items:
+ row.t_warehouse = None
+ if not row.s_warehouse and self.doc.from_warehouse:
+ row.s_warehouse = self.doc.from_warehouse
+
+ def validate_warehouse(self):
+ for row in self.doc.items:
+ if not row.s_warehouse:
+ frappe.throw(_("Source Warehouse is required for item {0}").format(row.item_code))
+
+
+class MaterialIssueStockEntry(BaseMaterialIssueStockEntry):
+ def before_validate(self):
+ self.set_default_warehouse()
+
+ def validate(self):
+ self.validate_warehouse()
+
+ def add_items(self):
+ self.add_raw_materials_based_on_bom()
+
+ def add_raw_materials_based_on_bom(self):
+ bom_items = get_bom_items(self.doc.bom_no, self.doc.use_multi_level_bom)
+
+ for row in bom_items:
+ row.s_warehouse = self.doc.from_warehouse
+ row.qty = row.qty * self.doc.fg_completed_qty
+ if not row.uom:
+ row.uom = row.stock_uom
+
+ self.doc.append("items", row)
+
+
+def get_consumed_items(work_order):
+ """Get all raw materials consumed through consumption entries for a work order."""
+ parent = frappe.qb.DocType("Stock Entry")
+ child = frappe.qb.DocType("Stock Entry Detail")
+
+ return (
+ frappe.qb.from_(parent)
+ .join(child)
+ .on(parent.name == child.parent)
+ .select(
+ child.item_code,
+ Sum(child.qty).as_("qty"),
+ child.original_item,
+ )
+ .where(
+ (parent.docstatus == 1)
+ & (parent.purpose == "Material Consumption for Manufacture")
+ & (parent.work_order == work_order)
+ )
+ .groupby(child.item_code, child.original_item)
+ ).run(as_dict=True)
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_handler/material_transfer.py b/erpnext/stock/doctype/stock_entry/stock_entry_handler/material_transfer.py
new file mode 100644
index 00000000000..880226e51cc
--- /dev/null
+++ b/erpnext/stock/doctype/stock_entry/stock_entry_handler/material_transfer.py
@@ -0,0 +1,435 @@
+import frappe
+from frappe import _
+from frappe.query_builder.functions import Sum
+from frappe.utils import cstr, flt
+
+from .base import BaseStockEntry
+from .manufacturing import _check_bom_component_qty, get_bom_items
+
+
+class BaseMaterialTransferStockEntry(BaseStockEntry):
+ def set_default_warehouse(self):
+ for row in self.doc.items:
+ if not row.t_warehouse and self.doc.to_warehouse:
+ row.t_warehouse = self.doc.to_warehouse
+ if not row.s_warehouse and self.doc.from_warehouse:
+ row.s_warehouse = self.doc.from_warehouse
+
+ def validate_warehouse(self):
+ for row in self.doc.items:
+ if not row.t_warehouse:
+ frappe.throw(_("Target Warehouse is required for item {0}").format(row.item_code))
+ if not row.s_warehouse:
+ frappe.throw(_("Source Warehouse is required for item {0}").format(row.item_code))
+
+ def validate_same_source_target_warehouse(self):
+ """
+ Raises: frappe.ValidationError: If warehouses are same and no inventory dimensions differ
+ """
+
+ if not frappe.get_single_value("Stock Settings", "validate_material_transfer_warehouses"):
+ return
+
+ from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
+
+ inventory_dimensions = get_inventory_dimensions()
+ for item in self.doc.items:
+ if cstr(item.s_warehouse) == cstr(item.t_warehouse):
+ if not inventory_dimensions:
+ frappe.throw(
+ _(
+ "Row #{0}: Source and Target Warehouse cannot be the same for Material Transfer"
+ ).format(item.idx),
+ title=_("Invalid Source and Target Warehouse"),
+ )
+ else:
+ difference_found = False
+ for dimension in inventory_dimensions:
+ fieldname = (
+ dimension.source_fieldname
+ if dimension.source_fieldname.startswith("to_")
+ else f"to_{dimension.source_fieldname}"
+ )
+ if (
+ item.get(dimension.source_fieldname)
+ and item.get(fieldname)
+ and item.get(dimension.source_fieldname) != item.get(fieldname)
+ ):
+ difference_found = True
+ break
+ if not difference_found:
+ frappe.throw(
+ _(
+ "Row #{0}: Source, Target Warehouse and Inventory Dimensions cannot be the exact same for Material Transfer"
+ ).format(item.idx),
+ title=_("Invalid Source and Target Warehouse"),
+ )
+
+
+class MaterialTransferStockEntry(BaseMaterialTransferStockEntry):
+ def before_validate(self):
+ self.set_default_warehouse()
+
+ def validate(self):
+ self.validate_warehouse()
+ self.validate_same_source_target_warehouse()
+
+ def on_submit(self):
+ self.update_subcontract_order_supplied_items()
+
+ def on_cancel(self):
+ self.update_subcontract_order_supplied_items()
+
+ def update_subcontract_order_supplied_items(self):
+ if not self.doc.get(self.doc.subcontract_data.order_field):
+ return
+
+ from .subcontracting import SendToSubcontractorStockEntry
+
+ SendToSubcontractorStockEntry(self.doc).update_subcontract_order_supplied_items()
+
+
+class MaterialTransferForManufactureStockEntry(BaseMaterialTransferStockEntry):
+ def before_validate(self):
+ self.set_default_warehouse()
+
+ def validate(self):
+ self.validate_warehouse()
+ self.validate_component_and_quantities()
+ self.validate_same_source_target_warehouse()
+
+ def validate_component_and_quantities(self):
+ if not frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"):
+ return
+
+ if not self.doc.fg_completed_qty:
+ return
+
+ _check_bom_component_qty(self.doc, get_bom_items(self.doc.bom_no, self.doc.use_multi_level_bom))
+
+ def add_items(self):
+ item_dict = self.get_pending_raw_materials()
+
+ for item in item_dict.values():
+ item["s_warehouse"] = item.get("from_warehouse")
+ if self.wo_doc and not item.get("t_warehouse"):
+ item["t_warehouse"] = self.wo_doc.wip_warehouse
+
+ for item_code in item_dict:
+ self.doc.append("items", item_dict[item_code])
+
+ def get_pending_raw_materials(self):
+ """Return pending raw material qty to transfer, capped at what's still needed."""
+ item_dict = self.get_work_order_required_items()
+ max_qty = flt(self.wo_doc.qty)
+ allow_overproduction = self._is_overproduction_allowed(max_qty)
+
+ for item, item_details in item_dict.items():
+ item_dict[item]["qty"] = self._calculate_item_transfer_qty(
+ item_details, allow_overproduction, max_qty
+ )
+ item_dict[item]["transfer_qty"] = flt(item_dict[item]["qty"]) * flt(
+ item_dict[item].get("conversion_factor") or 1
+ )
+
+ item_dict = {k: v for k, v in item_dict.items() if v["qty"]}
+
+ if not item_dict:
+ frappe.msgprint(_("All items have already been transferred for this Work Order."))
+
+ return item_dict
+
+ def _is_overproduction_allowed(self, max_qty):
+ overproduction_pct = flt(
+ frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order")
+ )
+ extra_materials_pct = flt(
+ frappe.db.get_single_value("Manufacturing Settings", "transfer_extra_materials_percentage")
+ )
+ to_transfer_qty = flt(self.wo_doc.material_transferred_for_manufacturing) + flt(
+ self.doc.fg_completed_qty
+ )
+ limit_pct = extra_materials_pct or overproduction_pct
+ transfer_limit_qty = max_qty + (max_qty * limit_pct / 100)
+ return transfer_limit_qty >= to_transfer_qty
+
+ def _calculate_item_transfer_qty(self, item_details, allow_overproduction, max_qty):
+ pending_to_issue = flt(item_details.required_qty) - flt(item_details.transferred_qty)
+ desire_to_transfer = flt(self.doc.fg_completed_qty) * flt(item_details.required_qty) / max_qty
+ can_transfer = (
+ desire_to_transfer <= pending_to_issue
+ or (desire_to_transfer > 0 and self.backflush_based_on == "Material Transferred for Manufacture")
+ or allow_overproduction
+ )
+ return _resolve_transfer_qty(desire_to_transfer, pending_to_issue, can_transfer)
+
+ def get_work_order_required_items(self):
+ """Gets Work Order Required Items for Material Transfer for Manufacture."""
+ work_order = self.wo_doc
+ consider_job_card = work_order.transfer_material_against == "Job Card" and self.doc.get("job_card")
+ job_card_items = self.get_job_card_item_codes() if consider_job_card else []
+ wip_warehouse = self._resolve_wip_warehouse(work_order)
+ extra_pct = flt(
+ frappe.db.get_single_value("Manufacturing Settings", "transfer_extra_materials_percentage")
+ )
+ item_dict = frappe._dict()
+ for d in work_order.get("required_items"):
+ self._add_required_item(
+ item_dict, d, consider_job_card, job_card_items, wip_warehouse, extra_pct, work_order
+ )
+ return item_dict
+
+ def _resolve_wip_warehouse(self, work_order):
+ if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"):
+ return work_order.wip_warehouse
+ return None
+
+ def _add_required_item(
+ self, item_dict, d, consider_job_card, job_card_items, wip_warehouse, extra_pct, work_order
+ ):
+ if consider_job_card and d.item_code not in job_card_items:
+ return
+ additional_qty = extra_pct * flt(d.required_qty) / 100 if extra_pct else 0.0
+ transfer_pending = (
+ (flt(d.required_qty) + additional_qty) > flt(d.transferred_qty)
+ if additional_qty
+ else flt(d.required_qty) > flt(d.transferred_qty)
+ )
+ can_transfer = transfer_pending or self.backflush_based_on == "Material Transferred for Manufacture"
+ if not can_transfer or not d.include_item_in_manufacturing:
+ return
+ self._build_required_item_row(item_dict, d, consider_job_card, wip_warehouse, work_order)
+
+ def _build_required_item_row(self, item_dict, d, consider_job_card, wip_warehouse, work_order):
+ item_row = d.as_dict()
+ item_row["idx"] = len(item_dict) + 1
+ if consider_job_card:
+ item_row["job_card_item"] = self._get_job_card_item(d.item_code)
+ if d.source_warehouse and not frappe.db.get_value("Warehouse", d.source_warehouse, "is_group"):
+ item_row["from_warehouse"] = d.source_warehouse
+ item_row["to_warehouse"] = wip_warehouse
+ if item_row["allow_alternative_item"]:
+ item_row["allow_alternative_item"] = work_order.allow_alternative_item
+ item_dict.setdefault(d.item_code, item_row)
+
+ def _get_job_card_item(self, item_code):
+ return (
+ frappe.db.get_value("Job Card Item", {"item_code": item_code, "parent": self.doc.get("job_card")})
+ or None
+ )
+
+ def get_job_card_item_codes(self):
+ if not self.doc.get("job_card"):
+ return []
+
+ return frappe.get_all(
+ "Job Card Item", filters={"parent": self.doc.get("job_card")}, pluck="item_code", distinct=True
+ )
+
+ def on_submit(self):
+ self.update_job_card_and_work_order()
+
+ def on_cancel(self):
+ self.update_job_card_and_work_order()
+
+ def update_job_card_and_work_order(self):
+ if self.doc.job_card:
+ job_doc = frappe.get_doc("Job Card", self.doc.job_card)
+ job_doc.set_transferred_qty(update_status=True)
+ job_doc.set_transferred_qty_in_job_card_item(self.doc)
+
+ if self.doc.work_order:
+ self._validate_work_order()
+
+ if self.doc.fg_completed_qty:
+ if self.doc.docstatus == 1:
+ self.wo_doc.add_additional_items(self.doc)
+ else:
+ self.wo_doc.remove_additional_items(self.doc)
+
+ self.wo_doc.run_method("update_work_order_qty")
+
+ self.wo_doc.run_method("update_status")
+ if not self.wo_doc.operations:
+ self.wo_doc.set_actual_dates()
+
+
+class MaterialRequestStockEntry(BaseMaterialTransferStockEntry):
+ def before_validate(self):
+ self.set_default_warehouse()
+
+ def validate(self):
+ self.validate_warehouse()
+ self.validate_material_request()
+
+ def get_material_request(self, item_row):
+ material_request = item_row.material_request or None
+ material_request_item = item_row.material_request_item or None
+
+ if self.doc.outgoing_stock_entry:
+ parent_se = frappe.get_value(
+ "Stock Entry Detail",
+ item_row.ste_detail,
+ ["material_request", "material_request_item"],
+ as_dict=True,
+ )
+ if parent_se:
+ material_request = parent_se.material_request
+ material_request_item = parent_se.material_request_item
+
+ return material_request, material_request_item
+
+ def validate_material_request(self):
+ for row in self.doc.items:
+ material_request, material_request_item = self.get_material_request(row)
+ if not material_request:
+ return
+
+ mreq_item = frappe.db.get_value(
+ "Material Request Item",
+ {"name": material_request_item, "parent": material_request},
+ ["item_code", "warehouse", "idx"],
+ as_dict=True,
+ )
+
+ if mreq_item.item_code != row.item_code:
+ frappe.throw(
+ _("Item for row {0} does not match Material Request").format(row.idx),
+ frappe.MappingMismatchError,
+ )
+
+ def on_submit(self):
+ self.update_transferred_qty()
+ if self.doc.add_to_transit:
+ self.set_material_request_transfer_status("In Transit")
+
+ if self.doc.outgoing_stock_entry:
+ self.set_material_request_transfer_status("Completed")
+
+ def on_cancel(self):
+ self.update_transferred_qty()
+ if self.doc.add_to_transit:
+ self.set_material_request_transfer_status("Not Started")
+
+ if self.doc.outgoing_stock_entry:
+ self.set_material_request_transfer_status("In Transit")
+
+ def update_transferred_qty(self):
+ if not self.doc.outgoing_stock_entry:
+ return
+
+ stock_entries, child_list = self._collect_transferred_qtys()
+ if not stock_entries:
+ return
+
+ self._bulk_update_transferred_qty(stock_entries, child_list)
+ self._update_per_transferred_field()
+
+ def _get_item_transferred_qty(self, item):
+ sed = frappe.qb.DocType("Stock Entry Detail")
+ result = (
+ frappe.qb.from_(sed)
+ .select(Sum(sed.transfer_qty).as_("qty"))
+ .where(
+ (sed.against_stock_entry == item.against_stock_entry)
+ & (sed.ste_detail == item.ste_detail)
+ & (sed.docstatus == 1)
+ )
+ ).run(as_dict=True)
+ return result[0].qty if result and result[0].qty else 0.0
+
+ def _validate_item_transferred_qty(self, item, transferred_qty):
+ if item.docstatus != 1:
+ return
+
+ transfer_qty = frappe.get_value("Stock Entry Detail", item.ste_detail, "transfer_qty")
+ if transferred_qty > transfer_qty:
+ frappe.throw(
+ _("Row {0}: Transferred quantity cannot be greater than the requested quantity.").format(
+ item.idx
+ )
+ )
+
+ def _collect_transferred_qtys(self):
+ stock_entries, child_list = {}, []
+ for item in self.doc.items:
+ if not (item.against_stock_entry and item.ste_detail):
+ continue
+
+ transferred_qty = self._get_item_transferred_qty(item)
+ self._validate_item_transferred_qty(item, transferred_qty)
+ child_list.append(item.ste_detail)
+ stock_entries[(item.against_stock_entry, item.ste_detail)] = transferred_qty
+ return stock_entries, child_list
+
+ def _bulk_update_transferred_qty(self, stock_entries, child_list):
+ sed = frappe.qb.DocType("Stock Entry Detail")
+ case_expr = self._build_case_expr(sed, stock_entries)
+ (
+ frappe.qb.update(sed)
+ .set(sed.transferred_qty, case_expr.else_(sed.transferred_qty))
+ .where(sed.name.isin(child_list))
+ ).run()
+
+ def _build_case_expr(self, sed, stock_entries):
+ from pypika import Case
+
+ case_expr = Case()
+ for (parent, name), qty in stock_entries.items():
+ case_expr = case_expr.when((sed.parent == parent) & (sed.name == name), qty)
+ return case_expr
+
+ def _update_per_transferred_field(self):
+ self.doc._update_percent_field_in_targets(self._get_per_transferred_config(), update_modified=True)
+
+ def _get_per_transferred_config(self):
+ return {
+ "source_dt": "Stock Entry Detail",
+ "target_field": "transferred_qty",
+ "target_ref_field": "qty",
+ "target_dt": "Stock Entry Detail",
+ "join_field": "ste_detail",
+ "target_parent_dt": "Stock Entry",
+ "target_parent_field": "per_transferred",
+ "source_field": "qty",
+ "percent_join_field": "against_stock_entry",
+ }
+
+ def set_material_request_transfer_status(self, status):
+ material_requests = []
+ parent_se = (
+ frappe.get_value("Stock Entry", self.doc.outgoing_stock_entry, "add_to_transit")
+ if self.doc.outgoing_stock_entry
+ else None
+ )
+ for item in self.doc.items:
+ mr = item.get("material_request")
+ if mr not in material_requests and self.doc.outgoing_stock_entry and parent_se:
+ mr = frappe.get_value("Stock Entry Detail", item.ste_detail, "material_request")
+ if mr and mr not in material_requests:
+ status = self._update_mr_transfer_status(mr, status, material_requests)
+
+ def _update_mr_transfer_status(self, material_request, status, material_requests):
+ material_requests.append(material_request)
+ if status == "Completed":
+ qty = get_transferred_qty(material_request)
+ if qty.get("transfer_qty") > qty.get("transferred_qty"):
+ status = "In Transit"
+ frappe.db.set_value("Material Request", material_request, "transfer_status", status)
+ return status
+
+
+def _resolve_transfer_qty(desire_to_transfer, pending_to_issue, can_transfer):
+ # "No need for transfer but qty still pending" can occur when transferring multiple RM in different Stock Entries
+ if can_transfer:
+ return desire_to_transfer if desire_to_transfer > 0 else pending_to_issue
+ return pending_to_issue if pending_to_issue > 0 else 0
+
+
+def get_transferred_qty(material_request):
+ sed = frappe.qb.DocType("Stock Entry Detail")
+ return (
+ frappe.qb.from_(sed)
+ .select(Sum(sed.transfer_qty).as_("transfer_qty"), Sum(sed.transferred_qty).as_("transferred_qty"))
+ .where((sed.material_request == material_request) & (sed.docstatus == 1))
+ ).run(as_dict=True)[0]
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_handler/serial_batch.py b/erpnext/stock/doctype/stock_entry/stock_entry_handler/serial_batch.py
new file mode 100644
index 00000000000..517affad752
--- /dev/null
+++ b/erpnext/stock/doctype/stock_entry/stock_entry_handler/serial_batch.py
@@ -0,0 +1,373 @@
+from collections import defaultdict
+
+import frappe
+from frappe import _
+from frappe.utils import cint, cstr, flt, nowdate
+
+from erpnext.manufacturing.doctype.bom.bom import get_backflush_based_on
+from erpnext.stock.serial_batch_bundle import SerialBatchCreation, get_serial_or_batch_items
+from erpnext.stock.utils import get_combine_datetime
+
+from .base import BaseStockEntry
+
+
+class StockEntrySABB(BaseStockEntry):
+ def make_serial_and_batch_bundle_for_outward(self):
+ serial_or_batch_items = get_serial_or_batch_items(self.doc.items)
+ if not serial_or_batch_items:
+ return
+
+ serial_nos, batch_nos = self.get_serial_batch_fields_for_subcontracting_inward()
+ already_picked_serial_nos = []
+
+ for row in self.doc.items:
+ if row.use_serial_batch_fields or not row.s_warehouse:
+ continue
+ if row.item_code not in serial_or_batch_items:
+ continue
+
+ bundle_doc = self._create_or_update_bundle_for_row(
+ row, serial_nos, batch_nos, already_picked_serial_nos
+ )
+ if not bundle_doc:
+ continue
+
+ for entry in bundle_doc.entries:
+ if entry.serial_no:
+ already_picked_serial_nos.append(entry.serial_no)
+
+ row.serial_and_batch_bundle = bundle_doc.name
+
+ def _create_or_update_bundle_for_row(self, row, serial_nos, batch_nos, already_picked_serial_nos):
+ if row.serial_and_batch_bundle and abs(row.transfer_qty) != abs(
+ frappe.get_cached_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "total_qty")
+ ):
+ return SerialBatchCreation(
+ {
+ "item_code": row.item_code,
+ "warehouse": row.s_warehouse,
+ "serial_and_batch_bundle": row.serial_and_batch_bundle,
+ "type_of_transaction": "Outward",
+ "ignore_serial_nos": already_picked_serial_nos,
+ "qty": row.transfer_qty * -1,
+ }
+ ).update_serial_and_batch_entries(
+ serial_nos=serial_nos.get(row.name), batch_nos=batch_nos.get(row.name)
+ )
+
+ if not row.serial_and_batch_bundle and frappe.get_single_value(
+ "Stock Settings", "auto_create_serial_and_batch_bundle_for_outward"
+ ):
+ return SerialBatchCreation(
+ {
+ "item_code": row.item_code,
+ "warehouse": row.s_warehouse,
+ "posting_datetime": get_combine_datetime(self.doc.posting_date, self.doc.posting_time),
+ "voucher_type": self.doc.doctype,
+ "voucher_detail_no": row.name,
+ "qty": row.transfer_qty * -1,
+ "ignore_serial_nos": already_picked_serial_nos,
+ "type_of_transaction": "Outward",
+ "company": self.doc.company,
+ "do_not_submit": True,
+ }
+ ).make_serial_and_batch_bundle(
+ serial_nos=serial_nos.get(row.name), batch_nos=batch_nos.get(row.name)
+ )
+
+ return None
+
+ def get_serial_nos_and_batches_from_sres(self, scio_detail, only_pending=True):
+ serial_nos, batch_nos = [], frappe._dict()
+
+ table = frappe.qb.DocType("Stock Reservation Entry")
+ child_table = frappe.qb.DocType("Serial and Batch Entry")
+ query = (
+ frappe.qb.from_(table)
+ .join(child_table)
+ .on(table.name == child_table.parent)
+ .select(child_table.serial_no, child_table.batch_no, child_table.qty)
+ .where((table.docstatus == 1) & (table.voucher_detail_no == scio_detail))
+ )
+
+ if only_pending:
+ query = query.where(child_table.qty != child_table.delivered_qty)
+ else:
+ query = query.where(child_table.delivered_qty > 0)
+
+ for d in query.run(as_dict=True):
+ if d.serial_no and d.serial_no not in serial_nos:
+ serial_nos.append(d.serial_no)
+ if d.batch_no and d.batch_no not in batch_nos:
+ batch_nos[d.batch_no] = d.qty
+
+ return serial_nos, batch_nos
+
+ def get_serial_batch_fields_for_subcontracting_inward(self):
+ serial_nos, batch_nos = frappe._dict(), frappe._dict()
+ for row in self.doc.items:
+ if self.doc.purpose in [
+ "Return Raw Material to Customer",
+ "Subcontracting Delivery",
+ "Subcontracting Return",
+ ]:
+ if not row.serial_and_batch_bundle:
+ serial_nos_list, batch_nos_list = self.get_serial_nos_and_batches_from_sres(
+ row.scio_detail, only_pending=self.doc.purpose != "Subcontracting Return"
+ )
+
+ if len(batch_nos_list) > 1:
+ row.use_serial_batch_fields = 0
+
+ if row.use_serial_batch_fields:
+ if serial_nos_list and not row.serial_no:
+ row.serial_no = "\n".join(serial_nos_list)
+ if batch_nos_list and not row.batch_no:
+ row.batch_no = next(iter(batch_nos_list.keys()))
+
+ serial_nos[row.name], batch_nos[row.name] = serial_nos_list, batch_nos_list
+
+ return serial_nos, batch_nos
+
+ def get_available_reserved_materials(self):
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ get_reserved_materials,
+ )
+
+ voucher_no = self.doc.work_order or self.doc.subcontracting_order
+ reserved_entries = get_reserved_materials(voucher_no)
+ if not reserved_entries:
+ return {}
+
+ itemwise_serial_batch_qty = frappe._dict()
+
+ for d in reserved_entries:
+ key = (d.item_code, d.warehouse)
+ if key not in itemwise_serial_batch_qty:
+ itemwise_serial_batch_qty[key] = frappe._dict(
+ {
+ "serial_no": [],
+ "batch_no": defaultdict(float),
+ "batchwise_sn": defaultdict(list),
+ }
+ )
+
+ details = itemwise_serial_batch_qty[key]
+ if d.batch_no:
+ details.batch_no[d.batch_no] += d.qty
+ if d.serial_no:
+ details.batchwise_sn[d.batch_no].extend(d.serial_no.split("\n"))
+ elif d.serial_no:
+ details.serial_no.append(d.serial_no)
+
+ return itemwise_serial_batch_qty
+
+ def set_serial_batch_based_on_reservation(self):
+ if self.doc.work_order and frappe.get_cached_value(
+ "Work Order", self.doc.work_order, "reserve_stock"
+ ):
+ skip_transfer = frappe.get_cached_value("Work Order", self.doc.work_order, "skip_transfer")
+ backflush_based_on = get_backflush_based_on(self.doc.bom_no)
+
+ if (
+ self.doc.purpose not in ["Material Transfer for Manufacture"]
+ and backflush_based_on != "BOM"
+ and not skip_transfer
+ ):
+ return
+
+ reservation_entries = self.get_available_reserved_materials()
+ if not reservation_entries:
+ return
+
+ new_items_to_add = []
+ for d in self.doc.items:
+ if d.serial_and_batch_bundle or d.serial_no or d.batch_no:
+ continue
+
+ key = (d.item_code, d.s_warehouse)
+ if details := reservation_entries.get(key):
+ self._apply_batch_reservation_to_item(d, details, new_items_to_add)
+ d.use_serial_batch_fields = 1
+
+ for new_row in new_items_to_add:
+ self.doc.append("items", new_row)
+
+ self._sort_and_reindex_items()
+
+ def _apply_batch_reservation_to_item(self, d, details, new_items_to_add):
+ original_qty = d.qty
+ if batches := details.get("batch_no"):
+ original_qty = self._distribute_batches_to_item(
+ d, batches, details, new_items_to_add, original_qty
+ )
+ if details.get("serial_no"):
+ d.serial_no = "\n".join(details.get("serial_no")[: cint(d.qty)])
+
+ def _distribute_batches_to_item(self, d, batches, details, new_items_to_add, original_qty):
+ for batch_no, qty in batches.items():
+ if original_qty <= 0:
+ break
+ if qty <= 0:
+ continue
+ if d.batch_no:
+ original_qty, _ = self._make_overflow_batch_row(
+ d, batches, details, new_items_to_add, batch_no, qty, original_qty
+ )
+ else:
+ self._assign_batch_to_item(d, batches, details, batch_no, qty)
+ return original_qty
+
+ def _make_overflow_batch_row(self, d, batches, details, new_items_to_add, batch_no, qty, original_qty):
+ new_row = frappe.copy_doc(d)
+ new_row.name = None
+ new_row.batch_no = batch_no
+ new_row.qty = qty
+ new_row.idx = d.idx + 1
+ if new_row.batch_no and details.get("batchwise_sn"):
+ new_row.serial_no = "\n".join(details.get("batchwise_sn")[new_row.batch_no][: cint(new_row.qty)])
+ new_items_to_add.append(new_row)
+ batches[batch_no] -= qty
+ return original_qty - qty, new_row
+
+ def _assign_batch_to_item(self, d, batches, details, batch_no, qty):
+ if qty >= d.qty:
+ d.batch_no = batch_no
+ batches[batch_no] -= d.qty
+ else:
+ d.batch_no = batch_no
+ d.qty = qty
+ batches[batch_no] = 0
+ if d.batch_no and details.get("batchwise_sn"):
+ d.serial_no = "\n".join(details.get("batchwise_sn")[d.batch_no][: cint(d.qty)])
+
+ def _sort_and_reindex_items(self):
+ sorted_items = sorted(self.doc.items, key=lambda x: x.item_code)
+ if self.doc.purpose == "Manufacture":
+ # ensure finished item at last
+ sorted_items = sorted(sorted_items, key=lambda x: cstr(x.t_warehouse))
+
+ for idx, row in enumerate(sorted_items, start=1):
+ row.idx = idx
+
+ self.doc.set("items", sorted_items)
+
+
+def create_serial_and_batch_bundle(parent_doc, 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
+ doc = _make_bundle_doc(parent_doc, child, type_of_transaction or "Inward")
+ _populate_bundle_entries(doc, row, child)
+ if not doc.entries:
+ return None
+ return doc.insert(ignore_permissions=True).name
+
+
+def _make_bundle_doc(parent_doc, child, type_of_transaction):
+ return frappe.get_doc(
+ {
+ "doctype": "Serial and Batch Bundle",
+ "voucher_type": "Stock Entry",
+ "item_code": child.item_code,
+ "warehouse": child.warehouse,
+ "type_of_transaction": type_of_transaction,
+ "posting_date": parent_doc.posting_date,
+ "posting_time": parent_doc.posting_time,
+ }
+ )
+
+
+def _populate_bundle_entries(doc, row, child):
+ precision = frappe.get_precision("Stock Entry Detail", "qty")
+ if row.serial_nos and row.batches_to_be_consume:
+ _append_serial_batch_entries(doc, row, child, precision)
+ elif row.serial_nos:
+ doc.has_serial_no = 1
+ for serial_no in row.serial_nos:
+ doc.append("entries", {"serial_no": serial_no, "warehouse": row.warehouse, "qty": -1})
+ elif row.batches_to_be_consume:
+ _append_batch_entries(doc, row)
+
+
+def _append_serial_batch_entries(doc, row, child, precision):
+ doc.has_serial_no = 1
+ doc.has_batch_no = 1
+ batchwise_serial_nos = get_batchwise_serial_nos(child.item_code, row)
+ for batch_no, qty in row.batches_to_be_consume.items():
+ while flt(qty, precision) > 0:
+ qty -= 1
+ doc.append(
+ "entries",
+ {
+ "batch_no": batch_no,
+ "serial_no": batchwise_serial_nos.get(batch_no).pop(0),
+ "warehouse": row.warehouse,
+ "qty": -1,
+ },
+ )
+
+
+def _append_batch_entries(doc, row):
+ precision = frappe.get_precision("Serial and Batch Entry", "qty")
+ doc.has_batch_no = 1
+ for batch_no, qty in row.batches_to_be_consume.items():
+ if flt(qty, precision) > 0:
+ doc.append(
+ "entries", {"batch_no": batch_no, "warehouse": row.warehouse, "qty": flt(qty, precision) * -1}
+ )
+
+
+def get_batchwise_serial_nos(item_code, row):
+ batchwise_serial_nos = {}
+
+ for batch_no in row.batches_to_be_consume:
+ serial_nos = frappe.get_all(
+ "Serial No",
+ filters={"item_code": item_code, "batch_no": batch_no, "name": ("in", row.serial_nos)},
+ )
+
+ if serial_nos:
+ batchwise_serial_nos[batch_no] = sorted([serial_no.name for serial_no in serial_nos])
+
+ return batchwise_serial_nos
+
+
+@frappe.whitelist()
+def get_expired_batch_items():
+ expired_batches = get_expired_batches()
+ if not expired_batches:
+ return []
+ return _enrich_expired_batches_with_stock(expired_batches)
+
+
+def _enrich_expired_batches_with_stock(expired_batches):
+ from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import get_auto_batch_nos
+
+ expired_batches_stock = get_auto_batch_nos(
+ frappe._dict({"batch_no": list(expired_batches.keys()), "for_stock_levels": True})
+ )
+ for row in expired_batches_stock:
+ row.update(expired_batches.get(row.batch_no))
+ return expired_batches_stock
+
+
+def get_expired_batches():
+ batch = frappe.qb.DocType("Batch")
+
+ data = (
+ frappe.qb.from_(batch)
+ .select(batch.item, batch.name.as_("batch_no"), batch.stock_uom)
+ .where((batch.expiry_date <= nowdate()) & (batch.expiry_date.isnotnull()))
+ ).run(as_dict=True)
+
+ if not data:
+ return []
+
+ expired_batches = frappe._dict()
+ for row in data:
+ expired_batches[row.batch_no] = row
+
+ return expired_batches
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_handler/subcontracting.py b/erpnext/stock/doctype/stock_entry/stock_entry_handler/subcontracting.py
new file mode 100644
index 00000000000..ef60504b083
--- /dev/null
+++ b/erpnext/stock/doctype/stock_entry/stock_entry_handler/subcontracting.py
@@ -0,0 +1,264 @@
+import json
+
+import frappe
+from frappe import _, bold
+from frappe.model.document import Document
+from frappe.query_builder.functions import Sum
+from frappe.utils import flt
+
+from erpnext.stock.utils import get_bin
+
+from .base import BaseStockEntry
+
+
+class SendToSubcontractorStockEntry(BaseStockEntry):
+ def validate(self):
+ self.validate_subcontract_order()
+
+ def validate_subcontract_order(self):
+ """Throw exception if more raw material is transferred against Subcontract Order than in
+ the raw materials supplied table"""
+ backflush_raw_materials_based_on = frappe.db.get_single_value(
+ "Buying Settings", "backflush_raw_materials_of_subcontract_based_on"
+ )
+
+ if backflush_raw_materials_based_on == "BOM":
+ subcontract_order = frappe.get_doc(
+ self.doc.subcontract_data.order_doctype, self.doc.get(self.doc.subcontract_data.order_field)
+ )
+ for se_item in self.doc.items:
+ self.validate_subcontracting_order_for_bom(se_item, subcontract_order)
+
+ elif backflush_raw_materials_based_on == "Material Transferred for Subcontract":
+ for row in self.doc.items:
+ self.validate_subcontracting_order_for_transfer(row)
+
+ def validate_subcontracting_order_for_bom(self, child_row, subcontract_order):
+ item_code = child_row.original_item or child_row.item_code
+ required_qty = self._get_required_qty_for_bom(item_code, child_row, subcontract_order)
+ qty_allowance = flt(frappe.db.get_single_value("Buying Settings", "over_transfer_allowance"))
+ total_allowed = required_qty + (required_qty * qty_allowance / 100)
+ self._validate_transfer_qty(child_row, item_code, total_allowed)
+ self._link_rm_detail_if_missing(child_row, item_code)
+
+ def _get_required_qty_for_bom(self, item_code, child_row, subcontract_order):
+ required_qty = sum(
+ flt(d.required_qty) for d in subcontract_order.supplied_items if d.rm_item_code == item_code
+ )
+ if not required_qty and child_row.allow_alternative_item:
+ original_item_code = frappe.get_value(
+ "Item Alternative", {"alternative_item_code": item_code}, "item_code"
+ )
+ required_qty = sum(
+ flt(d.required_qty)
+ for d in subcontract_order.supplied_items
+ if d.rm_item_code == original_item_code
+ )
+ if not required_qty:
+ frappe.throw(
+ _("Item {0} not found in 'Raw Materials Supplied' table in {1} {2}").format(
+ item_code,
+ self.doc.subcontract_data.order_doctype,
+ self.doc.get(self.doc.subcontract_data.order_field),
+ )
+ )
+ return required_qty
+
+ def _validate_transfer_qty(self, child_row, item_code, total_allowed):
+ total_supplied = self.get_total_supplied_qty(child_row)
+ total_returned = (
+ self.get_total_returned_qty(child_row)
+ if self.doc.subcontract_data.order_doctype == "Subcontracting Order"
+ else 0
+ )
+ if flt(
+ total_supplied + child_row.transfer_qty - total_returned, child_row.precision("transfer_qty")
+ ) > flt(total_allowed, child_row.precision("transfer_qty")):
+ frappe.throw(
+ _("Row #{0}: Item {1} cannot be transferred more than {2} against {3} {4}").format(
+ child_row.idx,
+ item_code,
+ total_allowed,
+ self.doc.subcontract_data.order_doctype,
+ self.doc.get(self.doc.subcontract_data.order_field),
+ )
+ )
+
+ def _link_rm_detail_if_missing(self, child_row, item_code):
+ if not child_row.get(self.doc.subcontract_data.rm_detail_field):
+ order_rm_detail = self.get_order_rm_detail(child_row)
+ if order_rm_detail:
+ child_row.db_set(self.doc.subcontract_data.rm_detail_field, order_rm_detail)
+ elif not child_row.allow_alternative_item:
+ frappe.throw(
+ _("Row {0}# Item {1} not found in 'Raw Materials Supplied' table in {2} {3}").format(
+ child_row.idx,
+ item_code,
+ self.doc.subcontract_data.order_doctype,
+ self.doc.get(self.doc.subcontract_data.order_field),
+ )
+ )
+
+ def validate_subcontracting_order_for_transfer(self, child_row):
+ if not child_row.subcontracted_item:
+ frappe.throw(
+ _("Row {0}: Subcontracted Item is mandatory for the raw material {1}").format(
+ child_row.idx, bold(child_row.item_code)
+ )
+ )
+ elif not child_row.get(self.doc.subcontract_data.rm_detail_field):
+ order_rm_detail = self.get_order_rm_detail(child_row)
+ if order_rm_detail:
+ child_row.db_set(self.doc.subcontract_data.rm_detail_field, order_rm_detail)
+
+ def get_total_supplied_qty(self, child_row):
+ se = frappe.qb.DocType("Stock Entry")
+ sed = frappe.qb.DocType("Stock Entry Detail")
+ order_filter = self._get_supplied_qty_order_filter(se, sed, child_row)
+ return (
+ frappe.qb.from_(se)
+ .inner_join(sed)
+ .on(se.name == sed.parent)
+ .select(Sum(sed.transfer_qty))
+ .where(
+ (se.purpose == "Send to Subcontractor")
+ & (se.docstatus == 1)
+ & (sed.item_code == child_row.item_code)
+ & order_filter
+ )
+ ).run()[0][0] or 0
+
+ def _get_supplied_qty_order_filter(self, se, sed, child_row):
+ if self.doc.subcontract_data.order_doctype == "Purchase Order":
+ return (se.purchase_order == self.doc.purchase_order) & (sed.po_detail == self.doc.po_detail)
+ return (se.subcontracting_order == self.doc.subcontracting_order) & (
+ sed.sco_rm_detail == child_row.sco_rm_detail
+ )
+
+ def get_total_returned_qty(self, child_row):
+ se = frappe.qb.DocType("Stock Entry")
+ sed = frappe.qb.DocType("Stock Entry Detail")
+ return (
+ frappe.qb.from_(se)
+ .inner_join(sed)
+ .on(se.name == sed.parent)
+ .select(Sum(sed.transfer_qty))
+ .where(
+ (se.purpose == "Material Transfer")
+ & (se.docstatus == 1)
+ & (se.is_return == 1)
+ & (sed.item_code == child_row.item_code)
+ & (sed.sco_rm_detail == child_row.sco_rm_detail)
+ & (se.subcontracting_order == self.doc.subcontracting_order)
+ )
+ ).run()[0][0] or 0
+
+ def get_order_rm_detail(self, child_row):
+ filters = {
+ "parent": self.doc.get(self.doc.subcontract_data.order_field),
+ "docstatus": 1,
+ "rm_item_code": child_row.item_code,
+ "main_item_code": child_row.subcontracted_item,
+ }
+
+ return frappe.db.get_value(self.doc.subcontract_data.order_supplied_items_field, filters, "name")
+
+ def on_submit(self):
+ self.update_subcontract_order_supplied_items()
+
+ def on_cancel(self):
+ self.update_subcontract_order_supplied_items()
+
+ def update_subcontract_order_supplied_items(self):
+ if not self.doc.get(self.doc.subcontract_data.order_field):
+ return
+ order_supplied_items = self._get_order_supplied_items()
+ supplied_items = self._get_supplied_items_details()
+ self._update_supplied_items_in_order(order_supplied_items, supplied_items)
+ self._update_reserved_qty_for_subcontracting(order_supplied_items)
+
+ def _get_order_supplied_items(self):
+ return frappe.db.get_all(
+ self.doc.subcontract_data.order_supplied_items_field,
+ filters={"parent": self.doc.get(self.doc.subcontract_data.order_field)},
+ fields=["name", "rm_item_code", "reserve_warehouse"],
+ )
+
+ def _get_supplied_items_details(self):
+ return get_supplied_items(
+ self.doc.get(self.doc.subcontract_data.order_field),
+ self.doc.subcontract_data.rm_detail_field,
+ self.doc.subcontract_data.order_field,
+ )
+
+ def _update_supplied_items_in_order(self, order_supplied_items, supplied_items):
+ for row in order_supplied_items:
+ item = supplied_items.get(row.name) or {
+ "supplied_qty": 0,
+ "returned_qty": 0,
+ "total_supplied_qty": 0,
+ }
+ frappe.db.set_value(self.doc.subcontract_data.order_supplied_items_field, row.name, item)
+
+ def _update_reserved_qty_for_subcontracting(self, order_supplied_items):
+ item_wh = {x.get("rm_item_code"): x.get("reserve_warehouse") for x in order_supplied_items}
+ for d in self.doc.get("items"):
+ item_code = d.get("original_item") or d.get("item_code")
+ reserve_warehouse = item_wh.get(item_code)
+ if not (reserve_warehouse and item_code):
+ continue
+ stock_bin = get_bin(item_code, reserve_warehouse)
+ stock_bin.update_reserved_qty_for_sub_contracting()
+
+
+def get_supplied_items(
+ subcontract_order, rm_detail_field="sco_rm_detail", subcontract_order_field="subcontracting_order"
+):
+ fields = [
+ "`tabStock Entry Detail`.`transfer_qty`",
+ "`tabStock Entry`.`is_return`",
+ f"`tabStock Entry Detail`.`{rm_detail_field}`",
+ "`tabStock Entry Detail`.`item_code`",
+ ]
+
+ filters = [
+ ["Stock Entry", "docstatus", "=", 1],
+ ["Stock Entry", subcontract_order_field, "=", subcontract_order],
+ ]
+
+ supplied_item_details = {}
+ for row in frappe.get_all("Stock Entry", fields=fields, filters=filters):
+ if not row.get(rm_detail_field):
+ continue
+
+ key = row.get(rm_detail_field)
+ if key not in supplied_item_details:
+ supplied_item_details.setdefault(
+ key, frappe._dict({"supplied_qty": 0, "returned_qty": 0, "total_supplied_qty": 0})
+ )
+
+ supplied_item = supplied_item_details[key]
+
+ if row.is_return:
+ supplied_item.returned_qty += row.transfer_qty
+ else:
+ supplied_item.supplied_qty += row.transfer_qty
+
+ supplied_item.total_supplied_qty = flt(supplied_item.supplied_qty) - flt(supplied_item.returned_qty)
+
+ return supplied_item_details
+
+
+@frappe.whitelist()
+def get_items_from_subcontract_order(source_name: str, target_doc: str | Document | None = None):
+ from erpnext.controllers.subcontracting_controller import make_rm_stock_entry
+
+ if isinstance(target_doc, str):
+ target_doc = frappe.get_doc(json.loads(target_doc))
+
+ order_doctype = "Purchase Order" if target_doc.purchase_order else "Subcontracting Order"
+ target_doc = make_rm_stock_entry(
+ subcontract_order=source_name, order_doctype=order_doctype, target_doc=target_doc
+ )
+
+ return target_doc
diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
index e1b541529e2..bb40f47765a 100644
--- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
@@ -909,6 +909,7 @@ class TestStockEntry(ERPNextTestSuite):
rm_cost += d.amount
fg_cost = next(filter(lambda x: x.item_code == "_Test FG Item", s.get("items"))).amount
secondary_item_cost = next(filter(lambda x: x.type or x.is_legacy_scrap_item, s.get("items"))).amount
+
self.assertEqual(fg_cost, flt(rm_cost - secondary_item_cost, 2))
# When Stock Entry has only FG + Scrap
@@ -1783,7 +1784,7 @@ class TestStockEntry(ERPNextTestSuite):
def test_use_serial_and_batch_fields(self):
item = make_item(
- "Test Use Serial and Batch Item SN Item",
+ "Test Use Serial and Batch Item SN Item - A",
{"has_serial_no": 1, "is_stock_item": 1},
)
@@ -2266,7 +2267,7 @@ class TestStockEntry(ERPNextTestSuite):
make_stock_entry(item_code=rm_item2, target=warehouse, qty=5, purpose="Material Receipt")
bom_no = make_bom(item=fg_item, raw_materials=[rm_item1, rm_item2]).name
- se = make_stock_entry(item_code=fg_item, qty=1, purpose="Manufacture", do_not_save=True)
+ se = make_stock_entry(item_code=fg_item, qty=1, purpose="Repack", do_not_save=True)
se.from_bom = 1
se.use_multi_level_bom = 1
se.bom_no = bom_no
@@ -2304,7 +2305,6 @@ class TestStockEntry(ERPNextTestSuite):
se.to_warehouse = warehouse
se.get_items()
-
# Verify FG as source (being consumed)
fg_items = [d for d in se.items if d.is_finished_item]
self.assertEqual(len(fg_items), 1)
@@ -2331,7 +2331,9 @@ class TestStockEntry(ERPNextTestSuite):
"Stock Settings", {"sample_retention_warehouse": "_Test Warehouse 1 - _TC"}
)
def test_sample_retention_stock_entry(self):
- from erpnext.stock.doctype.stock_entry.stock_entry import move_sample_to_retention_warehouse
+ from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import (
+ move_sample_to_retention_warehouse,
+ )
warehouse = "_Test Warehouse - _TC"
retain_sample_item = make_item(
@@ -2493,6 +2495,390 @@ class TestStockEntry(ERPNextTestSuite):
self.assertEqual(se.items[2].amount, 5)
+class TestStockEntryCoverage(ERPNextTestSuite):
+ """Tests for functions previously lacking dedicated coverage."""
+
+ # ── ceil_qty_if_uom_has_whole_number ──────────────────────────────────────
+
+ def test_ceil_qty_rounds_up_for_whole_number_uom(self):
+ from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import (
+ ceil_qty_if_uom_has_whole_number,
+ )
+
+ frappe.set_value("UOM", "Nos", "must_be_whole_number", 1)
+ self.assertEqual(ceil_qty_if_uom_has_whole_number(2.3, "Nos"), 3)
+ frappe.set_value("UOM", "Nos", "must_be_whole_number", 0)
+
+ def test_ceil_qty_no_rounding_for_decimal_uom(self):
+ from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import (
+ ceil_qty_if_uom_has_whole_number,
+ )
+
+ frappe.set_value("UOM", "Nos", "must_be_whole_number", 0)
+ self.assertEqual(ceil_qty_if_uom_has_whole_number(2.3, "Nos"), 2.3)
+
+ # ── get_uom_details ────────────────────────────────────────────────────────
+
+ def test_get_uom_details_returns_conversion_factor_and_transfer_qty(self):
+ from erpnext.stock.doctype.stock_entry.stock_entry import get_uom_details
+
+ result = get_uom_details("_Test Item", "Nos", 5)
+ self.assertEqual(flt(result.get("conversion_factor")), 1.0)
+ self.assertEqual(flt(result.get("transfer_qty")), 5.0)
+
+ # ── get_warehouse_details ──────────────────────────────────────────────────
+
+ def test_get_warehouse_details_returns_actual_qty_and_rate(self):
+ import json
+
+ from frappe.utils import nowdate, nowtime
+
+ from erpnext.stock.doctype.stock_entry.stock_entry import get_warehouse_details
+
+ make_stock_entry(
+ item_code="_Test Item",
+ target="_Test Warehouse - _TC",
+ qty=10,
+ basic_rate=100,
+ )
+
+ args = {
+ "item_code": "_Test Item",
+ "warehouse": "_Test Warehouse - _TC",
+ "posting_date": nowdate(),
+ "posting_time": nowtime(),
+ }
+ result = get_warehouse_details(json.dumps(args))
+ self.assertGreater(result.get("actual_qty", 0), 0)
+ self.assertGreater(result.get("basic_rate", 0), 0)
+
+ def test_get_warehouse_details_empty_args_returns_empty_dict(self):
+ from erpnext.stock.doctype.stock_entry.stock_entry import get_warehouse_details
+
+ self.assertEqual(get_warehouse_details({}), {})
+
+ # ── get_work_order_details ─────────────────────────────────────────────────
+
+ def test_get_work_order_details_returns_correct_fields(self):
+ from erpnext.stock.doctype.stock_entry.stock_entry import get_work_order_details
+
+ bom_no = frappe.db.get_value("BOM", {"item": "_Test FG Item 2", "is_default": 1, "docstatus": 1})
+ wo = frappe.new_doc("Work Order")
+ wo.update(
+ {
+ "company": "_Test Company",
+ "fg_warehouse": "_Test Warehouse 1 - _TC",
+ "production_item": "_Test FG Item 2",
+ "bom_no": bom_no,
+ "qty": 3.0,
+ "stock_uom": "_Test UOM",
+ "wip_warehouse": "_Test Warehouse - _TC",
+ }
+ )
+ wo.insert()
+ wo.submit()
+
+ result = get_work_order_details(wo.name, "_Test Company")
+ self.assertEqual(result["bom_no"], bom_no)
+ self.assertEqual(result["fg_completed_qty"], 3.0)
+ self.assertEqual(result["from_bom"], 1)
+ self.assertEqual(result["wip_warehouse"], wo.wip_warehouse)
+
+ # ── get_production_item_details ────────────────────────────────────────────
+
+ def test_get_production_item_details_from_bom(self):
+ from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import (
+ get_production_item_details,
+ )
+
+ bom_no = frappe.db.get_value("BOM", {"item": "_Test FG Item 2", "is_default": 1, "docstatus": 1})
+ result = get_production_item_details(bom_no=bom_no)
+ self.assertEqual(result.name, "_Test FG Item 2")
+ self.assertIsNotNone(result.stock_uom)
+
+ def test_get_production_item_details_from_work_order(self):
+ from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import (
+ get_production_item_details,
+ )
+
+ bom_no = frappe.db.get_value("BOM", {"item": "_Test FG Item 2", "is_default": 1, "docstatus": 1})
+ wo = frappe.new_doc("Work Order")
+ wo.update(
+ {
+ "company": "_Test Company",
+ "fg_warehouse": "_Test Warehouse 1 - _TC",
+ "production_item": "_Test FG Item 2",
+ "bom_no": bom_no,
+ "qty": 1.0,
+ "stock_uom": "_Test UOM",
+ "wip_warehouse": "_Test Warehouse - _TC",
+ }
+ )
+ wo.insert()
+ wo.submit()
+
+ result = get_production_item_details(work_order=wo.name)
+ self.assertEqual(result.name, "_Test FG Item 2")
+
+ # ── get_bom_items ──────────────────────────────────────────────────────────
+
+ def test_get_bom_items_returns_raw_materials_with_structure(self):
+ from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import get_bom_items
+
+ bom_no = frappe.db.get_value("BOM", {"item": "_Test FG Item 2", "is_default": 1, "docstatus": 1})
+ items = get_bom_items(bom_no)
+ self.assertGreater(len(items), 0)
+ for item in items:
+ self.assertIn("item_code", item)
+ self.assertIn("qty", item)
+
+ def test_get_bom_items_scales_qty_proportionally(self):
+ from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import get_bom_items
+
+ bom_no = frappe.db.get_value("BOM", {"item": "_Test FG Item 2", "is_default": 1, "docstatus": 1})
+ items_1 = {i["item_code"]: i["qty"] for i in get_bom_items(bom_no, qty=1)}
+ items_2 = {i["item_code"]: i["qty"] for i in get_bom_items(bom_no, qty=2)}
+ for item_code, qty_at_1 in items_1.items():
+ self.assertAlmostEqual(items_2[item_code], qty_at_1 * 2, places=4)
+
+ # ── validate_sample_quantity ───────────────────────────────────────────────
+
+ @ERPNextTestSuite.change_settings(
+ "Stock Settings", {"sample_retention_warehouse": "_Test Warehouse 1 - _TC"}
+ )
+ def test_validate_sample_quantity_raises_when_sample_exceeds_received_qty(self):
+ from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import (
+ validate_sample_quantity,
+ )
+
+ item = make_item(
+ "_Sample Qty Excess Item",
+ {"is_stock_item": 1, "retain_sample": 1, "sample_quantity": 2},
+ )
+ self.assertRaises(frappe.ValidationError, validate_sample_quantity, item.name, 10, 5)
+
+ # ── get_expired_batches ────────────────────────────────────────────────────
+
+ def test_get_expired_batches_includes_expired_batch(self):
+ from erpnext.stock.doctype.batch.test_batch import make_new_batch
+ from erpnext.stock.doctype.stock_entry.stock_entry_handler.serial_batch import (
+ get_expired_batches,
+ )
+
+ item = make_item("_Test Expired Batch Item", {"is_stock_item": 1, "has_batch_no": 1})
+ batch = make_new_batch(
+ batch_id=frappe.generate_hash("", 5),
+ item_code=item.name,
+ expiry_date=add_days(today(), -1),
+ )
+
+ expired = get_expired_batches()
+ self.assertIn(batch.name, expired)
+ self.assertEqual(expired[batch.name].item, item.name)
+
+ def test_get_expired_batches_excludes_future_batch(self):
+ from erpnext.stock.doctype.batch.test_batch import make_new_batch
+ from erpnext.stock.doctype.stock_entry.stock_entry_handler.serial_batch import (
+ get_expired_batches,
+ )
+
+ item = make_item("_Test Future Batch Item", {"is_stock_item": 1, "has_batch_no": 1})
+ future_batch = make_new_batch(
+ batch_id=frappe.generate_hash("", 5),
+ item_code=item.name,
+ expiry_date=add_days(today(), 10),
+ )
+
+ expired = get_expired_batches()
+ self.assertNotIn(future_batch.name, expired)
+
+ # ── validate_source_stock_entry ────────────────────────────────────────────
+
+ def test_validate_source_stock_entry_skips_when_no_source(self):
+ se = frappe.new_doc("Stock Entry")
+ se.source_stock_entry = None
+ se.validate_source_stock_entry() # must not raise
+
+ def test_validate_source_stock_entry_throws_on_work_order_mismatch(self):
+ source_se = make_stock_entry(
+ item_code="_Test Item",
+ target="_Test Warehouse - _TC",
+ qty=1,
+ basic_rate=100,
+ )
+ frappe.db.set_value("Stock Entry", source_se.name, "work_order", "WO-FAKE-SOURCE-001")
+
+ se = frappe.new_doc("Stock Entry")
+ se.source_stock_entry = source_se.name
+ se.work_order = "WO-FAKE-TARGET-999"
+
+ self.assertRaises(frappe.ValidationError, se.validate_source_stock_entry)
+
+ def test_validate_source_stock_entry_passes_with_matching_work_order(self):
+ source_se = make_stock_entry(
+ item_code="_Test Item",
+ target="_Test Warehouse - _TC",
+ qty=1,
+ basic_rate=100,
+ )
+ frappe.db.set_value("Stock Entry", source_se.name, "work_order", "WO-SAME-001")
+
+ se = frappe.new_doc("Stock Entry")
+ se.source_stock_entry = source_se.name
+ se.work_order = "WO-SAME-001"
+ se.validate_source_stock_entry() # must not raise
+
+ # ── validate_job_card_fg_item ──────────────────────────────────────────────
+
+ def test_validate_job_card_fg_item_skips_when_no_job_card(self):
+ se = frappe.new_doc("Stock Entry")
+ se.job_card = None
+ se.validate_job_card_fg_item() # must not raise
+
+ def test_validate_job_card_fg_item_throws_when_fg_item_mismatches(self):
+ wrong_fg = make_item("_JC Wrong FG Item", {"is_stock_item": 1}).name
+
+ jc_name = frappe.db.get_value("Job Card", {"docstatus": 1, "finished_good": ("!=", "")})
+ if not jc_name:
+ return # skip if no suitable job card in test data
+
+ jc = frappe.db.get_value("Job Card", jc_name, ["finished_good"], as_dict=1)
+ if jc.finished_good == wrong_fg:
+ return # skip if the wrong_fg happens to match
+
+ se = frappe.new_doc("Stock Entry")
+ se.job_card = jc_name
+ se.append("items", {"item_code": wrong_fg, "is_finished_item": 1, "qty": 1})
+ self.assertRaises(frappe.ValidationError, se.validate_job_card_fg_item)
+
+ # ── validate_job_card_item ─────────────────────────────────────────────────
+
+ def test_validate_job_card_item_skips_when_no_job_card(self):
+ se = frappe.new_doc("Stock Entry")
+ se.job_card = None
+ se.validate_job_card_item() # must not raise
+
+ def test_validate_job_card_item_skips_for_manufacture_purpose(self):
+ se = frappe.new_doc("Stock Entry")
+ se.job_card = "SOME-JC-001"
+ se.purpose = "Manufacture"
+ se.validate_job_card_item() # must not raise even with a job card set
+
+ @ERPNextTestSuite.change_settings("Manufacturing Settings", {"job_card_excess_transfer": 0})
+ def test_validate_job_card_item_throws_when_job_card_item_ref_missing(self):
+ jc_name = frappe.db.get_value("Job Card", {"docstatus": 1})
+ if not jc_name:
+ return # skip if no job cards in test data
+
+ se = frappe.new_doc("Stock Entry")
+ se.job_card = jc_name
+ se.purpose = "Material Transfer for Manufacture"
+ se.append(
+ "items",
+ {
+ "item_code": "_Test Item",
+ "s_warehouse": "_Test Warehouse - _TC",
+ "qty": 1,
+ "job_card_item": None,
+ },
+ )
+ self.assertRaises(frappe.ValidationError, se.validate_job_card_item)
+
+ # ── get_available_materials ────────────────────────────────────────────────
+
+ def test_get_available_materials_tracks_transferred_qty(self):
+ from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
+ from erpnext.manufacturing.doctype.work_order.work_order import (
+ make_stock_entry as _make_stock_entry,
+ )
+ from erpnext.stock.doctype.stock_entry.stock_entry_handler.disassemble import (
+ get_available_materials,
+ )
+
+ fg = make_item("_AM FG Item", {"is_stock_item": 1}).name
+ rm = make_item("_AM RM Item", {"is_stock_item": 1}).name
+ source_wh = "_Test Warehouse - _TC"
+ wip_wh = "_Test Warehouse 1 - _TC"
+
+ make_stock_entry(item_code=rm, target=source_wh, qty=20, purpose="Material Receipt")
+
+ bom_no = make_bom(item=fg, raw_materials=[rm]).name
+ wo = frappe.new_doc("Work Order")
+ wo.update(
+ {
+ "company": "_Test Company",
+ "fg_warehouse": "_Test Warehouse 1 - _TC",
+ "production_item": fg,
+ "bom_no": bom_no,
+ "qty": 2.0,
+ "stock_uom": "Nos",
+ "wip_warehouse": wip_wh,
+ }
+ )
+ wo.insert()
+ wo.submit()
+
+ transfer_se = frappe.get_doc(_make_stock_entry(wo.name, "Material Transfer for Manufacture", 2))
+ for d in transfer_se.items:
+ d.s_warehouse = source_wh
+ transfer_se.insert()
+ transfer_se.submit()
+
+ materials = get_available_materials(wo.name)
+ self.assertGreater(len(materials), 0)
+
+ key = (rm, wip_wh)
+ self.assertIn(key, materials)
+ self.assertGreater(materials[key].qty, 0)
+
+ def test_get_available_materials_reduces_qty_after_consumption(self):
+ from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
+ from erpnext.manufacturing.doctype.work_order.work_order import (
+ make_stock_entry as _make_stock_entry,
+ )
+ from erpnext.stock.doctype.stock_entry.stock_entry_handler.disassemble import (
+ get_available_materials,
+ )
+
+ fg = make_item("_AM2 FG Item", {"is_stock_item": 1}).name
+ rm = make_item("_AM2 RM Item", {"is_stock_item": 1}).name
+ source_wh = "_Test Warehouse - _TC"
+ wip_wh = "_Test Warehouse 1 - _TC"
+
+ make_stock_entry(item_code=rm, target=source_wh, qty=20, purpose="Material Receipt")
+
+ bom_no = make_bom(item=fg, raw_materials=[rm]).name
+ wo = frappe.new_doc("Work Order")
+ wo.update(
+ {
+ "company": "_Test Company",
+ "fg_warehouse": "_Test Warehouse 1 - _TC",
+ "production_item": fg,
+ "bom_no": bom_no,
+ "qty": 2.0,
+ "stock_uom": "Nos",
+ "wip_warehouse": wip_wh,
+ }
+ )
+ wo.insert()
+ wo.submit()
+
+ transfer_se = frappe.get_doc(_make_stock_entry(wo.name, "Material Transfer for Manufacture", 2))
+ for d in transfer_se.items:
+ d.s_warehouse = source_wh
+ transfer_se.insert()
+ transfer_se.submit()
+
+ manufacture_se = frappe.get_doc(_make_stock_entry(wo.name, "Manufacture", 2))
+ manufacture_se.insert()
+ manufacture_se.submit()
+
+ materials = get_available_materials(wo.name)
+ key = (rm, wip_wh)
+ if key in materials:
+ self.assertEqual(materials[key].qty, 0)
+
+
def make_serialized_item(self, **args):
args = frappe._dict(args)
se = frappe.copy_doc(self.globalTestRecords["Stock Entry"][0])
diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py
index 0c1a21fefce..5b933427ee4 100644
--- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py
+++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py
@@ -1,8 +1,24 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
-
+import frappe
+from frappe import _, bold
from frappe.model.document import Document
+from frappe.utils import (
+ cint,
+ cstr,
+ flt,
+ format_time,
+ formatdate,
+ get_link_to_form,
+ getdate,
+ nowdate,
+)
+
+from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
+ OpeningEntryAccountError,
+)
+from erpnext.stock.stock_ledger import NegativeStockError, get_previous_sle, is_negative_stock_allowed
class StockEntryDetail(Document):
@@ -73,4 +89,143 @@ class StockEntryDetail(Document):
valuation_rate: DF.Currency
# end: auto-generated types
- pass
+ def validate_batch(self):
+ if not self.batch_no:
+ return
+
+ disabled = frappe.db.get_value("Batch", self.batch_no, "disabled")
+ if disabled:
+ frappe.throw(_("Batch {0} of Item {1} is disabled.").format(self.batch_no, self.item_code))
+ return
+
+ expiry_date = frappe.db.get_value("Batch", self.batch_no, "expiry_date")
+ if expiry_date and getdate(self.parent_doc.posting_date) > getdate(expiry_date):
+ frappe.throw(_("Batch {0} of Item {1} has expired.").format(self.batch_no, self.item_code))
+
+ def validate_and_update_item_details(self, item_details, company, purpose):
+ if flt(self.qty) and flt(self.qty) < 0:
+ frappe.throw(
+ _("Row {0}: The item {1}, quantity must be positive number").format(
+ self.idx, bold(self.item_code)
+ )
+ )
+
+ if item_details.get("is_stock_item") != 1:
+ frappe.throw(_("{0} is not a stock Item").format(self.item_code))
+
+ reset_fields = ("stock_uom", "item_name")
+ for field in reset_fields:
+ self.set(field, item_details.get(field))
+
+ update_fields = (
+ "uom",
+ "description",
+ "expense_account",
+ "cost_center",
+ "conversion_factor",
+ "barcode",
+ )
+ for field in update_fields:
+ if not self.get(field):
+ self.set(field, item_details.get(field))
+ if field == "conversion_factor" and self.uom == item_details.get("stock_uom"):
+ self.set(field, item_details.get(field))
+
+ if not self.transfer_qty and self.qty:
+ self.transfer_qty = flt(
+ flt(self.qty) * flt(self.conversion_factor), self.precision("transfer_qty")
+ )
+
+ if purpose == "Subcontracting Delivery":
+ self.expense_account = frappe.get_value("Company", company, "default_expense_account")
+
+ def validate_expense_account(self, is_opening, purpose):
+ if not self.expense_account:
+ frappe.throw(
+ _(
+ "Please enter Difference Account or set default "
+ "Stock Adjustment Account for company {0}"
+ ).format(bold(self.parent_doc.company))
+ )
+
+ acc_details = frappe.get_cached_value(
+ "Account",
+ self.expense_account,
+ ["account_type", "report_type"],
+ as_dict=True,
+ )
+
+ if is_opening == "Yes" and acc_details.report_type == "Profit and Loss":
+ frappe.throw(
+ _(
+ "Difference Account must be a Asset/Liability type account "
+ "(Temporary Opening), since this Stock Entry is an Opening Entry"
+ ),
+ OpeningEntryAccountError,
+ )
+
+ if acc_details.account_type == "Stock":
+ frappe.throw(
+ _("At row #{0}: the Difference Account must not be a Stock type account...").format(
+ self.idx, get_link_to_form("Account", self.expense_account)
+ ),
+ title=_("Difference Account in Items Table"),
+ )
+
+ if (
+ purpose not in ["Material Issue", "Subcontracting Delivery"]
+ and acc_details.account_type == "Cost of Goods Sold"
+ ):
+ frappe.msgprint(
+ _("At row #{0}: you have selected the Difference Account {1}...").format(
+ self.idx, bold(get_link_to_form("Account", self.expense_account))
+ ),
+ indicator="orange",
+ alert=1,
+ )
+
+ def set_transfer_qty(self):
+ if not flt(self.conversion_factor):
+ frappe.throw(_("Row {0}: UOM Conversion Factor is mandatory").format(self.idx))
+
+ self.transfer_qty = flt(flt(self.qty) * flt(self.conversion_factor), self.precision("transfer_qty"))
+
+ if not flt(self.transfer_qty):
+ frappe.throw(
+ _("Row {0}: Qty in Stock UOM can not be zero.").format(self.idx), title=_("Zero quantity")
+ )
+
+ def set_actual_qty(self, posting_date, posting_time):
+ previous_sle = get_previous_sle(
+ {
+ "item_code": self.item_code,
+ "warehouse": self.s_warehouse or self.t_warehouse,
+ "posting_date": posting_date,
+ "posting_time": posting_time,
+ }
+ )
+
+ # get actual stock at source warehouse
+ self.actual_qty = previous_sle.get("qty_after_transaction") or 0
+
+ def delink_asset_repair_sabb(self, asset_repair):
+ if not self.serial_and_batch_bundle:
+ return
+
+ voucher_detail_no = frappe.db.get_value(
+ "Asset Repair Consumed Item",
+ {"parent": asset_repair, "serial_and_batch_bundle": self.serial_and_batch_bundle},
+ "name",
+ )
+
+ if not voucher_detail_no:
+ return
+
+ doc = frappe.get_doc("Serial and Batch Bundle", self.serial_and_batch_bundle)
+ doc.db_set(
+ {
+ "voucher_type": "Asset Repair",
+ "voucher_no": asset_repair,
+ "voucher_detail_no": voucher_detail_no,
+ }
+ )
diff --git a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py
index ae2ca3a67d6..c7e4fc0f500 100644
--- a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py
+++ b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py
@@ -123,8 +123,9 @@ class ManufactureEntry:
available_serial_batches = self.get_transferred_serial_batches()
for item_code, _dict in item_dict.items():
- _dict.from_warehouse = self.source_wh.get(item_code) or self.wip_warehouse
- _dict.to_warehouse = ""
+ _dict.s_warehouse = self.source_wh.get(item_code) or self.wip_warehouse
+ _dict.t_warehouse = ""
+ _dict.item_code = item_code
if backflush_based_on != "BOM" and not frappe.db.get_value(
"Job Card", self.job_card, "skip_material_transfer"
@@ -138,7 +139,7 @@ class ManufactureEntry:
_dict.qty = calculated_qty
self.update_available_serial_batches(_dict, available_serial_batches)
- self.stock_entry.add_to_stock_entry_detail(item_dict)
+ self.stock_entry.append("items", _dict)
def parse_available_serial_batches(self, item_dict, available_serial_batches):
key = (item_dict.item_code, item_dict.from_warehouse)
@@ -309,8 +310,8 @@ class ManufactureEntry:
item = get_item_defaults(self.production_item, self.company)
args = {
- "to_warehouse": self.fg_warehouse,
- "from_warehouse": "",
+ "t_warehouse": self.fg_warehouse,
+ "s_warehouse": "",
"qty": self.for_quantity - self.process_loss_qty,
"item_name": item.item_name,
"description": item.description,
@@ -318,6 +319,7 @@ class ManufactureEntry:
"expense_account": item.get("expense_account"),
"cost_center": item.get("buying_cost_center"),
"is_finished_item": 1,
+ "item_code": self.production_item,
}
- self.stock_entry.add_to_stock_entry_detail({self.production_item: args}, bom_no=self.bom_no)
+ self.stock_entry.append("items", args)
diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py
index b477fee2228..8fb7a375dcf 100644
--- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py
+++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py
@@ -1898,3 +1898,32 @@ def update_serial_batch_delivered_qty(row, name, is_cancelled=False):
)
query.run()
+
+
+def get_reserved_materials(voucher_no):
+ doctype = frappe.qb.DocType("Stock Reservation Entry")
+ serial_batch_doc = frappe.qb.DocType("Serial and Batch Entry")
+
+ query = (
+ frappe.qb.from_(doctype)
+ .inner_join(serial_batch_doc)
+ .on(doctype.name == serial_batch_doc.parent)
+ .select(
+ serial_batch_doc.serial_no,
+ serial_batch_doc.batch_no,
+ serial_batch_doc.qty,
+ doctype.item_code,
+ doctype.warehouse,
+ doctype.name,
+ doctype.transferred_qty,
+ doctype.consumed_qty,
+ )
+ .where(
+ (doctype.docstatus == 1)
+ & (doctype.voucher_no == voucher_no)
+ & (serial_batch_doc.delivered_qty < serial_batch_doc.qty)
+ )
+ .orderby(serial_batch_doc.idx)
+ )
+
+ return query.run(as_dict=True)
diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py
index bda5f68c1e6..d1cd27f4a11 100644
--- a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py
+++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py
@@ -372,13 +372,14 @@ class SubcontractingInwardOrder(SubcontractingController):
items_dict = {
rm_item.get("rm_item_code"): {
"scio_detail": rm_item.get("name"),
+ "item_code": rm_item.get("rm_item_code"),
"qty": calculate_qty_as_per_bom(rm_item),
- "to_warehouse": rm_item.get("warehouse"),
+ "t_warehouse": rm_item.get("warehouse"),
"stock_uom": rm_item.get("stock_uom"),
}
}
- stock_entry.add_to_stock_entry_detail(items_dict)
+ stock_entry.append("items", items_dict[rm_item.get("rm_item_code")])
if target_doc:
return stock_entry
@@ -413,13 +414,16 @@ class SubcontractingInwardOrder(SubcontractingController):
items_dict = {
rm_item.get("rm_item_code"): {
"scio_detail": rm_item.get("name"),
+ "item_code": rm_item.get("rm_item_code"),
"qty": rm_item.received_qty - rm_item.work_order_qty - rm_item.returned_qty,
- "from_warehouse": rm_item.get("warehouse"),
+ "s_warehouse": rm_item.get("warehouse"),
"stock_uom": rm_item.get("stock_uom"),
}
}
- stock_entry.add_to_stock_entry_detail(items_dict)
+ ste_item = items_dict[rm_item.get("rm_item_code")]
+ if ste_item.get("qty"):
+ stock_entry.append("items", ste_item)
if target_doc:
return stock_entry
@@ -465,14 +469,15 @@ class SubcontractingInwardOrder(SubcontractingController):
items_dict = {
fg_item.item_code: {
"qty": qty,
- "from_warehouse": fg_item.delivery_warehouse,
+ "item_code": fg_item.item_code,
+ "s_warehouse": fg_item.delivery_warehouse,
"stock_uom": fg_item.stock_uom,
"scio_detail": fg_item.name,
"is_finished_item": 1,
}
}
- stock_entry.add_to_stock_entry_detail(items_dict)
+ stock_entry.append("items", items_dict[fg_item.item_code])
if (
frappe.get_single_value("Selling Settings", "deliver_secondary_items")
@@ -490,14 +495,15 @@ class SubcontractingInwardOrder(SubcontractingController):
items_dict = {
secondary_item.item_code: {
"qty": secondary_item.produced_qty - secondary_item.delivered_qty,
- "from_warehouse": secondary_item.warehouse,
+ "item_code": secondary_item.item_code,
+ "s_warehouse": secondary_item.warehouse,
"stock_uom": secondary_item.stock_uom,
"scio_detail": secondary_item.name,
"type": secondary_item.type,
}
}
- stock_entry.add_to_stock_entry_detail(items_dict)
+ stock_entry.append("items", items_dict[secondary_item.item_code])
if target_doc:
return stock_entry
@@ -536,13 +542,14 @@ class SubcontractingInwardOrder(SubcontractingController):
items_dict = {
fg_item.item_code: {
"qty": qty,
+ "item_code": fg_item.item_code,
"stock_uom": fg_item.stock_uom,
"scio_detail": fg_item.name,
"is_finished_item": 1,
}
}
- stock_entry.add_to_stock_entry_detail(items_dict)
+ stock_entry.append("items", items_dict[fg_item.item_code])
if target_doc:
return stock_entry
diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py b/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py
index d035f4ddcb9..9a45a49be5e 100644
--- a/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py
+++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py
@@ -240,6 +240,7 @@ class IntegrationTestSubcontractingInwardOrder(ERPNextTestSuite):
delivery = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery())
delivery.items[0].use_serial_batch_fields = 1
delivery.save()
+ delivery.submit()
delivery_serial_list, _ = get_serial_batch_list_from_item(delivery.items[0])
self.assertEqual(sorted(serial_list), sorted(delivery_serial_list))