mirror of
https://github.com/frappe/erpnext.git
synced 2026-02-16 16:15:02 +00:00
Merge branch 'gh50060' of https://github.com/mihir-kandoi/erpnext into gh50060
This commit is contained in:
@@ -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'"
|
||||
);
|
||||
d.row_id = "";
|
||||
} else if (
|
||||
(d.charge_type == "On Previous Row Amount" || d.charge_type == "On Previous Row Total") &&
|
||||
d.row_id
|
||||
) {
|
||||
} else if (d.charge_type == "On Previous Row Amount" || d.charge_type == "On Previous Row Total") {
|
||||
if (d.idx == 1) {
|
||||
msg = __(
|
||||
"Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row"
|
||||
);
|
||||
d.charge_type = "";
|
||||
} 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.row_id = d.idx - 1;
|
||||
} else if (d.row_id && d.row_id >= d.idx) {
|
||||
msg = __(
|
||||
"Cannot refer row number greater than or equal to current row number for this Charge type"
|
||||
|
||||
@@ -427,6 +427,7 @@ class PaymentRequest(Document):
|
||||
context = {
|
||||
"doc": frappe.get_doc(self.reference_doctype, self.reference_name),
|
||||
"payment_url": self.payment_url,
|
||||
"payment_request": self,
|
||||
}
|
||||
|
||||
if self.message:
|
||||
@@ -892,22 +893,25 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False):
|
||||
|
||||
|
||||
def get_dummy_message(doc):
|
||||
return frappe.render_template(
|
||||
"""{% if doc.contact_person -%}
|
||||
<p>Dear {{ doc.contact_person }},</p>
|
||||
{%- else %}<p>Hello,</p>{% endif %}
|
||||
return """
|
||||
{% if doc.contact_person -%}
|
||||
<p>Dear {{ doc.contact_person }},</p>
|
||||
{%- else %}<p>Hello,</p>{% endif %}
|
||||
|
||||
<p>{{ _("Requesting payment against {0} {1} for amount {2}").format(doc.doctype,
|
||||
doc.name, doc.get_formatted("grand_total")) }}</p>
|
||||
<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>
|
||||
""",
|
||||
dict(doc=doc, payment_url="{{ payment_url }}"),
|
||||
)
|
||||
<p>{{ _("Thank you for your business!") }}</p>
|
||||
"""
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -270,8 +270,14 @@ frappe.ui.form.on("Asset", {
|
||||
const row = [
|
||||
sch["idx"],
|
||||
frappe.format(sch["schedule_date"], { fieldtype: "Date" }),
|
||||
frappe.format(sch["depreciation_amount"], { fieldtype: "Currency" }),
|
||||
frappe.format(sch["accumulated_depreciation_amount"], { fieldtype: "Currency" }),
|
||||
frappe.format(sch["depreciation_amount"], {
|
||||
fieldtype: "Currency",
|
||||
options: "Company:company:default_currency",
|
||||
}),
|
||||
frappe.format(sch["accumulated_depreciation_amount"], {
|
||||
fieldtype: "Currency",
|
||||
options: "Company:company:default_currency",
|
||||
}),
|
||||
sch["journal_entry"] || "",
|
||||
];
|
||||
|
||||
|
||||
@@ -32,6 +32,13 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e
|
||||
this.make_purchase_order.bind(this),
|
||||
__("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.add_custom_button(__("Quotation"), this.make_quotation.bind(this), __("Create"));
|
||||
} else if (this.frm.doc.docstatus === 0) {
|
||||
|
||||
@@ -348,3 +348,15 @@ def set_expired_status():
|
||||
""",
|
||||
(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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,15 +2,115 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe.tests import IntegrationTestCase, change_settings
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
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):
|
||||
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):
|
||||
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
|
||||
sq.items[0].qty = 0
|
||||
|
||||
@@ -2707,7 +2707,9 @@ class AccountsController(TransactionBase):
|
||||
|
||||
for d in self.get("payment_schedule"):
|
||||
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(
|
||||
_("Row {0}: Due Date in the Payment Terms table cannot be before Posting Date").format(
|
||||
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"))
|
||||
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
|
||||
child_item.base_rate = 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
|
||||
|
||||
|
||||
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."""
|
||||
if parent.doctype == "Sales Order":
|
||||
if flt(row.delivered_qty):
|
||||
@@ -3753,13 +3755,17 @@ def validate_child_on_delete(row, parent):
|
||||
row.idx, row.item_code
|
||||
)
|
||||
)
|
||||
|
||||
if flt(row.billed_amt):
|
||||
frappe.throw(
|
||||
_("Row #{0}: Cannot delete item {1} which has already been billed.").format(
|
||||
row.idx, row.item_code
|
||||
if parent.doctype in ["Purchase Order", "Sales Order"]:
|
||||
if flt(row.billed_amt):
|
||||
frappe.throw(
|
||||
_("Row #{0}: Cannot delete item {1} which has already been billed.").format(
|
||||
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):
|
||||
@@ -3785,7 +3791,7 @@ def update_bin_on_delete(row, doctype):
|
||||
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 = []
|
||||
updated_item_names = [d.get("docname") for d in data]
|
||||
for item in parent.items:
|
||||
@@ -3793,7 +3799,7 @@ def validate_and_delete_children(parent, data) -> bool:
|
||||
deleted_children.append(item)
|
||||
|
||||
for d in deleted_children:
|
||||
validate_child_on_delete(d, parent)
|
||||
validate_child_on_delete(d, parent, ordered_item)
|
||||
d.cancel()
|
||||
d.delete()
|
||||
|
||||
@@ -3802,16 +3808,19 @@ def validate_and_delete_children(parent, data) -> bool:
|
||||
|
||||
# need to update ordered qty in Material Request first
|
||||
# bin uses Material Request Items to recalculate & update
|
||||
parent.update_prevdoc_status()
|
||||
|
||||
for d in deleted_children:
|
||||
update_bin_on_delete(d, parent.doctype)
|
||||
if parent.doctype not in ["Quotation", "Supplier Quotation"]:
|
||||
parent.update_prevdoc_status()
|
||||
for d in deleted_children:
|
||||
update_bin_on_delete(d, parent.doctype)
|
||||
|
||||
return bool(deleted_children)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
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"):
|
||||
try:
|
||||
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):
|
||||
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)
|
||||
|
||||
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):
|
||||
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:
|
||||
"""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"]))
|
||||
|
||||
data = json.loads(trans_items)
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
any_conversion_factor_changed |= not conversion_factor_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
|
||||
if (
|
||||
rate_unchanged
|
||||
@@ -3978,6 +4012,10 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
continue
|
||||
|
||||
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")):
|
||||
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
|
||||
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"))))
|
||||
|
||||
# Amount cannot be lesser than billed amount, except for negative amounts
|
||||
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 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)
|
||||
|
||||
if parent_doctype in ["Purchase Order", "Sales Order"]:
|
||||
amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt(
|
||||
row_rate * flt(d.get("qty"), qty_precision), rate_precision
|
||||
)
|
||||
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:
|
||||
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":
|
||||
child_item.bom_no = d.get("bom_no")
|
||||
|
||||
if flt(child_item.price_list_rate):
|
||||
if flt(child_item.rate) > flt(child_item.price_list_rate):
|
||||
# if rate is greater than price_list_rate, set margin
|
||||
# or set discount
|
||||
child_item.discount_percentage = 0
|
||||
child_item.margin_type = "Amount"
|
||||
child_item.margin_rate_or_amount = flt(
|
||||
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.discount_percentage = flt(
|
||||
(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.margin_rate_or_amount = 0
|
||||
child_item.rate_with_margin = 0
|
||||
if parent_doctype in ["Sales Order", "Purchase Order"]:
|
||||
if flt(child_item.price_list_rate):
|
||||
if flt(child_item.rate) > flt(child_item.price_list_rate):
|
||||
# if rate is greater than price_list_rate, set margin
|
||||
# or set discount
|
||||
child_item.discount_percentage = 0
|
||||
child_item.margin_type = "Amount"
|
||||
child_item.margin_rate_or_amount = flt(
|
||||
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.discount_percentage = flt(
|
||||
(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.margin_rate_or_amount = 0
|
||||
child_item.rate_with_margin = 0
|
||||
|
||||
child_item.flags.ignore_validate_update_after_submit = True
|
||||
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.set_payment_schedule()
|
||||
if parent_doctype != "Supplier Quotation":
|
||||
parent.set_payment_schedule()
|
||||
if parent_doctype == "Purchase Order":
|
||||
parent.validate_minimum_order_qty()
|
||||
parent.validate_budget()
|
||||
if parent.is_against_so():
|
||||
parent.update_status_updater()
|
||||
else:
|
||||
elif parent_doctype == "Sales Order":
|
||||
parent.check_credit_limit()
|
||||
|
||||
# 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}."
|
||||
).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():
|
||||
frappe.throw(
|
||||
_(
|
||||
@@ -4138,9 +4181,10 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
parent.reload()
|
||||
validate_workflow_conditions(parent)
|
||||
|
||||
parent.update_blanket_order()
|
||||
parent.update_billing_percentage()
|
||||
parent.set_status()
|
||||
if parent_doctype in ["Purchase Order", "Sales Order"]:
|
||||
parent.update_blanket_order()
|
||||
parent.update_billing_percentage()
|
||||
parent.set_status()
|
||||
|
||||
parent.validate_uom_is_integer("uom", "qty")
|
||||
parent.validate_uom_is_integer("stock_uom", "stock_qty")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:market_segment",
|
||||
"creation": "2018-10-01 09:59:14.479509",
|
||||
"doctype": "DocType",
|
||||
@@ -17,10 +18,11 @@
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2024-08-16 19:24:55.811760",
|
||||
"modified": "2025-12-17 12:09:34.687368",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "Market Segment",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@@ -37,9 +39,10 @@
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1,
|
||||
"translated_doctype": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,7 +419,9 @@ scheduler_events = {
|
||||
"0/15 * * * *": [
|
||||
"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
|
||||
"30 * * * *": [
|
||||
"erpnext.accounts.doctype.gl_entry.gl_entry.rename_gle_sle_docs",
|
||||
|
||||
@@ -32,10 +32,9 @@ class TestJobCard(ERPNextTestSuite):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
# used in job card time log
|
||||
cls.make_employees()
|
||||
|
||||
def setUp(self):
|
||||
self.make_employees() # used in job card time log
|
||||
self.make_bom_for_jc_tests()
|
||||
self.transfer_material_against: Literal["Work Order", "Job Card"] = "Work Order"
|
||||
self.source_warehouse = None
|
||||
@@ -128,7 +127,7 @@ class TestJobCard(ERPNextTestSuite):
|
||||
jc1 = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
|
||||
jc2 = frappe.get_last_doc("Job Card", {"work_order": wo2.name})
|
||||
|
||||
employee = "_T-Employee-00001" # from test records
|
||||
employee = self.employees[0].name
|
||||
|
||||
jc1.append(
|
||||
"time_logs",
|
||||
|
||||
@@ -123,6 +123,13 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
|
||||
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(__("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")) {
|
||||
|
||||
@@ -613,6 +613,7 @@ def handle_mandatory_error(e, customer, lead_name):
|
||||
frappe.throw(message, title=_("Mandatory Missing"))
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_ordered_items(quotation: str):
|
||||
"""
|
||||
Returns a dict of ordered items with their total qty based on quotation row name.
|
||||
|
||||
@@ -1,17 +1,119 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe.tests import IntegrationTestCase, change_settings
|
||||
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
|
||||
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = ["Product Bundle"]
|
||||
|
||||
|
||||
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):
|
||||
qo = make_quotation(qty=0, do_not_save=True)
|
||||
with self.assertRaises(InvalidQtyError):
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
"options": "No\nYes"
|
||||
},
|
||||
{
|
||||
"default": "Each Transaction",
|
||||
"default": "Daily",
|
||||
"description": "How often should Project and Company be updated based on Sales Transactions?",
|
||||
"fieldname": "sales_update_frequency",
|
||||
"fieldtype": "Select",
|
||||
@@ -296,7 +296,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-12 16:08:48.865885",
|
||||
"modified": "2025-12-17 16:08:48.865885",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Selling Settings",
|
||||
|
||||
@@ -166,7 +166,9 @@ class Batch(Document):
|
||||
for row in batches:
|
||||
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)
|
||||
|
||||
def set_batchwise_valuation(self):
|
||||
|
||||
@@ -9,7 +9,7 @@ from frappe.desk.form.load import get_attachments
|
||||
from frappe.exceptions import QueryDeadlockError, QueryTimeoutError
|
||||
from frappe.model.document import Document
|
||||
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.user import get_users_with_role
|
||||
from rq.timeouts import JobTimeoutException
|
||||
@@ -539,41 +539,105 @@ def get_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():
|
||||
"""
|
||||
Reposts 'Repost Item Valuation' entries in queue.
|
||||
Called hourly via hooks.py.
|
||||
"""
|
||||
# This function is called every hour via hooks.py
|
||||
|
||||
if frappe.db.get_single_value("Stock Reposting Settings", "enable_parallel_reposting"):
|
||||
return
|
||||
|
||||
if not in_configured_timeslot():
|
||||
return
|
||||
|
||||
riv_entries = get_repost_item_valuation_entries()
|
||||
|
||||
for row in riv_entries:
|
||||
doc = frappe.get_doc("Repost Item Valuation", 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()
|
||||
execute_reposting_entry(row.name)
|
||||
|
||||
|
||||
def get_repost_item_valuation_entries():
|
||||
return frappe.db.sql(
|
||||
""" SELECT name from `tabRepost Item Valuation`
|
||||
WHERE status in ('Queued', 'In Progress') and creation <= %s and docstatus = 1
|
||||
ORDER BY timestamp(posting_date, posting_time) asc, creation asc, status asc
|
||||
""",
|
||||
now(),
|
||||
as_dict=1,
|
||||
def execute_reposting_entry(name):
|
||||
doc = frappe.get_doc("Repost Item Valuation", 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"]
|
||||
):
|
||||
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):
|
||||
"""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()
|
||||
def execute_repost_item_valuation():
|
||||
"""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(
|
||||
"Scheduled Job Type",
|
||||
{"method": "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries"},
|
||||
{"method": method},
|
||||
"name",
|
||||
):
|
||||
frappe.get_doc("Scheduled Job Type", name).enqueue(force=True)
|
||||
|
||||
@@ -1221,32 +1221,24 @@ class StockReconciliation(StockController):
|
||||
def get_batch_qty_for_stock_reco(
|
||||
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)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(ledger)
|
||||
.select(
|
||||
Sum(ledger.actual_qty).as_("batch_qty"),
|
||||
qty = (
|
||||
get_batch_qty(
|
||||
batch_no,
|
||||
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(
|
||||
(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)
|
||||
or 0
|
||||
)
|
||||
|
||||
sle = query.run(as_dict=True)
|
||||
|
||||
return flt(sle[0].batch_qty) if sle else 0
|
||||
return flt(qty)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"end_time",
|
||||
"limits_dont_apply_on",
|
||||
"item_based_reposting",
|
||||
"enable_parallel_reposting",
|
||||
"no_of_parallel_reposting",
|
||||
"errors_notification_section",
|
||||
"notify_reposting_error_to_role"
|
||||
],
|
||||
@@ -65,12 +67,25 @@
|
||||
"fieldname": "errors_notification_section",
|
||||
"fieldtype": "Section Break",
|
||||
"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,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-08 11:27:46.659056",
|
||||
"modified": "2025-12-10 17:45:56.597514",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Reposting Settings",
|
||||
|
||||
@@ -16,12 +16,14 @@ class StockRepostingSettings(Document):
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
enable_parallel_reposting: DF.Check
|
||||
end_time: DF.Time | None
|
||||
item_based_reposting: DF.Check
|
||||
limit_reposting_timeslot: DF.Check
|
||||
limits_dont_apply_on: DF.Literal[
|
||||
"", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"
|
||||
]
|
||||
no_of_parallel_reposting: DF.Int
|
||||
notify_reposting_error_to_role: DF.Link | None
|
||||
start_time: DF.Time | None
|
||||
# end: auto-generated types
|
||||
@@ -29,6 +31,16 @@ class StockRepostingSettings(Document):
|
||||
def validate(self):
|
||||
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):
|
||||
"""Ensure that timeslot for reposting is at least 12 hours."""
|
||||
if not self.limit_reposting_timeslot:
|
||||
|
||||
@@ -219,6 +219,7 @@ class TestIssue(TestSetUp):
|
||||
frappe.flags.current_time = get_datetime("2021-11-01 19:00")
|
||||
|
||||
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)
|
||||
add_assignment({"doctype": issue.doctype, "name": issue.name, "assign_to": ["test@admin.com"]})
|
||||
issue.reload()
|
||||
|
||||
Reference in New Issue
Block a user