mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-12 14:11:46 +00:00
feat: close individual Delivery Note and Purchase Receipt items
Extends row level close to the two documents where the goods have already moved, so closing a row writes off what is left to bill rather than what is left to fulfil. Nothing is released in Bin. Delivery Note and Purchase Receipt are billed through their own services rather than through status_updater, so their invoices declare the row link in `closed_source_links`. Without it a closed row stayed invoiceable, since the existing guard only walked status_updater args. `per_returned` shares the percentage funnel on both doctypes and is excluded from `SETTLED_BY_CLOSE`: closing a row writes off pending billing, it does not turn the row into a return. Closed rows now show a grey indicator in the items grid on all four doctypes. Purchase Receipt had no indicator formatter at all and gets one.
This commit is contained in:
@@ -235,6 +235,9 @@ class PurchaseInvoice(BuyingController):
|
||||
"overflow_type": "billing",
|
||||
}
|
||||
]
|
||||
self.closed_source_links = [
|
||||
("Purchase Invoice Item", "pr_detail", "Purchase Receipt Item", "Purchase Receipt")
|
||||
]
|
||||
|
||||
def onload(self):
|
||||
super().onload()
|
||||
|
||||
@@ -273,6 +273,9 @@ class SalesInvoice(SellingController):
|
||||
"overflow_type": "billing",
|
||||
}
|
||||
]
|
||||
self.closed_source_links = [
|
||||
("Sales Invoice Item", "dn_detail", "Delivery Note Item", "Delivery Note")
|
||||
]
|
||||
|
||||
def set_indicator(self):
|
||||
"""Set indicator for portal"""
|
||||
|
||||
@@ -14,7 +14,9 @@ frappe.ui.form.on("Purchase Order", {
|
||||
setup: function (frm) {
|
||||
frm.set_indicator_formatter("item_code", function (doc) {
|
||||
let color;
|
||||
if (!doc.qty && frm.doc.has_unit_price_items) {
|
||||
if (doc.closed) {
|
||||
color = "gray";
|
||||
} else if (!doc.qty && frm.doc.has_unit_price_items) {
|
||||
color = "yellow";
|
||||
} else if (doc.qty <= doc.received_qty) {
|
||||
color = "green";
|
||||
@@ -283,7 +285,6 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
|
||||
}
|
||||
|
||||
this.frm.set_df_property("drop_ship", "hidden", !is_drop_ship);
|
||||
this.set_item_close_buttons();
|
||||
|
||||
if (doc.docstatus == 1) {
|
||||
this.frm.fields_dict.items_section.wrapper.addClass("hide-border");
|
||||
@@ -438,6 +439,8 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
|
||||
} else if (doc.docstatus === 0) {
|
||||
this.frm.cscript.add_from_mappers();
|
||||
}
|
||||
|
||||
this.set_item_close_buttons();
|
||||
}
|
||||
|
||||
validate() {
|
||||
|
||||
@@ -15,7 +15,12 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import cint
|
||||
|
||||
REOPEN_STATUS = {"Purchase Order": "Submitted", "Sales Order": "Draft"}
|
||||
REOPEN_STATUS = {
|
||||
"Purchase Order": "Submitted",
|
||||
"Sales Order": "Draft",
|
||||
"Delivery Note": "Submitted",
|
||||
"Purchase Receipt": "Submitted",
|
||||
}
|
||||
|
||||
SETTLED_BY_CLOSE = ("per_ordered", "per_received", "per_delivered", "per_billed")
|
||||
|
||||
@@ -38,9 +43,7 @@ def closed_rows_settle(parent_doctype: str, item_doctype: str, percentage_field:
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_closed_status(
|
||||
doctype: str, name: str, item_names: str | list[str], closed: int
|
||||
) -> None:
|
||||
def update_closed_status(doctype: str, name: str, item_names: str | list[str], closed: int) -> None:
|
||||
if not has_closable_items(doctype):
|
||||
frappe.throw(_("Rows of {0} cannot be closed individually").format(_(doctype)))
|
||||
|
||||
|
||||
@@ -198,23 +198,36 @@ class StatusUpdater(Document):
|
||||
self.update_qty()
|
||||
self.validate_qty()
|
||||
|
||||
def get_closed_source_links(self):
|
||||
"""Row links that must not point at a closed source row.
|
||||
|
||||
`status_updater` covers documents whose progress it already tracks.
|
||||
Delivery Note and Purchase Receipt are billed through their own services
|
||||
instead, so their invoices declare the link in `closed_source_links`.
|
||||
"""
|
||||
links = [
|
||||
(args["source_dt"], args["join_field"], args["target_dt"], args["target_parent_dt"])
|
||||
for args in self.status_updater
|
||||
if args.get("target_dt")
|
||||
and args.get("target_parent_dt")
|
||||
and has_closable_items(args["target_parent_dt"])
|
||||
]
|
||||
|
||||
return links + list(getattr(self, "closed_source_links", []))
|
||||
|
||||
def validate_closed_source_items(self):
|
||||
"""Block submitting against rows that were closed on the source document."""
|
||||
if self.docstatus != 1:
|
||||
return
|
||||
|
||||
for args in self.status_updater:
|
||||
target_dt = args.get("target_dt")
|
||||
if not target_dt or not has_closable_items(args.get("target_parent_dt")):
|
||||
continue
|
||||
|
||||
for source_dt, join_field, target_dt, target_parent_dt in self.get_closed_source_links():
|
||||
if not frappe.get_meta(target_dt).has_field("closed"):
|
||||
continue
|
||||
|
||||
row_idx = {}
|
||||
for d in self.get_all_children(args["source_dt"]):
|
||||
if d.get(args["join_field"]):
|
||||
row_idx[d.get(args["join_field"])] = d.idx
|
||||
for d in self.get_all_children(source_dt):
|
||||
if d.get(join_field):
|
||||
row_idx[d.get(join_field)] = d.idx
|
||||
|
||||
if not row_idx:
|
||||
continue
|
||||
@@ -230,7 +243,7 @@ class StatusUpdater(Document):
|
||||
_("Row #{0}: Item {1} is closed in {2} {3} and cannot be processed further").format(
|
||||
row_idx[row.name],
|
||||
frappe.bold(row.item_code),
|
||||
_(args.get("target_parent_dt") or target_dt),
|
||||
_(target_parent_dt),
|
||||
frappe.bold(row.parent),
|
||||
)
|
||||
)
|
||||
|
||||
160
erpnext/controllers/tests/test_item_close_billing.py
Normal file
160
erpnext/controllers/tests/test_item_close_billing.py
Normal file
@@ -0,0 +1,160 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.controllers.item_close import update_closed_status
|
||||
from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_invoice
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
WAREHOUSE = "_Test Warehouse - _TC"
|
||||
|
||||
|
||||
class TestPurchaseReceiptItemClose(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.first_item = make_item(properties={"is_stock_item": 1}).name
|
||||
self.second_item = make_item(properties={"is_stock_item": 1}).name
|
||||
|
||||
def make_purchase_receipt(self):
|
||||
receipt = make_purchase_receipt(
|
||||
item_code=self.first_item, qty=10, rate=100, warehouse=WAREHOUSE, do_not_submit=True
|
||||
)
|
||||
receipt.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": self.second_item,
|
||||
"warehouse": WAREHOUSE,
|
||||
"qty": 10,
|
||||
"rate": 100,
|
||||
},
|
||||
)
|
||||
receipt.save()
|
||||
receipt.submit()
|
||||
return receipt
|
||||
|
||||
def close_items(self, doc, rows, closed=1):
|
||||
update_closed_status(doc.doctype, doc.name, [row.name for row in rows], closed)
|
||||
doc.reload()
|
||||
|
||||
def test_closing_row_settles_billing_percentage(self):
|
||||
receipt = self.make_purchase_receipt()
|
||||
self.assertEqual(receipt.per_billed, 0)
|
||||
|
||||
self.close_items(receipt, [receipt.items[1]])
|
||||
|
||||
self.assertEqual(receipt.per_billed, 50)
|
||||
|
||||
def test_closing_every_row_closes_the_receipt(self):
|
||||
receipt = self.make_purchase_receipt()
|
||||
|
||||
self.close_items(receipt, receipt.items)
|
||||
|
||||
self.assertEqual(receipt.per_billed, 100)
|
||||
self.assertEqual(receipt.status, "Closed")
|
||||
|
||||
def test_closed_row_is_not_mapped_to_purchase_invoice(self):
|
||||
receipt = self.make_purchase_receipt()
|
||||
self.close_items(receipt, [receipt.items[1]])
|
||||
|
||||
invoice = make_purchase_invoice(receipt.name)
|
||||
|
||||
self.assertEqual([item.item_code for item in invoice.items], [self.first_item])
|
||||
|
||||
def test_billing_a_closed_row_is_blocked(self):
|
||||
receipt = self.make_purchase_receipt()
|
||||
invoice = make_purchase_invoice(receipt.name)
|
||||
|
||||
self.close_items(receipt, [receipt.items[1]])
|
||||
|
||||
invoice.insert()
|
||||
self.assertRaises(frappe.ValidationError, invoice.submit)
|
||||
|
||||
def test_parent_reopen_is_blocked_when_all_rows_are_closed(self):
|
||||
receipt = self.make_purchase_receipt()
|
||||
self.close_items(receipt, receipt.items)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, receipt.update_status, "Submitted")
|
||||
|
||||
def test_reopening_one_row_reopens_the_receipt(self):
|
||||
receipt = self.make_purchase_receipt()
|
||||
self.close_items(receipt, receipt.items)
|
||||
|
||||
self.close_items(receipt, [receipt.items[1]], closed=0)
|
||||
|
||||
self.assertNotEqual(receipt.status, "Closed")
|
||||
self.assertEqual(receipt.per_billed, 50)
|
||||
|
||||
|
||||
class TestDeliveryNoteItemClose(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.first_item = make_item(properties={"is_stock_item": 1}).name
|
||||
self.second_item = make_item(properties={"is_stock_item": 1}).name
|
||||
for item_code in (self.first_item, self.second_item):
|
||||
make_stock_entry(item_code=item_code, target=WAREHOUSE, qty=100, basic_rate=50)
|
||||
|
||||
def make_delivery_note(self):
|
||||
note = create_delivery_note(
|
||||
item_code=self.first_item, qty=10, rate=100, warehouse=WAREHOUSE, do_not_save=True
|
||||
)
|
||||
note.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": self.second_item,
|
||||
"warehouse": WAREHOUSE,
|
||||
"qty": 10,
|
||||
"rate": 100,
|
||||
},
|
||||
)
|
||||
note.insert()
|
||||
note.submit()
|
||||
return note
|
||||
|
||||
def close_items(self, doc, rows, closed=1):
|
||||
update_closed_status(doc.doctype, doc.name, [row.name for row in rows], closed)
|
||||
doc.reload()
|
||||
|
||||
def test_closing_row_settles_billing_percentage(self):
|
||||
note = self.make_delivery_note()
|
||||
self.assertEqual(note.per_billed, 0)
|
||||
|
||||
self.close_items(note, [note.items[1]])
|
||||
|
||||
self.assertEqual(note.per_billed, 50)
|
||||
|
||||
def test_closing_every_row_closes_the_note(self):
|
||||
note = self.make_delivery_note()
|
||||
|
||||
self.close_items(note, note.items)
|
||||
|
||||
self.assertEqual(note.per_billed, 100)
|
||||
self.assertEqual(note.status, "Closed")
|
||||
|
||||
def test_closed_row_is_not_mapped_to_sales_invoice(self):
|
||||
note = self.make_delivery_note()
|
||||
self.close_items(note, [note.items[1]])
|
||||
|
||||
invoice = make_sales_invoice(note.name)
|
||||
|
||||
self.assertEqual([item.item_code for item in invoice.items], [self.first_item])
|
||||
|
||||
def test_billing_a_closed_row_is_blocked(self):
|
||||
note = self.make_delivery_note()
|
||||
invoice = make_sales_invoice(note.name)
|
||||
|
||||
self.close_items(note, [note.items[1]])
|
||||
|
||||
invoice.insert()
|
||||
self.assertRaises(frappe.ValidationError, invoice.submit)
|
||||
|
||||
def test_closing_a_row_does_not_mark_it_returned(self):
|
||||
note = self.make_delivery_note()
|
||||
|
||||
self.close_items(note, note.items)
|
||||
|
||||
self.assertEqual(note.per_returned, 0)
|
||||
self.assertEqual(note.status, "Closed")
|
||||
@@ -1,3 +1,5 @@
|
||||
frappe.provide("erpnext");
|
||||
|
||||
erpnext.item_close = {
|
||||
add_buttons(frm, config) {
|
||||
if (frm.doc.docstatus != 1 || !frm.has_perm("submit")) {
|
||||
@@ -42,16 +44,14 @@ erpnext.item_close = {
|
||||
cannot_add_rows: true,
|
||||
cannot_delete_rows: true,
|
||||
in_place_edit: false,
|
||||
fields: [
|
||||
{ fieldname: "name", fieldtype: "Data", read_only: 1, hidden: 1 },
|
||||
].concat(config.columns),
|
||||
fields: [{ fieldname: "name", fieldtype: "Data", read_only: 1, hidden: 1 }].concat(
|
||||
config.columns
|
||||
),
|
||||
},
|
||||
],
|
||||
primary_action_label: closed ? __("Close") : __("Reopen"),
|
||||
primary_action: () => {
|
||||
const selected = dialog.fields_dict.items.grid
|
||||
.get_selected_children()
|
||||
.map((row) => row.name);
|
||||
const selected = dialog.fields_dict.items.grid.get_selected_children().map((row) => row.name);
|
||||
|
||||
if (!selected.length) {
|
||||
frappe.msgprint(__("Select at least one row"));
|
||||
|
||||
@@ -25,7 +25,9 @@ frappe.ui.form.on("Sales Order", {
|
||||
// formatter for material request item
|
||||
frm.set_indicator_formatter("item_code", function (doc) {
|
||||
let color;
|
||||
if (!doc.qty && frm.doc.has_unit_price_items) {
|
||||
if (doc.closed) {
|
||||
color = "gray";
|
||||
} else if (!doc.qty && frm.doc.has_unit_price_items) {
|
||||
color = "yellow";
|
||||
} else if (doc.stock_qty - doc.delivered_qty <= doc.actual_qty) {
|
||||
color = "green";
|
||||
@@ -973,7 +975,6 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
|
||||
var me = this;
|
||||
super.refresh();
|
||||
let allow_delivery = false;
|
||||
this.set_item_close_buttons();
|
||||
|
||||
if (doc.docstatus == 1) {
|
||||
if (
|
||||
@@ -1266,6 +1267,8 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
|
||||
}
|
||||
|
||||
this.order_type(doc);
|
||||
|
||||
this.set_item_close_buttons();
|
||||
}
|
||||
|
||||
items_add(doc, cdt, cdn) {
|
||||
|
||||
@@ -23,6 +23,9 @@ frappe.ui.form.on("Delivery Note", {
|
||||
Shipment: "Shipment",
|
||||
}),
|
||||
frm.set_indicator_formatter("item_code", function (doc) {
|
||||
if (doc.closed) {
|
||||
return "gray";
|
||||
}
|
||||
return doc.docstatus == 1 || doc.qty <= doc.actual_qty ? "green" : "orange";
|
||||
});
|
||||
|
||||
@@ -353,7 +356,12 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends (
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.docstatus == 1 && doc.status === "Closed" && this.frm.has_perm("submit")) {
|
||||
if (
|
||||
doc.docstatus == 1 &&
|
||||
doc.status === "Closed" &&
|
||||
this.frm.has_perm("submit") &&
|
||||
!doc.items.every((item) => item.closed)
|
||||
) {
|
||||
this.frm.add_custom_button(
|
||||
__("Reopen"),
|
||||
function () {
|
||||
@@ -363,6 +371,7 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends (
|
||||
);
|
||||
}
|
||||
erpnext.stock.delivery_note.set_print_hide(doc, dt, dn);
|
||||
this.set_item_close_buttons();
|
||||
}
|
||||
|
||||
make_shipment() {
|
||||
@@ -429,6 +438,30 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends (
|
||||
this.update_status("Submitted");
|
||||
}
|
||||
|
||||
set_item_close_buttons() {
|
||||
erpnext.item_close.add_buttons(this.frm, {
|
||||
is_closable: (item) => !item.closed && flt(item.billed_amt) < flt(item.amount),
|
||||
help: __(
|
||||
"Closed rows stop being expected. Their unbilled amount is written off and they are skipped when creating a Sales Invoice."
|
||||
),
|
||||
summarise: (item) => ({
|
||||
item_code: item.item_code,
|
||||
item_name: item.item_name,
|
||||
qty: item.qty,
|
||||
amount: item.amount,
|
||||
billed_amt: item.billed_amt || 0,
|
||||
pending_amount: Math.max(flt(item.amount) - flt(item.billed_amt), 0),
|
||||
}),
|
||||
columns: [
|
||||
erpnext.item_close.column("item_code", __("Item Code"), "Data", 3),
|
||||
erpnext.item_close.column("item_name", __("Item Name"), "Data", 2),
|
||||
erpnext.item_close.column("qty", __("Qty")),
|
||||
erpnext.item_close.column("amount", __("Amount"), "Currency", 2),
|
||||
erpnext.item_close.column("pending_amount", __("Pending Amount"), "Currency", 2),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
update_status(status) {
|
||||
var me = this;
|
||||
frappe.ui.form.is_saving = true;
|
||||
|
||||
@@ -642,6 +642,12 @@ class DeliveryNote(SellingController):
|
||||
def update_status(self, status):
|
||||
BillingStatusService(self).update_status(status)
|
||||
|
||||
def on_item_close_status_change(self):
|
||||
self.update_billing_percentage()
|
||||
|
||||
def is_item_closable(self, item):
|
||||
return flt(item.billed_amt) < flt(item.amount)
|
||||
|
||||
def update_billing_status(self, update_modified=True):
|
||||
BillingStatusService(self).update_billing_status(update_modified)
|
||||
|
||||
|
||||
@@ -58,6 +58,14 @@ def get_returned_qty_map(delivery_note: str) -> dict:
|
||||
return returned_qty_map
|
||||
|
||||
|
||||
def is_bundle_of_closed_row(packed_item) -> bool:
|
||||
"""A packed item follows the Delivery Note Item row that bundles it."""
|
||||
return bool(
|
||||
packed_item.parent_detail_docname
|
||||
and frappe.db.get_value("Delivery Note Item", packed_item.parent_detail_docname, "closed")
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_sales_invoice(
|
||||
source_name: str, target_doc: str | dict | Document | None = None, args: dict | str | None = None
|
||||
@@ -123,7 +131,7 @@ def make_sales_invoice(
|
||||
def select_item(d):
|
||||
filtered_items = args.get("filtered_children", [])
|
||||
child_filter = d.name in filtered_items if filtered_items else True
|
||||
return child_filter
|
||||
return child_filter and not d.closed
|
||||
|
||||
doc = get_mapped_doc(
|
||||
"Delivery Note",
|
||||
@@ -254,7 +262,7 @@ def make_installation_note(
|
||||
"parenttype": "prevdoc_doctype",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: doc.installed_qty < doc.qty,
|
||||
"condition": lambda doc: doc.installed_qty < doc.qty and not doc.closed,
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
@@ -293,7 +301,9 @@ def make_packing_slip(source_name: str, target_doc: str | dict | Document | None
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda item: (
|
||||
not is_product_bundle(item.item_code) and flt(item.packed_qty) < flt(item.qty)
|
||||
not is_product_bundle(item.item_code)
|
||||
and not item.closed
|
||||
and flt(item.packed_qty) < flt(item.qty)
|
||||
),
|
||||
},
|
||||
"Packed Item": {
|
||||
@@ -307,7 +317,9 @@ def make_packing_slip(source_name: str, target_doc: str | dict | Document | None
|
||||
"name": "pi_detail",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda item: (flt(item.packed_qty) < flt(item.qty)),
|
||||
"condition": lambda item: (
|
||||
flt(item.packed_qty) < flt(item.qty) and not is_bundle_of_closed_row(item)
|
||||
),
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
@@ -576,7 +588,8 @@ def make_inter_company_transaction(doctype: str, source_name: str, target_doc=No
|
||||
"Material_request_item": "material_request_item",
|
||||
},
|
||||
"field_no_map": ["warehouse"],
|
||||
"condition": lambda item: item.received_qty < item.qty + item.returned_qty,
|
||||
"condition": lambda item: item.received_qty < item.qty + item.returned_qty
|
||||
and not item.closed,
|
||||
"postprocess": update_item,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,6 +9,8 @@ from frappe.desk.notifications import clear_doctype_notifications
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.controllers.item_close import validate_parent_reopen
|
||||
|
||||
|
||||
class BillingStatusService:
|
||||
def __init__(self, doc):
|
||||
@@ -16,6 +18,10 @@ class BillingStatusService:
|
||||
|
||||
def update_status(self, status: str) -> None:
|
||||
doc = self.doc
|
||||
|
||||
if status != "Closed" and doc.status == "Closed":
|
||||
validate_parent_reopen(doc)
|
||||
|
||||
doc.set_status(update=True, status=status)
|
||||
doc.notify_update()
|
||||
clear_doctype_notifications(doc)
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"base_net_rate",
|
||||
"base_net_amount",
|
||||
"billed_amt",
|
||||
"closed",
|
||||
"incoming_rate",
|
||||
"item_weight_details",
|
||||
"weight_per_unit",
|
||||
@@ -703,6 +704,15 @@
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "closed",
|
||||
"fieldtype": "Check",
|
||||
"label": "Closed",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"default": "0",
|
||||
@@ -982,7 +992,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-18 10:00:00.000000",
|
||||
"modified": "2026-07-29 12:30:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Delivery Note Item",
|
||||
|
||||
@@ -31,6 +31,7 @@ class DeliveryNoteItem(Document):
|
||||
batch_no: DF.Link | None
|
||||
billed_amt: DF.Currency
|
||||
brand: DF.Link | None
|
||||
closed: DF.Check
|
||||
company_total_stock: DF.Float
|
||||
conversion_factor: DF.Float
|
||||
cost_center: DF.Link | None
|
||||
|
||||
@@ -122,7 +122,7 @@ def make_purchase_invoice(
|
||||
def select_item(d):
|
||||
filtered_items = args.get("filtered_children", [])
|
||||
child_filter = d.name in filtered_items if filtered_items else True
|
||||
return child_filter
|
||||
return child_filter and not d.closed
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Purchase Receipt",
|
||||
|
||||
@@ -17,6 +17,10 @@ frappe.ui.form.on("Purchase Receipt", {
|
||||
"Landed Cost Voucher": "Landed Cost Voucher",
|
||||
};
|
||||
|
||||
frm.set_indicator_formatter("item_code", function (doc) {
|
||||
return doc.closed ? "gray" : "green";
|
||||
});
|
||||
|
||||
frm.set_query("wip_composite_asset", "items", function () {
|
||||
return {
|
||||
filters: { asset_type: "Composite Asset", docstatus: 0 },
|
||||
@@ -275,9 +279,40 @@ erpnext.stock.PurchaseReceiptController = class PurchaseReceiptController extend
|
||||
}
|
||||
}
|
||||
|
||||
if (this.frm.doc.docstatus == 1 && this.frm.doc.status === "Closed" && this.frm.has_perm("submit")) {
|
||||
if (
|
||||
this.frm.doc.docstatus == 1 &&
|
||||
this.frm.doc.status === "Closed" &&
|
||||
this.frm.has_perm("submit") &&
|
||||
!this.frm.doc.items.every((item) => item.closed)
|
||||
) {
|
||||
cur_frm.add_custom_button(__("Reopen"), this.reopen_purchase_receipt, __("Status"));
|
||||
}
|
||||
|
||||
this.set_item_close_buttons();
|
||||
}
|
||||
|
||||
set_item_close_buttons() {
|
||||
erpnext.item_close.add_buttons(this.frm, {
|
||||
is_closable: (item) => !item.closed && flt(item.billed_amt) < flt(item.amount),
|
||||
help: __(
|
||||
"Closed rows stop being expected. Their unbilled amount is written off and they are skipped when creating a Purchase Invoice."
|
||||
),
|
||||
summarise: (item) => ({
|
||||
item_code: item.item_code,
|
||||
item_name: item.item_name,
|
||||
qty: item.qty,
|
||||
amount: item.amount,
|
||||
billed_amt: item.billed_amt || 0,
|
||||
pending_amount: Math.max(flt(item.amount) - flt(item.billed_amt), 0),
|
||||
}),
|
||||
columns: [
|
||||
erpnext.item_close.column("item_code", __("Item Code"), "Data", 3),
|
||||
erpnext.item_close.column("item_name", __("Item Name"), "Data", 2),
|
||||
erpnext.item_close.column("qty", __("Qty")),
|
||||
erpnext.item_close.column("amount", __("Amount"), "Currency", 2),
|
||||
erpnext.item_close.column("pending_amount", __("Pending Amount"), "Currency", 2),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
make_purchase_invoice() {
|
||||
|
||||
@@ -11,6 +11,7 @@ from frappe.utils import cint, flt, getdate, nowdate
|
||||
import erpnext
|
||||
from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled
|
||||
from erpnext.controllers.buying_controller import BuyingController
|
||||
from erpnext.controllers.item_close import validate_parent_reopen
|
||||
from erpnext.stock.doctype.purchase_receipt.services.billing_status import BillingStatusService
|
||||
from erpnext.stock.doctype.purchase_receipt.services.provisional_accounting import (
|
||||
ProvisionalAccountingService,
|
||||
@@ -519,10 +520,19 @@ class PurchaseReceipt(BuyingController):
|
||||
)
|
||||
|
||||
def update_status(self, status):
|
||||
if status != "Closed" and self.status == "Closed":
|
||||
validate_parent_reopen(self)
|
||||
|
||||
self.set_status(update=True, status=status)
|
||||
self.notify_update()
|
||||
clear_doctype_notifications(self)
|
||||
|
||||
def on_item_close_status_change(self):
|
||||
self.update_billing_status()
|
||||
|
||||
def is_item_closable(self, item):
|
||||
return flt(item.billed_amt) < flt(item.amount)
|
||||
|
||||
def update_billing_status(self, update_modified=True):
|
||||
BillingStatusService(self).update_billing_status(update_modified)
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@ def update_billing_percentage(
|
||||
total_billable_amount = pending_amount if item.billed_amt <= pending_amount else item.billed_amt
|
||||
|
||||
total_amount += total_billable_amount
|
||||
total_billed_amount += abs(flt(item.billed_amt))
|
||||
total_billed_amount += total_billable_amount if item.closed else abs(flt(item.billed_amt))
|
||||
|
||||
if pr_doc.get("is_return") and not total_amount and total_billed_amount:
|
||||
total_amount = total_billed_amount
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"landed_cost_voucher_amount",
|
||||
"amount_difference_with_purchase_invoice",
|
||||
"billed_amt",
|
||||
"closed",
|
||||
"warehouse_and_reference",
|
||||
"warehouse",
|
||||
"rejected_warehouse",
|
||||
@@ -645,6 +646,15 @@
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "closed",
|
||||
"fieldtype": "Check",
|
||||
"label": "Closed",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "landed_cost_voucher_amount",
|
||||
@@ -1144,7 +1154,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-16 15:00:00.000000",
|
||||
"modified": "2026-07-29 12:30:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Purchase Receipt Item",
|
||||
|
||||
@@ -29,6 +29,7 @@ class PurchaseReceiptItem(Document):
|
||||
batch_no: DF.Link | None
|
||||
billed_amt: DF.Currency
|
||||
brand: DF.Link | None
|
||||
closed: DF.Check
|
||||
conversion_factor: DF.Float
|
||||
cost_center: DF.Link | None
|
||||
delivery_note_item: DF.Data | None
|
||||
|
||||
Reference in New Issue
Block a user