mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-04 18:23:05 +00:00
Compare commits
2 Commits
version-16
...
mergify/bp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4c1d92850 | ||
|
|
0d553860d5 |
@@ -1143,6 +1143,52 @@ class TestMaterialRequest(ERPNextTestSuite):
|
||||
se.save()
|
||||
se.submit()
|
||||
|
||||
def test_mr_status_for_mixed_direct_and_transit_transfer(self):
|
||||
material_request = make_material_request(
|
||||
material_request_type="Material Transfer",
|
||||
item_code="_Test Item Home Desktop 100",
|
||||
qty=5,
|
||||
)
|
||||
|
||||
in_transit_wh = get_in_transit_warehouse(material_request.company)
|
||||
|
||||
# Make stock available
|
||||
self._insert_stock_entry(20.0, 20.0)
|
||||
|
||||
# Direct Transfer for 3 Qty
|
||||
direct_transfer = make_stock_entry(material_request.name)
|
||||
direct_transfer.items[0].update(
|
||||
{
|
||||
"qty": 3,
|
||||
"transfer_qty": 3,
|
||||
"s_warehouse": "_Test Warehouse 1 - _TC",
|
||||
}
|
||||
)
|
||||
direct_transfer.save()
|
||||
direct_transfer.submit()
|
||||
|
||||
# In Transit Transfer for remaining 2 Qty
|
||||
transit_transfer = make_in_transit_stock_entry(material_request.name, in_transit_wh)
|
||||
transit_transfer.items[0].update(
|
||||
{
|
||||
"qty": 2,
|
||||
"s_warehouse": "_Test Warehouse 1 - _TC",
|
||||
}
|
||||
)
|
||||
transit_transfer.save()
|
||||
transit_transfer.submit()
|
||||
|
||||
# Complete End Transit
|
||||
end_transit = make_stock_in_entry(transit_transfer.name)
|
||||
end_transit.save()
|
||||
end_transit.submit()
|
||||
|
||||
material_request.reload()
|
||||
|
||||
self.assertEqual(material_request.per_ordered, 100)
|
||||
self.assertEqual(material_request.status, "Transferred")
|
||||
self.assertEqual(material_request.transfer_status, "Completed")
|
||||
|
||||
|
||||
def get_in_transit_warehouse(company):
|
||||
if not frappe.db.exists("Warehouse Type", "Transit"):
|
||||
|
||||
443
erpnext/stock/doctype/stock_entry/services/material_transfer.py
Normal file
443
erpnext/stock/doctype/stock_entry/services/material_transfer.py
Normal file
@@ -0,0 +1,443 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import cstr, flt
|
||||
|
||||
from .manufacturing import _check_bom_component_qty, get_bom_items
|
||||
from .stock_entry_base import BaseStockEntry
|
||||
|
||||
|
||||
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"),
|
||||
)
|
||||
|
||||
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": "transfer_qty",
|
||||
"target_dt": "Stock Entry Detail",
|
||||
"join_field": "ste_detail",
|
||||
"target_parent_dt": "Stock Entry",
|
||||
"target_parent_field": "per_transferred",
|
||||
"source_field": "transfer_qty",
|
||||
"percent_join_field": "against_stock_entry",
|
||||
}
|
||||
|
||||
|
||||
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_transferred_qty()
|
||||
self.update_subcontract_order_supplied_items()
|
||||
|
||||
def on_cancel(self):
|
||||
self.update_transferred_qty()
|
||||
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 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):
|
||||
from pypika import Case
|
||||
|
||||
se = frappe.qb.DocType("Stock Entry")
|
||||
sed = frappe.qb.DocType("Stock Entry Detail")
|
||||
completed_qty = Case().when(se.add_to_transit == 1, sed.transferred_qty).else_(sed.transfer_qty)
|
||||
return (
|
||||
frappe.qb.from_(sed)
|
||||
.inner_join(se)
|
||||
.on(se.name == sed.parent)
|
||||
.select(Sum(sed.transfer_qty).as_("transfer_qty"), Sum(completed_qty).as_("transferred_qty"))
|
||||
.where((sed.material_request == material_request) & (sed.docstatus == 1))
|
||||
).run(as_dict=True)[0]
|
||||
Reference in New Issue
Block a user