Merge branch 'develop' into perf-bom-update-tool

This commit is contained in:
Marica
2022-06-07 14:49:59 +05:30
committed by GitHub
144 changed files with 4176 additions and 2855 deletions

View File

@@ -93,6 +93,11 @@ frappe.ui.form.on("BOM", {
});
}
frm.add_custom_button(__("New Version"), function() {
let new_bom = frappe.model.copy_doc(frm.doc);
frappe.set_route("Form", "BOM", new_bom.name);
});
if(frm.doc.docstatus==1) {
frm.add_custom_button(__("Work Order"), function() {
frm.trigger("make_work_order");

View File

@@ -22,6 +22,10 @@ from erpnext.stock.get_item_details import get_conversion_factor, get_price_list
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
class BOMRecursionError(frappe.ValidationError):
pass
class BOMTree:
"""Full tree representation of a BOM"""
@@ -252,9 +256,8 @@ class BOM(WebsiteGenerator):
for item in self.get("items"):
self.validate_bom_currency(item)
item.bom_no = ""
if not item.do_not_explode:
item.bom_no = item.bom_no
if item.do_not_explode:
item.bom_no = ""
ret = self.get_bom_material_detail(
{
@@ -530,35 +533,27 @@ class BOM(WebsiteGenerator):
"""Check whether recursion occurs in any bom"""
def _throw_error(bom_name):
frappe.throw(_("BOM recursion: {0} cannot be parent or child of {0}").format(bom_name))
frappe.throw(
_("BOM recursion: {1} cannot be parent or child of {0}").format(self.name, bom_name),
exc=BOMRecursionError,
)
bom_list = self.traverse_tree()
child_items = (
frappe.get_all(
"BOM Item",
fields=["bom_no", "item_code"],
filters={"parent": ("in", bom_list), "parenttype": "BOM"},
)
or []
child_items = frappe.get_all(
"BOM Item",
fields=["bom_no", "item_code"],
filters={"parent": ("in", bom_list), "parenttype": "BOM"},
)
child_bom = {d.bom_no for d in child_items}
child_items_codes = {d.item_code for d in child_items}
for item in child_items:
if self.name == item.bom_no:
_throw_error(self.name)
if self.item == item.item_code and item.bom_no:
# Same item but with different BOM should not be allowed.
# Same item can appear recursively once as long as it doesn't have BOM.
_throw_error(item.bom_no)
if self.name in child_bom:
_throw_error(self.name)
if self.item in child_items_codes:
_throw_error(self.item)
bom_nos = (
frappe.get_all(
"BOM Item", fields=["parent"], filters={"bom_no": self.name, "parenttype": "BOM"}
)
or []
)
if self.name in {d.parent for d in bom_nos}:
if self.name in {d.bom_no for d in self.items}:
_throw_error(self.name)
def traverse_tree(self, bom_list=None):

View File

@@ -10,7 +10,7 @@ from frappe.tests.utils import FrappeTestCase
from frappe.utils import cstr, flt
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.manufacturing.doctype.bom.bom import item_query, make_variant_bom
from erpnext.manufacturing.doctype.bom.bom import BOMRecursionError, item_query, make_variant_bom
from erpnext.manufacturing.doctype.bom_update_log.test_bom_update_log import (
update_cost_in_all_boms_in_test,
)
@@ -326,43 +326,36 @@ class TestBOM(FrappeTestCase):
def test_bom_recursion_1st_level(self):
"""BOM should not allow BOM item again in child"""
item_code = "_Test BOM Recursion"
make_item(item_code, {"is_stock_item": 1})
item_code = make_item(properties={"is_stock_item": 1}).name
bom = frappe.new_doc("BOM")
bom.item = item_code
bom.append("items", frappe._dict(item_code=item_code))
with self.assertRaises(frappe.ValidationError) as err:
bom.save()
with self.assertRaises(BOMRecursionError):
bom.items[0].bom_no = bom.name
bom.save()
self.assertTrue("recursion" in str(err.exception).lower())
frappe.delete_doc("BOM", bom.name, ignore_missing=True)
def test_bom_recursion_transitive(self):
item1 = "_Test BOM Recursion"
item2 = "_Test BOM Recursion 2"
make_item(item1, {"is_stock_item": 1})
make_item(item2, {"is_stock_item": 1})
item1 = make_item(properties={"is_stock_item": 1}).name
item2 = make_item(properties={"is_stock_item": 1}).name
bom1 = frappe.new_doc("BOM")
bom1.item = item1
bom1.append("items", frappe._dict(item_code=item2))
bom1.save()
bom1.submit()
bom2 = frappe.new_doc("BOM")
bom2.item = item2
bom2.append("items", frappe._dict(item_code=item1))
bom2.save()
with self.assertRaises(frappe.ValidationError) as err:
bom2.items[0].bom_no = bom1.name
bom1.items[0].bom_no = bom2.name
with self.assertRaises(BOMRecursionError):
bom1.save()
bom2.save()
bom2.submit()
self.assertTrue("recursion" in str(err.exception).lower())
bom1.cancel()
frappe.delete_doc("BOM", bom1.name, ignore_missing=True, force=True)
frappe.delete_doc("BOM", bom2.name, ignore_missing=True, force=True)
def test_bom_with_process_loss_item(self):
fg_item_non_whole, fg_item_whole, bom_item = create_process_loss_bom_items()

View File

@@ -42,6 +42,10 @@ class JobCardCancelError(frappe.ValidationError):
pass
class JobCardOverTransferError(frappe.ValidationError):
pass
class JobCard(Document):
def onload(self):
excess_transfer = frappe.db.get_single_value(
@@ -522,23 +526,50 @@ class JobCard(Document):
},
)
def set_transferred_qty_in_job_card(self, ste_doc):
def set_transferred_qty_in_job_card_item(self, ste_doc):
from frappe.query_builder.functions import Sum
def _validate_over_transfer(row, transferred_qty):
"Block over transfer of items if not allowed in settings."
required_qty = frappe.db.get_value("Job Card Item", row.job_card_item, "required_qty")
is_excess = flt(transferred_qty) > flt(required_qty)
if is_excess:
frappe.throw(
_(
"Row #{0}: Cannot transfer more than Required Qty {1} for Item {2} against Job Card {3}"
).format(
row.idx, frappe.bold(required_qty), frappe.bold(row.item_code), ste_doc.job_card
),
title=_("Excess Transfer"),
exc=JobCardOverTransferError,
)
for row in ste_doc.items:
if not row.job_card_item:
continue
qty = frappe.db.sql(
""" SELECT SUM(qty) from `tabStock Entry Detail` sed, `tabStock Entry` se
WHERE sed.job_card_item = %s and se.docstatus = 1 and sed.parent = se.name and
se.purpose = 'Material Transfer for Manufacture'
""",
(row.job_card_item),
)[0][0]
sed = frappe.qb.DocType("Stock Entry Detail")
se = frappe.qb.DocType("Stock Entry")
transferred_qty = (
frappe.qb.from_(sed)
.join(se)
.on(sed.parent == se.name)
.select(Sum(sed.qty))
.where(
(sed.job_card_item == row.job_card_item)
& (se.docstatus == 1)
& (se.purpose == "Material Transfer for Manufacture")
)
).run()[0][0]
frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", flt(qty))
allow_excess = frappe.db.get_single_value("Manufacturing Settings", "job_card_excess_transfer")
if not allow_excess:
_validate_over_transfer(row, transferred_qty)
frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", flt(transferred_qty))
def set_transferred_qty(self, update_status=False):
"Set total FG Qty for which RM was transferred."
"Set total FG Qty in Job Card for which RM was transferred."
if not self.items:
self.transferred_qty = self.for_quantity if self.docstatus == 1 else 0
@@ -590,7 +621,7 @@ class JobCard(Document):
self.set_status(update_status)
def set_status(self, update_status=False):
if self.status == "On Hold":
if self.status == "On Hold" and self.docstatus == 0:
return
self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0]
@@ -866,6 +897,7 @@ def make_corrective_job_card(source_name, operation=None, for_operation=None, ta
target.set("time_logs", [])
target.set("employee", [])
target.set("items", [])
target.set("sub_operations", [])
target.set_sub_operations()
target.get_required_items()
target.validate_time_logs()

View File

@@ -1,15 +1,25 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import random_string
from erpnext.manufacturing.doctype.job_card.job_card import OperationMismatchError, OverlapError
from typing import Literal
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import random_string
from frappe.utils.data import add_to_date, now
from erpnext.manufacturing.doctype.job_card.job_card import (
JobCardOverTransferError,
OperationMismatchError,
OverlapError,
make_corrective_job_card,
)
from erpnext.manufacturing.doctype.job_card.job_card import (
make_stock_entry as make_stock_entry_from_jc,
)
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.doctype.work_order.work_order import WorkOrder
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
@@ -17,34 +27,36 @@ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
class TestJobCard(FrappeTestCase):
def setUp(self):
make_bom_for_jc_tests()
self.transfer_material_against: Literal["Work Order", "Job Card"] = "Work Order"
self.source_warehouse = None
self._work_order = None
transfer_material_against, source_warehouse = None, None
@property
def work_order(self) -> WorkOrder:
"""Work Order lazily created for tests."""
if not self._work_order:
self._work_order = make_wo_order_test_record(
item="_Test FG Item 2",
qty=2,
transfer_material_against=self.transfer_material_against,
source_warehouse=self.source_warehouse,
)
return self._work_order
tests_that_skip_setup = ("test_job_card_material_transfer_correctness",)
tests_that_transfer_against_jc = (
"test_job_card_multiple_materials_transfer",
"test_job_card_excess_material_transfer",
"test_job_card_partial_material_transfer",
)
if self._testMethodName in tests_that_skip_setup:
return
if self._testMethodName in tests_that_transfer_against_jc:
transfer_material_against = "Job Card"
source_warehouse = "Stores - _TC"
self.work_order = make_wo_order_test_record(
item="_Test FG Item 2",
qty=2,
transfer_material_against=transfer_material_against,
source_warehouse=source_warehouse,
)
def generate_required_stock(self, work_order: WorkOrder) -> None:
"""Create twice the stock for all required items in work order."""
for item in work_order.required_items:
make_stock_entry(
item_code=item.item_code,
target=item.source_warehouse or self.source_warehouse,
qty=item.required_qty * 2,
basic_rate=100,
)
def tearDown(self):
frappe.db.rollback()
def test_job_card(self):
def test_job_card_operations(self):
job_cards = frappe.get_all(
"Job Card", filters={"work_order": self.work_order.name}, fields=["operation_id", "name"]
@@ -58,9 +70,6 @@ class TestJobCard(FrappeTestCase):
doc.operation_id = "Test Data"
self.assertRaises(OperationMismatchError, doc.save)
for d in job_cards:
frappe.delete_doc("Job Card", d.name)
def test_job_card_with_different_work_station(self):
job_cards = frappe.get_all(
"Job Card",
@@ -96,19 +105,11 @@ class TestJobCard(FrappeTestCase):
)
self.assertEqual(completed_qty, job_card.for_quantity)
doc.cancel()
for d in job_cards:
frappe.delete_doc("Job Card", d.name)
def test_job_card_overlap(self):
wo2 = make_wo_order_test_record(item="_Test FG Item 2", qty=2)
jc1_name = frappe.db.get_value("Job Card", {"work_order": self.work_order.name})
jc2_name = frappe.db.get_value("Job Card", {"work_order": wo2.name})
jc1 = frappe.get_doc("Job Card", jc1_name)
jc2 = frappe.get_doc("Job Card", jc2_name)
jc1 = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
jc2 = frappe.get_last_doc("Job Card", {"work_order": wo2.name})
employee = "_T-Employee-00001" # from test records
@@ -137,10 +138,10 @@ class TestJobCard(FrappeTestCase):
def test_job_card_multiple_materials_transfer(self):
"Test transferring RMs separately against Job Card with multiple RMs."
make_stock_entry(item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=100)
make_stock_entry(
item_code="_Test Item Home Desktop Manufactured", target="Stores - _TC", qty=6, basic_rate=100
)
self.transfer_material_against = "Job Card"
self.source_warehouse = "Stores - _TC"
self.generate_required_stock(self.work_order)
job_card_name = frappe.db.get_value("Job Card", {"work_order": self.work_order.name})
job_card = frappe.get_doc("Job Card", job_card_name)
@@ -165,16 +166,58 @@ class TestJobCard(FrappeTestCase):
# transfer was made for 2 fg qty in first transfer Stock Entry
self.assertEqual(transfer_entry_2.fg_completed_qty, 0)
@change_settings("Manufacturing Settings", {"job_card_excess_transfer": 1})
def test_job_card_excess_material_transfer(self):
"Test transferring more than required RM against Job Card."
make_stock_entry(item_code="_Test Item", target="Stores - _TC", qty=25, basic_rate=100)
make_stock_entry(
item_code="_Test Item Home Desktop Manufactured", target="Stores - _TC", qty=15, basic_rate=100
self.transfer_material_against = "Job Card"
self.source_warehouse = "Stores - _TC"
self.generate_required_stock(self.work_order)
job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
self.assertEqual(job_card.status, "Open")
# fully transfer both RMs
transfer_entry_1 = make_stock_entry_from_jc(job_card.name)
transfer_entry_1.insert()
transfer_entry_1.submit()
# transfer extra qty of both RM due to previously damaged RM
transfer_entry_2 = make_stock_entry_from_jc(job_card.name)
# deliberately change 'For Quantity'
transfer_entry_2.fg_completed_qty = 1
transfer_entry_2.items[0].qty = 5
transfer_entry_2.items[1].qty = 3
transfer_entry_2.insert()
transfer_entry_2.submit()
job_card.reload()
self.assertGreater(job_card.transferred_qty, job_card.for_quantity)
# Check if 'For Quantity' is negative
# as 'transferred_qty' > Qty to Manufacture
transfer_entry_3 = make_stock_entry_from_jc(job_card.name)
self.assertEqual(transfer_entry_3.fg_completed_qty, 0)
job_card.append(
"time_logs",
{"from_time": "2021-01-01 00:01:00", "to_time": "2021-01-01 06:00:00", "completed_qty": 2},
)
job_card.save()
job_card.submit()
# JC is Completed with excess transfer
self.assertEqual(job_card.status, "Completed")
@change_settings("Manufacturing Settings", {"job_card_excess_transfer": 0})
def test_job_card_excess_material_transfer_block(self):
self.transfer_material_against = "Job Card"
self.source_warehouse = "Stores - _TC"
self.generate_required_stock(self.work_order)
job_card_name = frappe.db.get_value("Job Card", {"work_order": self.work_order.name})
job_card = frappe.get_doc("Job Card", job_card_name)
self.assertEqual(job_card.status, "Open")
# fully transfer both RMs
transfer_entry_1 = make_stock_entry_from_jc(job_card_name)
@@ -188,39 +231,19 @@ class TestJobCard(FrappeTestCase):
transfer_entry_2.items[0].qty = 5
transfer_entry_2.items[1].qty = 3
transfer_entry_2.insert()
transfer_entry_2.submit()
job_card.reload()
self.assertGreater(job_card.transferred_qty, job_card.for_quantity)
# Check if 'For Quantity' is negative
# as 'transferred_qty' > Qty to Manufacture
transfer_entry_3 = make_stock_entry_from_jc(job_card_name)
self.assertEqual(transfer_entry_3.fg_completed_qty, 0)
job_card.append(
"time_logs",
{"from_time": "2021-01-01 00:01:00", "to_time": "2021-01-01 06:00:00", "completed_qty": 2},
)
job_card.save()
job_card.submit()
# JC is Completed with excess transfer
self.assertEqual(job_card.status, "Completed")
self.assertRaises(JobCardOverTransferError, transfer_entry_2.submit)
def test_job_card_partial_material_transfer(self):
"Test partial material transfer against Job Card"
self.transfer_material_against = "Job Card"
self.source_warehouse = "Stores - _TC"
make_stock_entry(item_code="_Test Item", target="Stores - _TC", qty=25, basic_rate=100)
make_stock_entry(
item_code="_Test Item Home Desktop Manufactured", target="Stores - _TC", qty=15, basic_rate=100
)
self.generate_required_stock(self.work_order)
job_card_name = frappe.db.get_value("Job Card", {"work_order": self.work_order.name})
job_card = frappe.get_doc("Job Card", job_card_name)
job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
# partially transfer
transfer_entry = make_stock_entry_from_jc(job_card_name)
transfer_entry = make_stock_entry_from_jc(job_card.name)
transfer_entry.fg_completed_qty = 1
transfer_entry.get_items()
transfer_entry.insert()
@@ -232,7 +255,7 @@ class TestJobCard(FrappeTestCase):
self.assertEqual(transfer_entry.items[1].qty, 3)
# transfer remaining
transfer_entry_2 = make_stock_entry_from_jc(job_card_name)
transfer_entry_2 = make_stock_entry_from_jc(job_card.name)
self.assertEqual(transfer_entry_2.fg_completed_qty, 1)
self.assertEqual(transfer_entry_2.items[0].qty, 5)
@@ -277,7 +300,49 @@ class TestJobCard(FrappeTestCase):
self.assertEqual(transfer_entry.items[0].item_code, "_Test Item")
self.assertEqual(transfer_entry.items[0].qty, 2)
# rollback via tearDown method
@change_settings(
"Manufacturing Settings", {"add_corrective_operation_cost_in_finished_good_valuation": 1}
)
def test_corrective_costing(self):
job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
job_card.append(
"time_logs",
{"from_time": now(), "to_time": add_to_date(now(), hours=1), "completed_qty": 2},
)
job_card.submit()
self.work_order.reload()
original_cost = self.work_order.total_operating_cost
# Create a corrective operation against it
corrective_action = frappe.get_doc(
doctype="Operation", is_corrective_operation=1, name=frappe.generate_hash()
).insert()
corrective_job_card = make_corrective_job_card(
job_card.name, operation=corrective_action.name, for_operation=job_card.operation
)
corrective_job_card.hour_rate = 100
corrective_job_card.insert()
corrective_job_card.append(
"time_logs",
{
"from_time": add_to_date(now(), hours=2),
"to_time": add_to_date(now(), hours=2, minutes=30),
"completed_qty": 2,
},
)
corrective_job_card.submit()
self.work_order.reload()
cost_after_correction = self.work_order.total_operating_cost
self.assertGreater(cost_after_correction, original_cost)
corrective_job_card.cancel()
self.work_order.reload()
cost_after_cancel = self.work_order.total_operating_cost
self.assertEqual(cost_after_cancel, original_cost)
def create_bom_with_multiple_operations():

View File

@@ -34,8 +34,7 @@ def get_data(filters):
if filters.get(field):
query_filters[field] = ("in", filters.get(field))
query_filters["report_date"] = (">=", filters.get("from_date"))
query_filters["report_date"] = ("<=", filters.get("to_date"))
query_filters["report_date"] = ["between", [filters.get("from_date"), filters.get("to_date")]]
return frappe.get_all(
"Quality Inspection", fields=fields, filters=query_filters, order_by="report_date asc"

View File

@@ -402,14 +402,15 @@
"type": "Link"
}
],
"modified": "2022-01-13 17:40:09.474747",
"modified": "2022-05-31 22:08:19.408223",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Manufacturing",
"owner": "Administrator",
"parent_page": "",
"public": 1,
"restrict_to_domain": "Manufacturing",
"quick_lists": [],
"restrict_to_domain": "",
"roles": [],
"sequence_id": 17.0,
"shortcuts": [