Merge branch 'gh50060' of https://github.com/mihir-kandoi/erpnext into gh50060

This commit is contained in:
Mihir Kandoi
2025-12-18 21:07:57 +05:30
20 changed files with 507 additions and 133 deletions

View File

@@ -1494,18 +1494,14 @@ frappe.ui.form.on("Payment Entry", {
"Can refer row only if the charge type is 'On Previous Row Amount' or 'Previous Row Total'" "Can refer row only if the charge type is 'On Previous Row Amount' or 'Previous Row Total'"
); );
d.row_id = ""; d.row_id = "";
} else if ( } else if (d.charge_type == "On Previous Row Amount" || d.charge_type == "On Previous Row Total") {
(d.charge_type == "On Previous Row Amount" || d.charge_type == "On Previous Row Total") &&
d.row_id
) {
if (d.idx == 1) { if (d.idx == 1) {
msg = __( msg = __(
"Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row" "Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row"
); );
d.charge_type = ""; d.charge_type = "";
} else if (!d.row_id) { } else if (!d.row_id) {
msg = __("Please specify a valid Row ID for row {0} in table {1}", [d.idx, __(d.doctype)]); d.row_id = d.idx - 1;
d.row_id = "";
} else if (d.row_id && d.row_id >= d.idx) { } else if (d.row_id && d.row_id >= d.idx) {
msg = __( msg = __(
"Cannot refer row number greater than or equal to current row number for this Charge type" "Cannot refer row number greater than or equal to current row number for this Charge type"

View File

@@ -427,6 +427,7 @@ class PaymentRequest(Document):
context = { context = {
"doc": frappe.get_doc(self.reference_doctype, self.reference_name), "doc": frappe.get_doc(self.reference_doctype, self.reference_name),
"payment_url": self.payment_url, "payment_url": self.payment_url,
"payment_request": self,
} }
if self.message: if self.message:
@@ -892,22 +893,25 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False):
def get_dummy_message(doc): def get_dummy_message(doc):
return frappe.render_template( return """
"""{% if doc.contact_person -%} {% if doc.contact_person -%}
<p>Dear {{ doc.contact_person }},</p> <p>Dear {{ doc.contact_person }},</p>
{%- else %}<p>Hello,</p>{% endif %} {%- else %}<p>Hello,</p>{% endif %}
<p>{{ _("Requesting payment against {0} {1} for amount {2}").format(doc.doctype, <p>
doc.name, doc.get_formatted("grand_total")) }}</p> {{ _("Requesting payment against {0} {1} for amount {2}").format(
doc.doctype,
doc.name,
payment_request.get_formatted("grand_total")
) }}
</p>
<a href="{{ payment_url }}">{{ _("Make Payment") }}</a> <a href="{{ payment_url }}">{{ _("Make Payment") }}</a>
<p>{{ _("If you have any questions, please get back to us.") }}</p> <p>{{ _("If you have any questions, please get back to us.") }}</p>
<p>{{ _("Thank you for your business!") }}</p> <p>{{ _("Thank you for your business!") }}</p>
""", """
dict(doc=doc, payment_url="{{ payment_url }}"),
)
@frappe.whitelist() @frappe.whitelist()

View File

@@ -270,8 +270,14 @@ frappe.ui.form.on("Asset", {
const row = [ const row = [
sch["idx"], sch["idx"],
frappe.format(sch["schedule_date"], { fieldtype: "Date" }), frappe.format(sch["schedule_date"], { fieldtype: "Date" }),
frappe.format(sch["depreciation_amount"], { fieldtype: "Currency" }), frappe.format(sch["depreciation_amount"], {
frappe.format(sch["accumulated_depreciation_amount"], { fieldtype: "Currency" }), fieldtype: "Currency",
options: "Company:company:default_currency",
}),
frappe.format(sch["accumulated_depreciation_amount"], {
fieldtype: "Currency",
options: "Company:company:default_currency",
}),
sch["journal_entry"] || "", sch["journal_entry"] || "",
]; ];

View File

@@ -32,6 +32,13 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e
this.make_purchase_order.bind(this), this.make_purchase_order.bind(this),
__("Create") __("Create")
); );
this.frm.add_custom_button(__("Update Items"), () => {
erpnext.utils.update_child_items({
frm: this.frm,
child_docname: "items",
cannot_add_row: false,
});
});
this.frm.page.set_inner_btn_group_as_primary(__("Create")); this.frm.page.set_inner_btn_group_as_primary(__("Create"));
this.frm.add_custom_button(__("Quotation"), this.make_quotation.bind(this), __("Create")); this.frm.add_custom_button(__("Quotation"), this.make_quotation.bind(this), __("Create"));
} else if (this.frm.doc.docstatus === 0) { } else if (this.frm.doc.docstatus === 0) {

View File

@@ -348,3 +348,15 @@ def set_expired_status():
""", """,
(nowdate()), (nowdate()),
) )
def get_purchased_items(supplier_quotation: str):
return frappe._dict(
frappe.get_all(
"Purchase Order Item",
filters={"supplier_quotation": supplier_quotation, "docstatus": 1},
fields=["supplier_quotation_item", {"SUM": "qty"}],
group_by="supplier_quotation_item",
as_list=1,
)
)

View File

@@ -2,15 +2,115 @@
# License: GNU General Public License v3. See license.txt # License: GNU General Public License v3. See license.txt
import json
import frappe import frappe
from frappe.tests import IntegrationTestCase, change_settings from frappe.tests import IntegrationTestCase, change_settings
from frappe.utils import add_days, today from frappe.utils import add_days, today
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order
from erpnext.controllers.accounts_controller import InvalidQtyError from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate
class TestPurchaseOrder(IntegrationTestCase): class TestPurchaseOrder(IntegrationTestCase):
def test_update_child_supplier_quotation_add_item(self):
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
sq.submit()
trans_item = json.dumps(
[
{
"item_code": sq.items[0].item_code,
"rate": sq.items[0].rate,
"qty": 5,
"docname": sq.items[0].name,
},
{
"item_code": "_Test Item 2",
"rate": 300,
"qty": 3,
},
]
)
update_child_qty_rate("Supplier Quotation", trans_item, sq.name)
sq.reload()
self.assertEqual(sq.get("items")[0].qty, 5)
self.assertEqual(sq.get("items")[1].rate, 300)
def test_update_supplier_quotation_child_rate_disallow(self):
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
sq.submit()
trans_item = json.dumps(
[
{
"item_code": sq.items[0].item_code,
"rate": 300,
"qty": sq.items[0].qty,
"docname": sq.items[0].name,
},
]
)
self.assertRaises(
frappe.ValidationError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name
)
def test_update_supplier_quotation_child_remove_item(self):
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
sq.submit()
po = make_purchase_order(sq.name)
trans_item = json.dumps(
[
{
"item_code": sq.items[0].item_code,
"rate": sq.items[0].rate,
"qty": sq.items[0].qty,
"docname": sq.items[0].name,
},
{
"item_code": "_Test Item 2",
"rate": 300,
"qty": 3,
},
]
)
po.get("items")[0].schedule_date = add_days(today(), 1)
update_child_qty_rate("Supplier Quotation", trans_item, sq.name)
po.submit()
sq.reload()
trans_item = json.dumps(
[
{
"item_code": "_Test Item 2",
"rate": 300,
"qty": 3,
}
]
)
frappe.db.savepoint("before_cancel")
# check if item having purchase order can be removed
self.assertRaises(
frappe.LinkExistsError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name
)
frappe.db.rollback(save_point="before_cancel")
trans_item = json.dumps(
[
{
"item_code": sq.items[0].item_code,
"rate": sq.items[0].rate,
"qty": sq.items[0].qty,
"docname": sq.items[0].name,
}
]
)
update_child_qty_rate("Supplier Quotation", trans_item, sq.name)
sq.reload()
self.assertEqual(len(sq.get("items")), 1)
def test_supplier_quotation_qty(self): def test_supplier_quotation_qty(self):
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0]) sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
sq.items[0].qty = 0 sq.items[0].qty = 0

View File

@@ -2707,7 +2707,9 @@ class AccountsController(TransactionBase):
for d in self.get("payment_schedule"): for d in self.get("payment_schedule"):
d.validate_from_to_dates("discount_date", "due_date") d.validate_from_to_dates("discount_date", "due_date")
if self.doctype == "Sales Order" and getdate(d.due_date) < getdate(self.transaction_date): if self.doctype in ["Sales Order", "Quotation"] and getdate(d.due_date) < getdate(
self.transaction_date
):
frappe.throw( frappe.throw(
_("Row {0}: Due Date in the Payment Terms table cannot be before Posting Date").format( _("Row {0}: Due Date in the Payment Terms table cannot be before Posting Date").format(
d.idx d.idx
@@ -3707,7 +3709,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child
conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor")) conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor"))
child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor
if child_doctype == "Purchase Order Item": if child_doctype in ["Purchase Order Item", "Supplier Quotation Item"]:
# Initialized value will update in parent validation # Initialized value will update in parent validation
child_item.base_rate = 1 child_item.base_rate = 1
child_item.base_amount = 1 child_item.base_amount = 1
@@ -3725,7 +3727,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child
return child_item return child_item
def validate_child_on_delete(row, parent): def validate_child_on_delete(row, parent, ordered_item=None):
"""Check if partially transacted item (row) is being deleted.""" """Check if partially transacted item (row) is being deleted."""
if parent.doctype == "Sales Order": if parent.doctype == "Sales Order":
if flt(row.delivered_qty): if flt(row.delivered_qty):
@@ -3753,13 +3755,17 @@ def validate_child_on_delete(row, parent):
row.idx, row.item_code row.idx, row.item_code
) )
) )
if parent.doctype in ["Purchase Order", "Sales Order"]:
if flt(row.billed_amt): if flt(row.billed_amt):
frappe.throw( frappe.throw(
_("Row #{0}: Cannot delete item {1} which has already been billed.").format( _("Row #{0}: Cannot delete item {1} which has already been billed.").format(
row.idx, row.item_code row.idx, row.item_code
)
) )
)
if parent.doctype == "Quotation":
if ordered_item.get(row.name):
frappe.throw(_("Cannot delete an item which has been ordered"))
def update_bin_on_delete(row, doctype): def update_bin_on_delete(row, doctype):
@@ -3785,7 +3791,7 @@ def update_bin_on_delete(row, doctype):
update_bin_qty(row.item_code, row.warehouse, qty_dict) update_bin_qty(row.item_code, row.warehouse, qty_dict)
def validate_and_delete_children(parent, data) -> bool: def validate_and_delete_children(parent, data, ordered_item=None) -> bool:
deleted_children = [] deleted_children = []
updated_item_names = [d.get("docname") for d in data] updated_item_names = [d.get("docname") for d in data]
for item in parent.items: for item in parent.items:
@@ -3793,7 +3799,7 @@ def validate_and_delete_children(parent, data) -> bool:
deleted_children.append(item) deleted_children.append(item)
for d in deleted_children: for d in deleted_children:
validate_child_on_delete(d, parent) validate_child_on_delete(d, parent, ordered_item)
d.cancel() d.cancel()
d.delete() d.delete()
@@ -3802,16 +3808,19 @@ def validate_and_delete_children(parent, data) -> bool:
# need to update ordered qty in Material Request first # need to update ordered qty in Material Request first
# bin uses Material Request Items to recalculate & update # bin uses Material Request Items to recalculate & update
parent.update_prevdoc_status() if parent.doctype not in ["Quotation", "Supplier Quotation"]:
parent.update_prevdoc_status()
for d in deleted_children: for d in deleted_children:
update_bin_on_delete(d, parent.doctype) update_bin_on_delete(d, parent.doctype)
return bool(deleted_children) return bool(deleted_children)
@frappe.whitelist() @frappe.whitelist()
def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, child_docname="items"): def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, child_docname="items"):
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items
from erpnext.selling.doctype.quotation.quotation import get_ordered_items
def check_doc_permissions(doc, perm_type="create"): def check_doc_permissions(doc, perm_type="create"):
try: try:
doc.check_permission(perm_type) doc.check_permission(perm_type)
@@ -3850,7 +3859,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
) )
def get_new_child_item(item_row): def get_new_child_item(item_row):
child_doctype = "Sales Order Item" if parent_doctype == "Sales Order" else "Purchase Order Item" child_doctype = parent_doctype + " Item"
return set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child_docname, item_row) return set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child_docname, item_row)
def is_allowed_zero_qty(): def is_allowed_zero_qty():
@@ -3875,6 +3884,21 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
if parent_doctype == "Purchase Order" and flt(new_data.get("qty")) < flt(child_item.received_qty): if parent_doctype == "Purchase Order" and flt(new_data.get("qty")) < flt(child_item.received_qty):
frappe.throw(_("Cannot set quantity less than received quantity")) frappe.throw(_("Cannot set quantity less than received quantity"))
if parent_doctype in ["Quotation", "Supplier Quotation"]:
if (parent_doctype == "Quotation" and not ordered_items) or (
parent_doctype == "Supplier Quotation" and not purchased_items
):
return
qty_to_check = (
ordered_items.get(child_item.name)
if parent_doctype == "Quotation"
else purchased_items.get(child_item.name)
)
if qty_to_check:
if flt(new_data.get("qty")) < qty_to_check:
frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity"))
def should_update_supplied_items(doc) -> bool: def should_update_supplied_items(doc) -> bool:
"""Subcontracted PO can allow following changes *after submit*: """Subcontracted PO can allow following changes *after submit*:
@@ -3917,7 +3941,6 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
frappe.throw(_("Finished Good Item {0} Qty can not be zero").format(new_data["fg_item"])) frappe.throw(_("Finished Good Item {0} Qty can not be zero").format(new_data["fg_item"]))
data = json.loads(trans_items) data = json.loads(trans_items)
any_qty_changed = False # updated to true if any item's qty changes any_qty_changed = False # updated to true if any item's qty changes
items_added_or_removed = False # updated to true if any new item is added or removed items_added_or_removed = False # updated to true if any new item is added or removed
any_conversion_factor_changed = False any_conversion_factor_changed = False
@@ -3925,7 +3948,16 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
parent = frappe.get_doc(parent_doctype, parent_doctype_name) parent = frappe.get_doc(parent_doctype, parent_doctype_name)
check_doc_permissions(parent, "write") check_doc_permissions(parent, "write")
_removed_items = validate_and_delete_children(parent, data)
if parent_doctype == "Quotation":
ordered_items = get_ordered_items(parent.name)
_removed_items = validate_and_delete_children(parent, data, ordered_items)
elif parent_doctype == "Supplier Quotation":
purchased_items = get_purchased_items(parent.name)
_removed_items = validate_and_delete_children(parent, data, purchased_items)
else:
_removed_items = validate_and_delete_children(parent, data)
items_added_or_removed |= _removed_items items_added_or_removed |= _removed_items
for d in data: for d in data:
@@ -3965,7 +3997,9 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
conversion_factor_unchanged = prev_con_fac == new_con_fac conversion_factor_unchanged = prev_con_fac == new_con_fac
any_conversion_factor_changed |= not conversion_factor_unchanged any_conversion_factor_changed |= not conversion_factor_unchanged
date_unchanged = ( date_unchanged = (
prev_date == getdate(new_date) if prev_date and new_date else False (prev_date == getdate(new_date) if prev_date and new_date else False)
if parent_doctype not in ["Quotation", "Supplier Quotation"]
else None
) # in case of delivery note etc ) # in case of delivery note etc
if ( if (
rate_unchanged rate_unchanged
@@ -3978,6 +4012,10 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
continue continue
validate_quantity(child_item, d) validate_quantity(child_item, d)
if parent_doctype in ["Quotation", "Supplier Quotation"]:
if not rate_unchanged:
frappe.throw(_("Rates cannot be modified for quoted items"))
if flt(child_item.get("qty")) != flt(d.get("qty")): if flt(child_item.get("qty")) != flt(d.get("qty")):
any_qty_changed = True any_qty_changed = True
@@ -4001,18 +4039,21 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
rate_unchanged = prev_rate == new_rate rate_unchanged = prev_rate == new_rate
if not rate_unchanged and not child_item.get("qty") and is_allowed_zero_qty(): if not rate_unchanged and not child_item.get("qty") and is_allowed_zero_qty():
frappe.throw(_("Rate of '{}' items cannot be changed").format(frappe.bold(_("Unit Price")))) frappe.throw(_("Rate of '{}' items cannot be changed").format(frappe.bold(_("Unit Price"))))
# Amount cannot be lesser than billed amount, except for negative amounts # Amount cannot be lesser than billed amount, except for negative amounts
row_rate = flt(d.get("rate"), rate_precision) row_rate = flt(d.get("rate"), rate_precision)
amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt(
row_rate * flt(d.get("qty"), qty_precision), rate_precision if parent_doctype in ["Purchase Order", "Sales Order"]:
) amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt(
if amount_below_billed_amt and row_rate > 0.0: row_rate * flt(d.get("qty"), qty_precision), rate_precision
frappe.throw(
_(
"Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}."
).format(child_item.idx, child_item.item_code)
) )
if amount_below_billed_amt and row_rate > 0.0:
frappe.throw(
_(
"Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}."
).format(child_item.idx, child_item.item_code)
)
else:
child_item.rate = row_rate
else: else:
child_item.rate = row_rate child_item.rate = row_rate
@@ -4040,26 +4081,27 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
if d.get("bom_no") and parent_doctype == "Sales Order": if d.get("bom_no") and parent_doctype == "Sales Order":
child_item.bom_no = d.get("bom_no") child_item.bom_no = d.get("bom_no")
if flt(child_item.price_list_rate): if parent_doctype in ["Sales Order", "Purchase Order"]:
if flt(child_item.rate) > flt(child_item.price_list_rate): if flt(child_item.price_list_rate):
# if rate is greater than price_list_rate, set margin if flt(child_item.rate) > flt(child_item.price_list_rate):
# or set discount # if rate is greater than price_list_rate, set margin
child_item.discount_percentage = 0 # or set discount
child_item.margin_type = "Amount" child_item.discount_percentage = 0
child_item.margin_rate_or_amount = flt( child_item.margin_type = "Amount"
child_item.rate - child_item.price_list_rate, child_item.margin_rate_or_amount = flt(
child_item.precision("margin_rate_or_amount"), child_item.rate - child_item.price_list_rate,
) child_item.precision("margin_rate_or_amount"),
child_item.rate_with_margin = child_item.rate )
else: child_item.rate_with_margin = child_item.rate
child_item.discount_percentage = flt( else:
(1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0, child_item.discount_percentage = flt(
child_item.precision("discount_percentage"), (1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0,
) child_item.precision("discount_percentage"),
child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate) )
child_item.margin_type = "" child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate)
child_item.margin_rate_or_amount = 0 child_item.margin_type = ""
child_item.rate_with_margin = 0 child_item.margin_rate_or_amount = 0
child_item.rate_with_margin = 0
child_item.flags.ignore_validate_update_after_submit = True child_item.flags.ignore_validate_update_after_submit = True
if new_child_flag: if new_child_flag:
@@ -4081,13 +4123,14 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
parent.doctype, parent.company, parent.base_grand_total parent.doctype, parent.company, parent.base_grand_total
) )
parent.set_payment_schedule() if parent_doctype != "Supplier Quotation":
parent.set_payment_schedule()
if parent_doctype == "Purchase Order": if parent_doctype == "Purchase Order":
parent.validate_minimum_order_qty() parent.validate_minimum_order_qty()
parent.validate_budget() parent.validate_budget()
if parent.is_against_so(): if parent.is_against_so():
parent.update_status_updater() parent.update_status_updater()
else: elif parent_doctype == "Sales Order":
parent.check_credit_limit() parent.check_credit_limit()
# reset index of child table # reset index of child table
@@ -4120,7 +4163,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
"Items cannot be updated as Subcontracting Order is created against the Purchase Order {0}." "Items cannot be updated as Subcontracting Order is created against the Purchase Order {0}."
).format(frappe.bold(parent.name)) ).format(frappe.bold(parent.name))
) )
else: # Sales Order elif parent_doctype == "Sales Order": # Sales Order
if parent.is_subcontracted and not parent.can_update_items(): if parent.is_subcontracted and not parent.can_update_items():
frappe.throw( frappe.throw(
_( _(
@@ -4138,9 +4181,10 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
parent.reload() parent.reload()
validate_workflow_conditions(parent) validate_workflow_conditions(parent)
parent.update_blanket_order() if parent_doctype in ["Purchase Order", "Sales Order"]:
parent.update_billing_percentage() parent.update_blanket_order()
parent.set_status() parent.update_billing_percentage()
parent.set_status()
parent.validate_uom_is_integer("uom", "qty") parent.validate_uom_is_integer("uom", "qty")
parent.validate_uom_is_integer("stock_uom", "stock_qty") parent.validate_uom_is_integer("stock_uom", "stock_qty")

View File

@@ -1,5 +1,6 @@
{ {
"actions": [], "actions": [],
"allow_rename": 1,
"autoname": "field:market_segment", "autoname": "field:market_segment",
"creation": "2018-10-01 09:59:14.479509", "creation": "2018-10-01 09:59:14.479509",
"doctype": "DocType", "doctype": "DocType",
@@ -17,10 +18,11 @@
} }
], ],
"links": [], "links": [],
"modified": "2024-08-16 19:24:55.811760", "modified": "2025-12-17 12:09:34.687368",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "CRM", "module": "CRM",
"name": "Market Segment", "name": "Market Segment",
"naming_rule": "By fieldname",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {
@@ -37,9 +39,10 @@
} }
], ],
"quick_entry": 1, "quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"track_changes": 1, "track_changes": 1,
"translated_doctype": 1 "translated_doctype": 1
} }

View File

@@ -419,7 +419,9 @@ scheduler_events = {
"0/15 * * * *": [ "0/15 * * * *": [
"erpnext.manufacturing.doctype.bom_update_log.bom_update_log.resume_bom_cost_update_jobs", "erpnext.manufacturing.doctype.bom_update_log.bom_update_log.resume_bom_cost_update_jobs",
], ],
"0/30 * * * *": [], "0/30 * * * *": [
"erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.run_parallel_reposting",
],
# Hourly but offset by 30 minutes # Hourly but offset by 30 minutes
"30 * * * *": [ "30 * * * *": [
"erpnext.accounts.doctype.gl_entry.gl_entry.rename_gle_sle_docs", "erpnext.accounts.doctype.gl_entry.gl_entry.rename_gle_sle_docs",

View File

@@ -32,10 +32,9 @@ class TestJobCard(ERPNextTestSuite):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super().setUpClass() super().setUpClass()
# used in job card time log
cls.make_employees()
def setUp(self): def setUp(self):
self.make_employees() # used in job card time log
self.make_bom_for_jc_tests() self.make_bom_for_jc_tests()
self.transfer_material_against: Literal["Work Order", "Job Card"] = "Work Order" self.transfer_material_against: Literal["Work Order", "Job Card"] = "Work Order"
self.source_warehouse = None self.source_warehouse = None
@@ -128,7 +127,7 @@ class TestJobCard(ERPNextTestSuite):
jc1 = frappe.get_last_doc("Job Card", {"work_order": self.work_order.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}) jc2 = frappe.get_last_doc("Job Card", {"work_order": wo2.name})
employee = "_T-Employee-00001" # from test records employee = self.employees[0].name
jc1.append( jc1.append(
"time_logs", "time_logs",

View File

@@ -123,6 +123,13 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
frappe.datetime.get_diff(doc.valid_till, frappe.datetime.get_today()) >= 0) frappe.datetime.get_diff(doc.valid_till, frappe.datetime.get_today()) >= 0)
) { ) {
this.frm.add_custom_button(__("Sales Order"), () => this.make_sales_order(), __("Create")); this.frm.add_custom_button(__("Sales Order"), () => this.make_sales_order(), __("Create"));
this.frm.add_custom_button(__("Update Items"), () => {
erpnext.utils.update_child_items({
frm: this.frm,
child_docname: "items",
cannot_add_row: false,
});
});
} }
if (doc.status !== "Ordered" && this.frm.has_perm("write")) { if (doc.status !== "Ordered" && this.frm.has_perm("write")) {

View File

@@ -613,6 +613,7 @@ def handle_mandatory_error(e, customer, lead_name):
frappe.throw(message, title=_("Mandatory Missing")) frappe.throw(message, title=_("Mandatory Missing"))
@frappe.whitelist()
def get_ordered_items(quotation: str): def get_ordered_items(quotation: str):
""" """
Returns a dict of ordered items with their total qty based on quotation row name. Returns a dict of ordered items with their total qty based on quotation row name.

View File

@@ -1,17 +1,119 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt # License: GNU General Public License v3. See license.txt
import json
import frappe import frappe
from frappe.tests import IntegrationTestCase, change_settings from frappe.tests import IntegrationTestCase, change_settings
from frappe.utils import add_days, add_months, flt, getdate, nowdate from frappe.utils import add_days, add_months, flt, getdate, nowdate
from erpnext.controllers.accounts_controller import InvalidQtyError from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate
from erpnext.selling.doctype.quotation.quotation import make_sales_order
from erpnext.setup.utils import get_exchange_rate from erpnext.setup.utils import get_exchange_rate
EXTRA_TEST_RECORD_DEPENDENCIES = ["Product Bundle"] EXTRA_TEST_RECORD_DEPENDENCIES = ["Product Bundle"]
class TestQuotation(IntegrationTestCase): class TestQuotation(IntegrationTestCase):
def test_update_child_quotation_add_item(self):
from erpnext.stock.doctype.item.test_item import make_item
item_1 = make_item("_Test Item")
item_2 = make_item("_Test Item 1")
item_list = [
{"item_code": item_1.item_code, "warehouse": "", "qty": 10, "rate": 300},
{"item_code": item_2.item_code, "warehouse": "", "qty": 5, "rate": 400},
]
qo = make_quotation(item_list=item_list)
first_item = qo.get("items")[0]
second_item = qo.get("items")[1]
trans_item = json.dumps(
[
{
"item_code": first_item.item_code,
"rate": first_item.rate,
"qty": 11,
"docname": first_item.name,
},
{
"item_code": second_item.item_code,
"rate": second_item.rate,
"qty": second_item.qty,
"docname": second_item.name,
},
{"item_code": "_Test Item 2", "rate": 100, "qty": 7},
]
)
update_child_qty_rate("Quotation", trans_item, qo.name)
qo.reload()
self.assertEqual(qo.get("items")[0].qty, 11)
self.assertEqual(qo.get("items")[-1].rate, 100)
def test_disallow_due_date_before_transaction_date(self):
qo = make_quotation(qty=3, do_not_submit=1)
qo.payment_schedule[0].due_date = add_days(qo.transaction_date, -2)
self.assertRaises(frappe.ValidationError, qo.save)
def test_update_child_disallow_rate_change(self):
qo = make_quotation(qty=4)
trans_item = json.dumps(
[
{
"item_code": qo.items[0].item_code,
"rate": 5000,
"qty": qo.items[0].qty,
"docname": qo.items[0].name,
}
]
)
self.assertRaises(frappe.ValidationError, update_child_qty_rate, "Quotation", trans_item, qo.name)
def test_update_child_removing_item(self):
qo = make_quotation(qty=10)
sales_order = make_sales_order(qo.name)
sales_order.delivery_date = nowdate()
trans_item = json.dumps(
[
{
"item_code": qo.items[0].item_code,
"rate": qo.items[0].rate,
"qty": qo.items[0].qty,
"docname": qo.items[0].name,
},
{"item_code": "_Test Item 2", "rate": 100, "qty": 7},
]
)
update_child_qty_rate("Quotation", trans_item, qo.name)
sales_order.submit()
qo.reload()
self.assertEqual(qo.status, "Partially Ordered")
trans_item = json.dumps([{"item_code": "_Test Item 2", "rate": 100, "qty": 7}])
# check if items having a sales order can be removed
self.assertRaises(frappe.ValidationError, update_child_qty_rate, "Quotation", trans_item, qo.name)
trans_item = json.dumps(
[
{
"item_code": qo.items[0].item_code,
"rate": qo.items[0].rate,
"qty": qo.items[0].qty,
"docname": qo.items[0].name,
}
]
)
# remove item with no sales order
update_child_qty_rate("Quotation", trans_item, qo.name)
qo.reload()
self.assertEqual(len(qo.get("items")), 1)
def test_quotation_qty(self): def test_quotation_qty(self):
qo = make_quotation(qty=0, do_not_save=True) qo = make_quotation(qty=0, do_not_save=True)
with self.assertRaises(InvalidQtyError): with self.assertRaises(InvalidQtyError):

View File

@@ -94,7 +94,7 @@
"options": "No\nYes" "options": "No\nYes"
}, },
{ {
"default": "Each Transaction", "default": "Daily",
"description": "How often should Project and Company be updated based on Sales Transactions?", "description": "How often should Project and Company be updated based on Sales Transactions?",
"fieldname": "sales_update_frequency", "fieldname": "sales_update_frequency",
"fieldtype": "Select", "fieldtype": "Select",
@@ -296,7 +296,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2025-10-12 16:08:48.865885", "modified": "2025-12-17 16:08:48.865885",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Selling Settings", "name": "Selling Settings",

View File

@@ -166,7 +166,9 @@ class Batch(Document):
for row in batches: for row in batches:
batch_qty += row.get("qty") batch_qty += row.get("qty")
self.db_set("batch_qty", batch_qty) if self.batch_qty != batch_qty:
self.db_set("batch_qty", batch_qty)
frappe.msgprint(_("Batch Qty updated to {0}").format(batch_qty), alert=True) frappe.msgprint(_("Batch Qty updated to {0}").format(batch_qty), alert=True)
def set_batchwise_valuation(self): def set_batchwise_valuation(self):

View File

@@ -9,7 +9,7 @@ from frappe.desk.form.load import get_attachments
from frappe.exceptions import QueryDeadlockError, QueryTimeoutError from frappe.exceptions import QueryDeadlockError, QueryTimeoutError
from frappe.model.document import Document from frappe.model.document import Document
from frappe.query_builder import DocType, Interval from frappe.query_builder import DocType, Interval
from frappe.query_builder.functions import Max, Now from frappe.query_builder.functions import CombineDatetime, Max, Now
from frappe.utils import cint, get_link_to_form, get_weekday, getdate, now, nowtime from frappe.utils import cint, get_link_to_form, get_weekday, getdate, now, nowtime
from frappe.utils.user import get_users_with_role from frappe.utils.user import get_users_with_role
from rq.timeouts import JobTimeoutException from rq.timeouts import JobTimeoutException
@@ -539,41 +539,105 @@ def get_recipients():
return recipients return recipients
def run_parallel_reposting():
# This function is called every 15 minutes via hooks.py
if not frappe.db.get_single_value("Stock Reposting Settings", "enable_parallel_reposting"):
return
if not in_configured_timeslot():
return
items = set()
no_of_parallel_reposting = (
frappe.db.get_single_value("Stock Reposting Settings", "no_of_parallel_reposting") or 4
)
riv_entries = get_repost_item_valuation_entries("Item and Warehouse")
for row in riv_entries:
if row.repost_only_accounting_ledgers:
execute_reposting_entry(row.name)
continue
if frappe.db.get_value(
"Repost Item Valuation",
{
"based_on": "Item and Warehouse",
"item_code": row.item_code,
"docstatus": 1,
"status": "In Progress",
},
"name",
):
continue
if row.item_code in items:
continue
items.add(row.item_code)
if len(items) > no_of_parallel_reposting:
break
frappe.enqueue(
execute_reposting_entry,
name=row.name,
queue="long",
timeout=1800,
)
def repost_entries(): def repost_entries():
""" # This function is called every hour via hooks.py
Reposts 'Repost Item Valuation' entries in queue.
Called hourly via hooks.py. if frappe.db.get_single_value("Stock Reposting Settings", "enable_parallel_reposting"):
""" return
if not in_configured_timeslot(): if not in_configured_timeslot():
return return
riv_entries = get_repost_item_valuation_entries() riv_entries = get_repost_item_valuation_entries()
for row in riv_entries: for row in riv_entries:
doc = frappe.get_doc("Repost Item Valuation", row.name) execute_reposting_entry(row.name)
if (
doc.repost_only_accounting_ledgers
and doc.reposting_reference
and frappe.db.get_value("Repost Item Valuation", doc.reposting_reference, "status")
not in ["Completed", "Skipped"]
):
continue
if doc.status in ("Queued", "In Progress"):
repost(doc)
doc.deduplicate_similar_repost()
def get_repost_item_valuation_entries(): def execute_reposting_entry(name):
return frappe.db.sql( doc = frappe.get_doc("Repost Item Valuation", name)
""" SELECT name from `tabRepost Item Valuation` if (
WHERE status in ('Queued', 'In Progress') and creation <= %s and docstatus = 1 doc.repost_only_accounting_ledgers
ORDER BY timestamp(posting_date, posting_time) asc, creation asc, status asc and doc.reposting_reference
""", and frappe.db.get_value("Repost Item Valuation", doc.reposting_reference, "status")
now(), not in ["Completed", "Skipped"]
as_dict=1, ):
return
if doc.status in ("Queued", "In Progress"):
repost(doc)
doc.deduplicate_similar_repost()
def get_repost_item_valuation_entries(based_on=None):
doctype = frappe.qb.DocType("Repost Item Valuation")
query = (
frappe.qb.from_(doctype)
.select(doctype.name, doctype.based_on, doctype.item_code, doctype.repost_only_accounting_ledgers)
.where(
(doctype.status.isin(["Queued", "In Progress"]))
& (doctype.creation <= now())
& (doctype.docstatus == 1)
)
.orderby(CombineDatetime(doctype.posting_date, doctype.posting_time), order=frappe.qb.asc)
.orderby(doctype.creation, order=frappe.qb.asc)
.orderby(doctype.status, order=frappe.qb.asc)
) )
if based_on:
query = query.where((doctype.based_on == based_on) | (doctype.repost_only_accounting_ledgers == 1))
return query.run(as_dict=True)
def in_configured_timeslot(repost_settings=None, current_time=None): def in_configured_timeslot(repost_settings=None, current_time=None):
"""Check if current time is in configured timeslot for reposting.""" """Check if current time is in configured timeslot for reposting."""
@@ -601,9 +665,14 @@ def in_configured_timeslot(repost_settings=None, current_time=None):
@frappe.whitelist() @frappe.whitelist()
def execute_repost_item_valuation(): def execute_repost_item_valuation():
"""Execute repost item valuation via scheduler.""" """Execute repost item valuation via scheduler."""
method = "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries"
if frappe.db.get_single_value("Stock Reposting Settings", "enable_parallel_reposting"):
method = "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.run_parallel_reposting"
if name := frappe.db.get_value( if name := frappe.db.get_value(
"Scheduled Job Type", "Scheduled Job Type",
{"method": "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries"}, {"method": method},
"name", "name",
): ):
frappe.get_doc("Scheduled Job Type", name).enqueue(force=True) frappe.get_doc("Scheduled Job Type", name).enqueue(force=True)

View File

@@ -1221,32 +1221,24 @@ class StockReconciliation(StockController):
def get_batch_qty_for_stock_reco( def get_batch_qty_for_stock_reco(
item_code, warehouse, batch_no, posting_date, posting_time, voucher_no, sle_creation item_code, warehouse, batch_no, posting_date, posting_time, voucher_no, sle_creation
): ):
ledger = frappe.qb.DocType("Stock Ledger Entry")
posting_datetime = get_combine_datetime(posting_date, posting_time) posting_datetime = get_combine_datetime(posting_date, posting_time)
query = ( qty = (
frappe.qb.from_(ledger) get_batch_qty(
.select( batch_no,
Sum(ledger.actual_qty).as_("batch_qty"), warehouse,
item_code,
creation=sle_creation,
posting_datetime=posting_datetime,
ignore_voucher_nos=[voucher_no],
for_stock_levels=True,
consider_negative_batches=True,
do_not_check_future_batches=True,
) )
.where( or 0
(ledger.item_code == item_code)
& (ledger.warehouse == warehouse)
& (ledger.docstatus == 1)
& (ledger.is_cancelled == 0)
& (ledger.batch_no == batch_no)
& (ledger.voucher_no != voucher_no)
& (
(ledger.posting_datetime < posting_datetime)
| ((ledger.posting_datetime == posting_datetime) & (ledger.creation < sle_creation))
)
)
.groupby(ledger.batch_no)
) )
sle = query.run(as_dict=True) return flt(qty)
return flt(sle[0].batch_qty) if sle else 0
@frappe.whitelist() @frappe.whitelist()

View File

@@ -13,6 +13,8 @@
"end_time", "end_time",
"limits_dont_apply_on", "limits_dont_apply_on",
"item_based_reposting", "item_based_reposting",
"enable_parallel_reposting",
"no_of_parallel_reposting",
"errors_notification_section", "errors_notification_section",
"notify_reposting_error_to_role" "notify_reposting_error_to_role"
], ],
@@ -65,12 +67,25 @@
"fieldname": "errors_notification_section", "fieldname": "errors_notification_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Errors Notification" "label": "Errors Notification"
},
{
"default": "0",
"depends_on": "eval: doc.item_based_reposting",
"fieldname": "enable_parallel_reposting",
"fieldtype": "Check",
"label": "Enable Parallel Reposting"
},
{
"default": "4",
"fieldname": "no_of_parallel_reposting",
"fieldtype": "Int",
"label": "No of Parallel Reposting (Per Item)"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2025-07-08 11:27:46.659056", "modified": "2025-12-10 17:45:56.597514",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Reposting Settings", "name": "Stock Reposting Settings",

View File

@@ -16,12 +16,14 @@ class StockRepostingSettings(Document):
if TYPE_CHECKING: if TYPE_CHECKING:
from frappe.types import DF from frappe.types import DF
enable_parallel_reposting: DF.Check
end_time: DF.Time | None end_time: DF.Time | None
item_based_reposting: DF.Check item_based_reposting: DF.Check
limit_reposting_timeslot: DF.Check limit_reposting_timeslot: DF.Check
limits_dont_apply_on: DF.Literal[ limits_dont_apply_on: DF.Literal[
"", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" "", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"
] ]
no_of_parallel_reposting: DF.Int
notify_reposting_error_to_role: DF.Link | None notify_reposting_error_to_role: DF.Link | None
start_time: DF.Time | None start_time: DF.Time | None
# end: auto-generated types # end: auto-generated types
@@ -29,6 +31,16 @@ class StockRepostingSettings(Document):
def validate(self): def validate(self):
self.set_minimum_reposting_time_slot() self.set_minimum_reposting_time_slot()
def before_save(self):
self.reset_parallel_reposting_settings()
def reset_parallel_reposting_settings(self):
if not self.item_based_reposting and self.enable_parallel_reposting:
self.enable_parallel_reposting = 0
if self.enable_parallel_reposting and not self.no_of_parallel_reposting:
self.no_of_parallel_reposting = 4
def set_minimum_reposting_time_slot(self): def set_minimum_reposting_time_slot(self):
"""Ensure that timeslot for reposting is at least 12 hours.""" """Ensure that timeslot for reposting is at least 12 hours."""
if not self.limit_reposting_timeslot: if not self.limit_reposting_timeslot:

View File

@@ -219,6 +219,7 @@ class TestIssue(TestSetUp):
frappe.flags.current_time = get_datetime("2021-11-01 19:00") frappe.flags.current_time = get_datetime("2021-11-01 19:00")
issue = make_issue(frappe.flags.current_time, index=1) issue = make_issue(frappe.flags.current_time, index=1)
create_user("test@admin.com")
create_communication(issue.name, "test@example.com", "Received", frappe.flags.current_time) create_communication(issue.name, "test@example.com", "Received", frappe.flags.current_time)
add_assignment({"doctype": issue.doctype, "name": issue.name, "assign_to": ["test@admin.com"]}) add_assignment({"doctype": issue.doctype, "name": issue.name, "assign_to": ["test@admin.com"]})
issue.reload() issue.reload()