Skip to content

Commit

Permalink
feat(subcontracting): Added provision to create multiple Subcontracti…
Browse files Browse the repository at this point in the history
…ng Orders against a single Purchase Order (#44711)

* feat(subcontracting): Added provision to create multiple Subcontracting Orders from a single Subcontracted Purchase Order

* refactor(new_sc_flow_2): Fixed error thrown by semgrep
  • Loading branch information
mihir-kandoi authored Dec 18, 2024
1 parent 779dd2d commit 3eba6bf
Show file tree
Hide file tree
Showing 14 changed files with 273 additions and 62 deletions.
16 changes: 9 additions & 7 deletions erpnext/buying/doctype/purchase_order/purchase_order.js
Original file line number Diff line number Diff line change
Expand Up @@ -402,13 +402,15 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
);
}
} else {
this.frm.add_custom_button(
__("Subcontracting Order"),
() => {
me.make_subcontracting_order();
},
__("Create")
);
if (!doc.items.every((item) => item.qty == item.sco_qty)) {
this.frm.add_custom_button(
__("Subcontracting Order"),
() => {
me.make_subcontracting_order();
},
__("Create")
);
}
}
}
}
Expand Down
61 changes: 33 additions & 28 deletions erpnext/buying/doctype/purchase_order/purchase_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -871,27 +871,40 @@ def make_inter_company_sales_order(source_name, target_doc=None):

@frappe.whitelist()
def make_subcontracting_order(source_name, target_doc=None, save=False, submit=False, notify=False):
target_doc = get_mapped_subcontracting_order(source_name, target_doc)

if (save or submit) and frappe.has_permission(target_doc.doctype, "create"):
target_doc.save()
if not is_po_fully_subcontracted(source_name):
target_doc = get_mapped_subcontracting_order(source_name, target_doc)

if (save or submit) and frappe.has_permission(target_doc.doctype, "create"):
target_doc.save()

if submit and frappe.has_permission(target_doc.doctype, "submit", target_doc):
try:
target_doc.submit()
except Exception as e:
target_doc.add_comment("Comment", _("Submit Action Failed") + "<br><br>" + str(e))

if notify:
frappe.msgprint(
_("Subcontracting Order {0} created.").format(
get_link_to_form(target_doc.doctype, target_doc.name)
),
indicator="green",
alert=True,
)

if submit and frappe.has_permission(target_doc.doctype, "submit", target_doc):
try:
target_doc.submit()
except Exception as e:
target_doc.add_comment("Comment", _("Submit Action Failed") + "<br><br>" + str(e))
return target_doc
else:
frappe.throw(_("This PO has been fully subcontracted."))

if notify:
frappe.msgprint(
_("Subcontracting Order {0} created.").format(
get_link_to_form(target_doc.doctype, target_doc.name)
),
indicator="green",
alert=True,
)

return target_doc
def is_po_fully_subcontracted(po_name):
table = frappe.qb.DocType("Purchase Order Item")
query = (
frappe.qb.from_(table)
.select(table.name)
.where((table.parent == po_name) & (table.qty != table.sco_qty))
)
return not query.run(as_dict=True)


def get_mapped_subcontracting_order(source_name, target_doc=None):
Expand Down Expand Up @@ -943,20 +956,12 @@ def post_process(source_doc, target_doc):
"material_request": "material_request",
"material_request_item": "material_request_item",
},
"field_no_map": [],
"field_no_map": ["qty", "fg_item_qty", "amount"],
"condition": lambda item: item.qty != item.sco_qty,
},
},
target_doc,
post_process,
)

return target_doc


@frappe.whitelist()
def is_subcontracting_order_created(po_name) -> bool:
return (
True
if frappe.db.exists("Subcontracting Order", {"purchase_order": po_name, "docstatus": ["=", 1]})
else False
)
116 changes: 115 additions & 1 deletion erpnext/buying/doctype/purchase_order/test_purchase_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -1025,7 +1025,7 @@ def test_update_items_for_subcontracting_purchase_order(self):
)

def update_items(po, qty):
trans_items = [po.items[0].as_dict()]
trans_items = [po.items[0].as_dict().update({"docname": po.items[0].name})]
trans_items[0]["qty"] = qty
trans_items[0]["fg_item_qty"] = qty
trans_items = json.dumps(trans_items, default=str)
Expand Down Expand Up @@ -1080,6 +1080,73 @@ def update_items(po, qty):
self.assertEqual(po.items[0].qty, 30)
self.assertEqual(po.items[0].fg_item_qty, 30)

def test_new_sc_flow(self):
from erpnext.buying.doctype.purchase_order.purchase_order import make_subcontracting_order

po = create_po_for_sc_testing()
sco = make_subcontracting_order(po.name)

sco.items[0].qty = 5
sco.items.pop(1)
sco.items[1].qty = 25
sco.save()
sco.submit()

# Test - 1: Quantity of Service Items should change based on change in Quantity of its corresponding Finished Goods Item
self.assertEqual(sco.service_items[0].qty, 5)

# Test - 2: Subcontracted Quantity for the PO Items of each line item should be updated accordingly
po.reload()
self.assertEqual(po.items[0].sco_qty, 5)
self.assertEqual(po.items[1].sco_qty, 0)
self.assertEqual(po.items[2].sco_qty, 12.5)

# Test - 3: Amount for both FG Item and its Service Item should be updated correctly based on change in Quantity
self.assertEqual(sco.items[0].amount, 2000)
self.assertEqual(sco.service_items[0].amount, 500)

# Test - 4: Service Items should be removed if its corresponding Finished Good line item is deleted
self.assertEqual(len(sco.service_items), 2)

# Test - 5: Service Item quantity calculation should be based upon conversion factor calculated from its corresponding PO Item
self.assertEqual(sco.service_items[1].qty, 12.5)

sco = make_subcontracting_order(po.name)

sco.items[0].qty = 6

# Test - 6: Saving document should not be allowed if Quantity exceeds available Subcontracting Quantity of any Purchase Order Item
self.assertRaises(frappe.ValidationError, sco.save)

sco.items[0].qty = 5
sco.items.pop()
sco.items.pop()
sco.save()
sco.submit()

sco = make_subcontracting_order(po.name)

# Test - 7: Since line item 1 is now fully subcontracted, new SCO should by default only have the remaining 2 line items
self.assertEqual(len(sco.items), 2)

sco.items.pop(0)
sco.save()
sco.submit()

# Test - 8: Subcontracted Quantity for each PO Item should be subtracted if SCO gets cancelled
po.reload()
self.assertEqual(po.items[2].sco_qty, 25)
sco.cancel()
po.reload()
self.assertEqual(po.items[2].sco_qty, 12.5)

sco = make_subcontracting_order(po.name)
sco.save()
sco.submit()

# Test - 8: Since this PO is now fully subcontracted, creating a new SCO from it should throw error
self.assertRaises(frappe.ValidationError, make_subcontracting_order, po.name)

@IntegrationTestCase.change_settings("Buying Settings", {"auto_create_subcontracting_order": 1})
def test_auto_create_subcontracting_order(self):
from erpnext.controllers.tests.test_subcontracting_controller import (
Expand Down Expand Up @@ -1173,6 +1240,53 @@ def test_po_billed_amount_against_return_entry(self):
self.assertEqual(po.per_billed, 100)


def create_po_for_sc_testing():
from erpnext.controllers.tests.test_subcontracting_controller import (
make_bom_for_subcontracted_items,
make_raw_materials,
make_service_items,
make_subcontracted_items,
)

make_subcontracted_items()
make_raw_materials()
make_service_items()
make_bom_for_subcontracted_items()

service_items = [
{
"warehouse": "_Test Warehouse - _TC",
"item_code": "Subcontracted Service Item 1",
"qty": 10,
"rate": 100,
"fg_item": "Subcontracted Item SA1",
"fg_item_qty": 10,
},
{
"warehouse": "_Test Warehouse - _TC",
"item_code": "Subcontracted Service Item 2",
"qty": 20,
"rate": 25,
"fg_item": "Subcontracted Item SA2",
"fg_item_qty": 15,
},
{
"warehouse": "_Test Warehouse - _TC",
"item_code": "Subcontracted Service Item 3",
"qty": 25,
"rate": 10,
"fg_item": "Subcontracted Item SA3",
"fg_item_qty": 50,
},
]

return create_purchase_order(
rm_items=service_items,
is_subcontracted=1,
supplier_warehouse="_Test Warehouse 1 - _TC",
)


def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier
from erpnext.selling.doctype.customer.test_customer import create_internal_customer
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"actions": [],
"autoname": "hash",
"creation": "2013-05-24 19:29:06",
"creation": "2024-12-09 12:54:24.652161",
"doctype": "DocType",
"document_type": "Document",
"editable_grid": 1,
Expand All @@ -26,6 +26,7 @@
"quantity_and_rate",
"qty",
"stock_uom",
"sco_qty",
"col_break2",
"uom",
"conversion_factor",
Expand Down Expand Up @@ -929,13 +930,21 @@
"fieldtype": "Currency",
"label": "Distributed Discount Amount",
"options": "currency"
},
{
"allow_on_submit": 1,
"fieldname": "sco_qty",
"fieldtype": "Float",
"label": "Subcontracted Quantity",
"no_copy": 1,
"read_only": 1
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-06-02 06:20:10.508290",
"modified": "2024-12-10 12:11:18.536089",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order Item",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class PurchaseOrderItem(Document):
item_name: DF.Data
item_tax_rate: DF.Code | None
item_tax_template: DF.Link | None
job_card: DF.Link | None
last_purchase_rate: DF.Currency
manufacturer: DF.Link | None
manufacturer_part_no: DF.Data | None
Expand Down Expand Up @@ -81,6 +82,7 @@ class PurchaseOrderItem(Document):
sales_order_item: DF.Data | None
sales_order_packed_item: DF.Data | None
schedule_date: DF.Date
sco_qty: DF.Float
stock_qty: DF.Float
stock_uom: DF.Link
stock_uom_rate: DF.Currency
Expand Down
19 changes: 19 additions & 0 deletions erpnext/controllers/subcontracting_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,19 @@ def validate_items(self):
_("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name)
)

if (
self.doctype not in "Subcontracting Receipt"
and item.qty
> flt(get_pending_sco_qty(self.purchase_order).get(item.purchase_order_item))
/ item.sc_conversion_factor
):
frappe.throw(
_(
"Row {0}: Item {1}'s quantity cannot be higher than the available quantity."
).format(item.idx, item.item_name)
)
item.amount = item.qty * item.rate

if item.bom:
is_active, bom_item = frappe.get_value("BOM", item.bom, ["is_active", "item"])

Expand Down Expand Up @@ -1116,6 +1129,12 @@ def get_item_details(items):
return item_details


def get_pending_sco_qty(po_name):
table = frappe.qb.DocType("Purchase Order Item")
query = frappe.qb.from_(table).select(table.name, table.qty, table.sco_qty).where(table.parent == po_name)
return {item.name: item.qty - item.sco_qty for item in query.run(as_dict=True)}


@frappe.whitelist()
def make_rm_stock_entry(
subcontract_order, rm_items=None, order_doctype="Subcontracting Order", target_doc=None
Expand Down
3 changes: 2 additions & 1 deletion erpnext/controllers/tests/test_subcontracting_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -1261,6 +1261,7 @@ def make_raw_materials():
for item, properties in raw_materials.items():
if not frappe.db.exists("Item", item):
properties.update({"is_stock_item": 1})
properties.update({"valuation_rate": 100})
make_item(item, properties)


Expand Down Expand Up @@ -1311,7 +1312,7 @@ def make_bom_for_subcontracted_items():

for item_code, raw_materials in boms.items():
if not frappe.db.exists("BOM", {"item": item_code}):
make_bom(item=item_code, raw_materials=raw_materials, rate=100)
make_bom(item=item_code, raw_materials=raw_materials, rate=100, currency="INR")


def set_backflush_based_on(based_on):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,38 @@ frappe.provide("erpnext.buying");

erpnext.landed_cost_taxes_and_charges.setup_triggers("Subcontracting Order");

// client script for Subcontracting Order Item is not necessarily required as the server side code will do everything that is necessary.
// this is just so that the user does not get potentially confused
frappe.ui.form.on("Subcontracting Order Item", {
qty(frm, cdt, cdn) {
const row = locals[cdt][cdn];
frappe.model.set_value(cdt, cdn, "amount", row.qty * row.rate);
const service_item = frm.doc.service_items[row.idx - 1];
frappe.model.set_value(
service_item.doctype,
service_item.name,
"qty",
row.qty * row.sc_conversion_factor
);
frappe.model.set_value(service_item.doctype, service_item.name, "fg_item_qty", row.qty);
frappe.model.set_value(
service_item.doctype,
service_item.name,
"amount",
row.qty * row.sc_conversion_factor * service_item.rate
);
},
before_items_remove(frm, cdt, cdn) {
const row = locals[cdt][cdn];
frm.toggle_enable(["service_items"], true);
frm.get_field("service_items").grid.grid_rows[row.idx - 1].remove();
frm.toggle_enable(["service_items"], false);
},
});

frappe.ui.form.on("Subcontracting Order", {
setup: (frm) => {
frm.get_field("items").grid.cannot_add_rows = true;
frm.get_field("items").grid.only_sortable();
frm.trigger("set_queries");

frm.set_indicator_formatter("item_code", (doc) => (doc.qty <= doc.received_qty ? "green" : "orange"));
Expand Down
Loading

0 comments on commit 3eba6bf

Please sign in to comment.