Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(subcontracting): Added provision to create multiple Subcontracting Orders against a single Purchase Order (backport #44711) #44782

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions erpnext/buying/doctype/purchase_order/purchase_order.js
Original file line number Diff line number Diff line change
Expand Up @@ -400,11 +400,15 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
);
}
} else {
cur_frm.add_custom_button(
__("Subcontracting Order"),
this.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 @@ -867,27 +867,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 @@ -931,20 +944,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 @@ -1004,7 +1004,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 @@ -1059,6 +1059,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)

@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 @@ -1124,6 +1191,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 @@ -909,13 +910,21 @@
{
"fieldname": "column_break_fyqr",
"fieldtype": "Column Break"
},
{
"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-02-05 11:23:24.859435",
"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 @@ -80,6 +80,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 @@ -1110,6 +1123,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
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
Loading