Merge pull request #47758 from frappe/version-15-hotfix

chore: release v15
This commit is contained in:
ruthra kumar
2025-05-27 20:24:59 +05:30
committed by GitHub
55 changed files with 810 additions and 91 deletions

View File

@@ -273,6 +273,8 @@ class POSInvoice(SalesInvoice):
against_psi_doc.delete_loyalty_point_entry()
against_psi_doc.make_loyalty_point_entry()
self.db_set("status", "Cancelled")
if self.coupon_code:
from erpnext.accounts.doctype.pricing_rule.utils import update_coupon_code_count

View File

@@ -129,8 +129,8 @@ def get_statement_dict(doc, get_statement_dict=False):
tax_id = frappe.get_doc("Customer", entry.customer).tax_id
presentation_currency = (
get_party_account_currency("Customer", entry.customer, doc.company)
or doc.currency
doc.currency
or get_party_account_currency("Customer", entry.customer, doc.company)
or get_company_currency(doc.company)
)

View File

@@ -97,6 +97,7 @@ def create_process_soa(**args):
company=args.company or "_Test Company",
customers=args.customers or [{"customer": "_Test Customer"}],
enable_auto_email=1 if args.enable_auto_email else 0,
currency=args.currency or "",
frequency=args.frequency or "Weekly",
report=args.report or "General Ledger",
from_date=args.from_date or getdate(today()),

View File

@@ -425,6 +425,8 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
this.frm.set_value("is_paid", 0);
frappe.msgprint(__("Please specify Company to proceed"));
}
} else {
this.frm.set_value("paid_amount", 0);
}
this.calculate_outstanding_amount();
this.frm.refresh_fields();

View File

@@ -671,6 +671,7 @@ def get_tcs_amount(parties, inv, tax_details, vouchers, adv_vouchers):
conditions.append(ple.party.isin(parties))
conditions.append(ple.voucher_no == ple.against_voucher_no)
conditions.append(ple.company == inv.company)
conditions.append(ple.posting_date[tax_details.from_date : tax_details.to_date])
advance_amt = (
qb.from_(ple).select(Abs(Sum(ple.amount))).where(Criterion.all(conditions)).run()[0][0] or 0.0

View File

@@ -288,17 +288,18 @@ class TestTaxWithholdingCategory(FrappeTestCase):
frappe.db.set_value(
"Customer", "Test TCS Customer", "tax_withholding_category", "Cumulative Threshold TCS"
)
fiscal_year = get_fiscal_year(today(), company="_Test Company")
vouchers = []
# create advance payment
pe = create_payment_entry(
pe1 = create_payment_entry(
payment_type="Receive", party_type="Customer", party="Test TCS Customer", paid_amount=20000
)
pe.paid_from = "Debtors - _TC"
pe.paid_to = "Cash - _TC"
pe.submit()
vouchers.append(pe)
pe1.paid_from = "Debtors - _TC"
pe1.paid_to = "Cash - _TC"
pe1.submit()
vouchers.append(pe1)
# create invoice
si1 = create_sales_invoice(customer="Test TCS Customer", rate=5000)
@@ -320,6 +321,17 @@ class TestTaxWithholdingCategory(FrappeTestCase):
# make another invoice
# sum of unallocated amount from payment entry and this sales invoice will breach cumulative threashold
# TDS should be calculated
# this payment should not be considered for TCS calculation as it is outside of fiscal year
pe2 = create_payment_entry(
payment_type="Receive", party_type="Customer", party="Test TCS Customer", paid_amount=10000
)
pe2.paid_from = "Debtors - _TC"
pe2.paid_to = "Cash - _TC"
pe2.posting_date = add_days(fiscal_year[1], -10)
pe2.submit()
vouchers.append(pe2)
si2 = create_sales_invoice(customer="Test TCS Customer", rate=15000)
si2.submit()
vouchers.append(si2)

View File

@@ -460,6 +460,12 @@ def get_party_account(party_type, party=None, company=None, include_advance=Fals
if (account and account_currency != existing_gle_currency) or not account:
account = get_party_gle_account(party_type, party, company)
# get default account on the basis of party type
if not account:
account_type = frappe.get_cached_value("Party Type", party_type, "account_type")
default_account_name = "default_" + account_type.lower() + "_account"
account = frappe.get_cached_value("Company", company, default_account_name)
if include_advance and party_type in ["Customer", "Supplier", "Student"]:
advance_account = get_party_advance_account(party_type, party, company)
if advance_account:

View File

@@ -8,6 +8,7 @@ from frappe.query_builder import Criterion, Tuple
from frappe.query_builder.functions import IfNull
from frappe.utils import getdate, nowdate
from frappe.utils.nestedset import get_descendants_of
from pypika.terms import LiteralValue
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
@@ -15,7 +16,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
)
TREE_DOCTYPES = frozenset(
["Customer Group", "Terrirtory", "Supplier Group", "Sales Partner", "Sales Person", "Cost Center"]
["Customer Group", "Territory", "Supplier Group", "Sales Partner", "Sales Person", "Cost Center"]
)
@@ -77,13 +78,12 @@ class PartyLedgerSummaryReport:
from frappe.desk.reportview import build_match_conditions
query, params = query.walk()
match_conditions = build_match_conditions(party_type)
if match_conditions:
query += "and" + match_conditions
query = query.where(LiteralValue(match_conditions))
party_details = frappe.db.sql(query, params, as_dict=True)
party_details = query.run(as_dict=True)
for row in party_details:
self.parties.append(row.party)
@@ -458,9 +458,16 @@ class PartyLedgerSummaryReport:
def get_children(doctype, value):
children = get_descendants_of(doctype, value)
if not isinstance(value, list):
value = [d.strip() for d in value.strip().split(",") if d]
return [value, *children]
all_children = []
for d in value:
all_children += get_descendants_of(doctype, value)
all_children.append(d)
return list(set(all_children))
def execute(filters=None):

View File

@@ -402,7 +402,7 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s
args: {
item_code: item.item_code,
warehouse: cstr(item.warehouse),
qty: flt(item.stock_qty),
qty: -1 * flt(item.stock_qty),
serial_no: item.serial_no,
posting_date: me.frm.doc.posting_date,
posting_time: me.frm.doc.posting_time,

View File

@@ -255,8 +255,10 @@ class AssetDepreciationSchedule(Document):
value_after_depreciation,
):
asset_doc.validate_asset_finance_books(row)
if not value_after_depreciation:
if (
not value_after_depreciation
and not asset_doc.flags.decrease_in_asset_value_due_to_value_adjustment
):
value_after_depreciation = _get_value_after_depreciation_for_making_schedule(asset_doc, row)
row.value_after_depreciation = value_after_depreciation
@@ -1068,8 +1070,6 @@ def make_new_active_asset_depr_schedules_and_cancel_current_ones(
)
new_asset_depr_schedule_doc = frappe.copy_doc(current_asset_depr_schedule_doc)
if asset_doc.flags.decrease_in_asset_value_due_to_value_adjustment and not value_after_depreciation:
value_after_depreciation = row.value_after_depreciation - difference_amount
if asset_doc.flags.increase_in_asset_value_due_to_repair and row.depreciation_method in (
"Written Down Value",

View File

@@ -5,7 +5,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import flt, formatdate, get_link_to_form, getdate
from frappe.utils import cstr, flt, formatdate, get_link_to_form, getdate
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_checks_for_pl_and_bs_accounts,
@@ -188,12 +188,21 @@ class AssetValueAdjustment(Document):
get_link_to_form(self.get("doctype"), self.get("name")),
)
difference_amount = self.difference_amount if self.docstatus == 1 else -1 * self.difference_amount
if asset.calculate_depreciation:
for row in asset.finance_books:
if cstr(row.finance_book) == cstr(self.finance_book):
row.value_after_depreciation += flt(difference_amount)
row.db_update()
asset.db_update()
make_new_active_asset_depr_schedules_and_cancel_current_ones(
asset,
notes,
value_after_depreciation=asset_value,
ignore_booked_entry=True,
difference_amount=self.difference_amount,
difference_amount=difference_amount,
)
asset.flags.ignore_validate_update_after_submit = True
asset.save()

View File

@@ -25,6 +25,9 @@
"disable_last_purchase_rate",
"show_pay_button",
"use_transaction_date_exchange_rate",
"allow_zero_qty_in_request_for_quotation",
"allow_zero_qty_in_supplier_quotation",
"allow_zero_qty_in_purchase_order",
"subcontract",
"backflush_raw_materials_of_subcontract_based_on",
"column_break_11",
@@ -207,14 +210,36 @@
"fieldtype": "Select",
"label": "Update frequency of Project",
"options": "Each Transaction\nManual"
},
{
"default": "0",
"description": "Allows users to submit Purchase Orders with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.",
"fieldname": "allow_zero_qty_in_purchase_order",
"fieldtype": "Check",
"label": "Allow Purchase Order with Zero Quantity"
},
{
"default": "0",
"description": "Allows users to submit Request for Quotations with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.",
"fieldname": "allow_zero_qty_in_request_for_quotation",
"fieldtype": "Check",
"label": "Allow Request for Quotation with Zero Quantity"
},
{
"default": "0",
"description": "Allows users to submit Supplier Quotations with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.",
"fieldname": "allow_zero_qty_in_supplier_quotation",
"fieldtype": "Check",
"label": "Allow Supplier Quotation with Zero Quantity"
}
],
"grid_page_length": 50,
"icon": "fa fa-cog",
"idx": 1,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2024-01-31 13:34:18.101256",
"modified": "2025-05-06 15:21:49.639642",
"modified_by": "Administrator",
"module": "Buying",
"name": "Buying Settings",

View File

@@ -18,6 +18,9 @@ class BuyingSettings(Document):
from frappe.types import DF
allow_multiple_items: DF.Check
allow_zero_qty_in_purchase_order: DF.Check
allow_zero_qty_in_request_for_quotation: DF.Check
allow_zero_qty_in_supplier_quotation: DF.Check
auto_create_purchase_receipt: DF.Check
auto_create_subcontracting_order: DF.Check
backflush_raw_materials_of_subcontract_based_on: DF.Literal[

View File

@@ -26,7 +26,15 @@ frappe.ui.form.on("Purchase Order", {
}
frm.set_indicator_formatter("item_code", function (doc) {
return doc.qty <= doc.received_qty ? "green" : "orange";
let color;
if (!doc.qty && frm.doc.has_unit_price_items) {
color = "yellow";
} else if (doc.qty <= doc.received_qty) {
color = "green";
} else {
color = "orange";
}
return color;
});
frm.set_query("expense_account", "items", function () {
@@ -63,6 +71,10 @@ frappe.ui.form.on("Purchase Order", {
}
});
}
if (frm.doc.docstatus == 0) {
erpnext.set_unit_price_items_note(frm);
}
},
supplier: function (frm) {

View File

@@ -24,6 +24,7 @@
"apply_tds",
"tax_withholding_category",
"is_subcontracted",
"has_unit_price_items",
"supplier_warehouse",
"amended_from",
"accounting_dimensions_section",
@@ -1285,6 +1286,14 @@
"label": "Dispatch Address Details",
"print_hide": 1,
"read_only": 1
},
{
"default": "0",
"fieldname": "has_unit_price_items",
"fieldtype": "Check",
"hidden": 1,
"label": "Has Unit Price Items",
"no_copy": 1
}
],
"grid_page_length": 50,

View File

@@ -97,6 +97,7 @@ class PurchaseOrder(BuyingController):
from_date: DF.Date | None
grand_total: DF.Currency
group_same_items: DF.Check
has_unit_price_items: DF.Check
ignore_pricing_rule: DF.Check
in_words: DF.Data | None
incoterm: DF.Link | None
@@ -191,6 +192,10 @@ class PurchaseOrder(BuyingController):
self.set_onload("supplier_tds", supplier_tds)
self.set_onload("can_update_items", self.can_update_items())
def before_validate(self):
self.set_has_unit_price_items()
self.flags.allow_zero_qty = self.has_unit_price_items
def validate(self):
super().validate()
@@ -223,6 +228,17 @@ class PurchaseOrder(BuyingController):
)
self.reset_default_field_value("set_warehouse", "items", "warehouse")
def set_has_unit_price_items(self):
"""
If permitted in settings and any item has 0 qty, the PO has unit price items.
"""
if not frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_purchase_order"):
return
self.has_unit_price_items = any(
not row.qty for row in self.get("items") if (row.item_code and not row.qty)
)
def validate_with_previous_doc(self):
mri_compare_fields = [["project", "="], ["item_code", "="]]
if self.is_subcontracted:
@@ -707,8 +723,13 @@ def set_missing_values(source, target):
@frappe.whitelist()
def make_purchase_receipt(source_name, target_doc=None):
has_unit_price_items = frappe.db.get_value("Purchase Order", source_name, "has_unit_price_items")
def is_unit_price_row(source):
return has_unit_price_items and source.qty == 0
def update_item(obj, target, source_parent):
target.qty = flt(obj.qty) - flt(obj.received_qty)
target.qty = flt(obj.qty) if is_unit_price_row(obj) else flt(obj.qty) - flt(obj.received_qty)
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor)
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
target.base_amount = (
@@ -739,7 +760,9 @@ def make_purchase_receipt(source_name, target_doc=None):
"wip_composite_asset": "wip_composite_asset",
},
"postprocess": update_item,
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty)
"condition": lambda doc: (
True if is_unit_price_row(doc) else abs(doc.received_qty) < abs(doc.qty)
)
and doc.delivered_by_supplier != 1,
},
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},

View File

@@ -52,6 +52,13 @@ class TestPurchaseOrder(FrappeTestCase):
po.save()
self.assertEqual(po.items[1].qty, 1)
def test_purchase_order_zero_qty(self):
po = create_purchase_order(qty=0, do_not_save=True)
with change_settings("Buying Settings", {"allow_zero_qty_in_purchase_order": 1}):
po.save()
self.assertEqual(po.items[0].qty, 0)
def test_make_purchase_receipt(self):
po = create_purchase_order(do_not_submit=True)
self.assertRaises(frappe.ValidationError, make_purchase_receipt, po.name)
@@ -801,8 +808,6 @@ class TestPurchaseOrder(FrappeTestCase):
po_doc.reload()
self.assertEqual(po_doc.advance_paid, 5000)
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
company_doc.book_advance_payments_in_separate_party_account = False
company_doc.save()
@@ -1207,6 +1212,80 @@ class TestPurchaseOrder(FrappeTestCase):
po.reload()
self.assertEqual(po.per_billed, 100)
@change_settings("Buying Settings", {"allow_zero_qty_in_purchase_order": 1})
def test_receive_zero_qty_purchase_order(self):
"""
Test the flow of a Unit Price PO and PR creation against it until completion.
Flow:
PO Qty 0 -> Receive +5 -> Receive +5 -> Update PO Qty +10 -> PO is 100% received
"""
po = create_purchase_order(qty=0)
pr = make_purchase_receipt(po.name)
self.assertEqual(pr.items[0].qty, 0)
pr.items[0].qty = 5
pr.submit()
po.reload()
self.assertEqual(po.items[0].received_qty, 5)
self.assertFalse(po.per_received)
self.assertEqual(po.status, "To Receive and Bill")
# Update PO Item Qty to 10 after receipt of items
first_item_of_po = po.items[0]
trans_item = json.dumps(
[
{
"item_code": first_item_of_po.item_code,
"rate": first_item_of_po.rate,
"qty": 10,
"docname": first_item_of_po.name,
}
]
)
update_child_qty_rate("Purchase Order", trans_item, po.name)
# Test: PR can be made against PO as long PO qty is 0 OR PO qty > received qty
pr2 = make_purchase_receipt(po.name)
po.reload()
self.assertEqual(po.items[0].qty, 10)
self.assertEqual(pr2.items[0].qty, 5)
pr2.submit()
# PO should be updated to 100% received
po.reload()
self.assertEqual(po.items[0].qty, 10)
self.assertEqual(po.items[0].received_qty, 10)
self.assertEqual(po.per_received, 100.0)
self.assertEqual(po.status, "To Bill")
@change_settings("Buying Settings", {"allow_zero_qty_in_purchase_order": 1})
def test_bill_zero_qty_purchase_order(self):
po = create_purchase_order(qty=0)
self.assertEqual(po.grand_total, 0)
self.assertFalse(po.per_billed)
self.assertEqual(po.items[0].qty, 0)
self.assertEqual(po.items[0].rate, 500)
pi = make_pi_from_po(po.name)
self.assertEqual(pi.items[0].qty, 0)
self.assertEqual(pi.items[0].rate, 500)
pi.items[0].qty = 5
pi.submit()
self.assertEqual(pi.grand_total, 2500)
po.reload()
self.assertEqual(po.items[0].amount, 0)
self.assertEqual(po.items[0].billed_amt, 2500)
# PO still has qty 0, so billed % should be unset
self.assertFalse(po.per_billed)
self.assertEqual(po.status, "To Receive and Bill")
def create_po_for_sc_testing():
from erpnext.controllers.tests.test_subcontracting_controller import (

View File

@@ -28,6 +28,10 @@ frappe.ui.form.on("Request for Quotation", {
is_group: 0,
},
}));
frm.set_indicator_formatter("item_code", function (doc) {
return !doc.qty && frm.doc.has_unit_price_items ? "yellow" : "";
});
},
onload: function (frm) {
@@ -163,6 +167,10 @@ frappe.ui.form.on("Request for Quotation", {
__("View")
);
}
if (frm.doc.docstatus === 0) {
erpnext.set_unit_price_items_note(frm);
}
},
show_supplier_quotation_comparison(frm) {

View File

@@ -16,6 +16,7 @@
"transaction_date",
"schedule_date",
"status",
"has_unit_price_items",
"amended_from",
"suppliers_section",
"suppliers",
@@ -306,13 +307,22 @@
"fieldtype": "Small Text",
"label": "Billing Address Details",
"read_only": 1
},
{
"default": "0",
"fieldname": "has_unit_price_items",
"fieldtype": "Check",
"hidden": 1,
"label": "Has Unit Price Items",
"no_copy": 1
}
],
"grid_page_length": 50,
"icon": "fa fa-shopping-cart",
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2023-11-06 12:45:28.898706",
"modified": "2025-03-03 16:48:39.856779",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation",
@@ -377,6 +387,7 @@
"role": "All"
}
],
"row_format": "Dynamic",
"search_fields": "status, transaction_date",
"show_name_in_global_search": 1,
"sort_field": "modified",

View File

@@ -42,6 +42,7 @@ class RequestforQuotation(BuyingController):
billing_address_display: DF.SmallText | None
company: DF.Link
email_template: DF.Link | None
has_unit_price_items: DF.Check
incoterm: DF.Link | None
items: DF.Table[RequestforQuotationItem]
letter_head: DF.Link | None
@@ -61,6 +62,10 @@ class RequestforQuotation(BuyingController):
vendor: DF.Link | None
# end: auto-generated types
def before_validate(self):
self.set_has_unit_price_items()
self.flags.allow_zero_qty = self.has_unit_price_items
def validate(self):
self.validate_duplicate_supplier()
self.validate_supplier_list()
@@ -73,6 +78,17 @@ class RequestforQuotation(BuyingController):
# after amend and save, status still shows as cancelled, until submit
self.db_set("status", "Draft")
def set_has_unit_price_items(self):
"""
If permitted in settings and any item has 0 qty, the RFQ has unit price items.
"""
if not frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_request_for_quotation"):
return
self.has_unit_price_items = any(
not row.qty for row in self.get("items") if (row.item_code and not row.qty)
)
def validate_duplicate_supplier(self):
supplier_list = [d.supplier for d in self.suppliers]
if len(supplier_list) != len(set(supplier_list)):
@@ -440,11 +456,10 @@ def create_supplier_quotation(doc):
def add_items(sq_doc, supplier, items):
for data in items:
if data.get("qty") > 0:
if isinstance(data, dict):
data = frappe._dict(data)
if isinstance(data, dict):
data = frappe._dict(data)
create_rfq_items(sq_doc, supplier, data)
create_rfq_items(sq_doc, supplier, data)
def create_rfq_items(sq_doc, supplier, data):

View File

@@ -5,7 +5,7 @@
from urllib.parse import urlparse
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import nowdate
from erpnext.buying.doctype.request_for_quotation.request_for_quotation import (
@@ -32,6 +32,16 @@ class TestRequestforQuotation(FrappeTestCase):
rfq.save()
self.assertEqual(rfq.items[0].qty, 1)
def test_rfq_zero_qty(self):
"""
Test if RFQ with zero qty (Unit Price Item) is conditionally allowed.
"""
rfq = make_request_for_quotation(qty=0, do_not_save=True)
with change_settings("Buying Settings", {"allow_zero_qty_in_request_for_quotation": 1}):
rfq.save()
self.assertEqual(rfq.items[0].qty, 0)
def test_quote_status(self):
rfq = make_request_for_quotation()
@@ -172,6 +182,32 @@ class TestRequestforQuotation(FrappeTestCase):
supplier_doc.reload()
self.assertTrue(supplier_doc.portal_users[0].user)
@change_settings("Buying Settings", {"allow_zero_qty_in_request_for_quotation": 1})
def test_supplier_quotation_from_zero_qty_rfq(self):
rfq = make_request_for_quotation(qty=0)
sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.get("suppliers")[0].supplier)
self.assertEqual(len(sq.items), 1)
self.assertEqual(sq.items[0].qty, 0)
self.assertEqual(sq.items[0].item_code, rfq.items[0].item_code)
@change_settings(
"Buying Settings",
{
"allow_zero_qty_in_request_for_quotation": 1,
"allow_zero_qty_in_supplier_quotation": 1,
},
)
def test_supplier_quotation_from_zero_qty_rfq_in_portal(self):
rfq = make_request_for_quotation(qty=0)
rfq.supplier = rfq.suppliers[0].supplier
sq_name = create_supplier_quotation(rfq)
sq = frappe.get_doc("Supplier Quotation", sq_name)
self.assertEqual(len(sq.items), 1)
self.assertEqual(sq.items[0].qty, 0)
self.assertEqual(sq.items[0].item_code, rfq.items[0].item_code)
def make_request_for_quotation(**args) -> "RequestforQuotation":
"""

View File

@@ -11,6 +11,11 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e
Quotation: "Quotation",
};
const me = this;
this.frm.set_indicator_formatter("item_code", function (doc) {
return !doc.qty && me.frm.doc.has_unit_price_items ? "yellow" : "";
});
super.setup();
}
@@ -26,6 +31,8 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e
cur_frm.page.set_inner_btn_group_as_primary(__("Create"));
cur_frm.add_custom_button(__("Quotation"), this.make_quotation, __("Create"));
} else if (this.frm.doc.docstatus === 0) {
erpnext.set_unit_price_items_note(this.frm);
this.frm.add_custom_button(
__("Material Request"),
function () {

View File

@@ -19,6 +19,7 @@
"transaction_date",
"valid_till",
"quotation_number",
"has_unit_price_items",
"amended_from",
"accounting_dimensions_section",
"cost_center",
@@ -921,14 +922,23 @@
"fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break",
"label": "Accounting Dimensions"
},
{
"default": "0",
"fieldname": "has_unit_price_items",
"fieldtype": "Check",
"hidden": 1,
"label": "Has Unit Price Items",
"no_copy": 1
}
],
"grid_page_length": 50,
"icon": "fa fa-shopping-cart",
"idx": 29,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2024-03-28 10:20:30.231915",
"modified": "2025-03-03 17:39:38.459977",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation",
@@ -989,6 +999,7 @@
"write": 1
}
],
"row_format": "Dynamic",
"search_fields": "status, transaction_date, supplier,grand_total",
"show_name_in_global_search": 1,
"sort_field": "modified",

View File

@@ -60,6 +60,7 @@ class SupplierQuotation(BuyingController):
discount_amount: DF.Currency
grand_total: DF.Currency
group_same_items: DF.Check
has_unit_price_items: DF.Check
ignore_pricing_rule: DF.Check
in_words: DF.Data | None
incoterm: DF.Link | None
@@ -103,6 +104,10 @@ class SupplierQuotation(BuyingController):
valid_till: DF.Date | None
# end: auto-generated types
def before_validate(self):
self.set_has_unit_price_items()
self.flags.allow_zero_qty = self.has_unit_price_items
def validate(self):
super().validate()
@@ -129,6 +134,17 @@ class SupplierQuotation(BuyingController):
def on_trash(self):
pass
def set_has_unit_price_items(self):
"""
If permitted in settings and any item has 0 qty, the SQ has unit price items.
"""
if not frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_supplier_quotation"):
return
self.has_unit_price_items = any(
not row.qty for row in self.get("items") if (row.item_code and not row.qty)
)
def validate_with_previous_doc(self):
super().validate_with_previous_doc(
{

View File

@@ -3,9 +3,10 @@
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests.utils import FrappeTestCase, 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
@@ -22,8 +23,6 @@ class TestPurchaseOrder(FrappeTestCase):
self.assertEqual(sq.items[0].qty, 1)
def test_make_purchase_order(self):
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order
sq = frappe.copy_doc(test_records[0]).insert()
self.assertRaises(frappe.ValidationError, make_purchase_order, sq.name)
@@ -43,5 +42,16 @@ class TestPurchaseOrder(FrappeTestCase):
po.insert()
@change_settings("Buying Settings", {"allow_zero_qty_in_supplier_quotation": 1})
def test_map_purchase_order_from_zero_qty_supplier_quotation(self):
sq = frappe.copy_doc(test_records[0]).insert()
sq.items[0].qty = 0
sq.submit()
po = make_purchase_order(sq.name)
self.assertEqual(len(po.get("items")), 1)
self.assertEqual(po.get("items")[0].qty, 0)
self.assertEqual(po.get("items")[0].item_code, sq.get("items")[0].item_code)
test_records = frappe.get_test_records("Supplier Quotation")

View File

@@ -20,6 +20,9 @@ def update_last_purchase_rate(doc, is_submit) -> None:
this_purchase_date = getdate(doc.get("posting_date") or doc.get("transaction_date"))
for d in doc.get("items"):
if d.get("is_free_item"):
continue
# get last purchase details
last_purchase_details = get_last_purchase_details(d.item_code, doc.name)

View File

@@ -649,6 +649,9 @@ class AccountsController(TransactionBase):
self.base_paid_amount = flt(
self.paid_amount * self.conversion_rate, self.precision("base_paid_amount")
)
else:
self.paid_amount = 0
self.base_paid_amount = 0
def set_missing_values(self, for_validate=False):
if frappe.flags.in_test:
@@ -1252,6 +1255,9 @@ class AccountsController(TransactionBase):
)
def validate_qty_is_not_zero(self):
if self.flags.allow_zero_qty:
return
for item in self.items:
if self.doctype == "Purchase Receipt" and item.rejected_qty:
continue
@@ -3732,9 +3738,9 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
)
if amount_below_billed_amt and row_rate > 0.0:
frappe.throw(
_("Row #{0}: Cannot set Rate if amount is greater than billed amount for Item {1}.").format(
child_item.idx, child_item.item_code
)
_(
"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

View File

@@ -70,6 +70,14 @@ class BuyingController(SubcontractingController):
frappe.db.get_single_value("Buying Settings", "backflush_raw_materials_of_subcontract_based_on"),
)
if self.docstatus == 1 and self.doctype in ["Purchase Receipt", "Purchase Invoice"]:
self.set_onload(
"allow_to_make_qc_after_submission",
frappe.db.get_single_value(
"Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery"
),
)
def create_package_for_transfer(self) -> None:
"""Create serial and batch package for Sourece Warehouse in case of inter transfer."""

View File

@@ -31,6 +31,14 @@ class SellingController(StockController):
)
)
if self.docstatus == 1 and self.doctype in ["Delivery Note", "Sales Invoice"]:
self.set_onload(
"allow_to_make_qc_after_submission",
frappe.db.get_single_value(
"Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery"
),
)
def validate(self):
super().validate()
self.validate_items()

View File

@@ -892,7 +892,7 @@ class StockController(AccountsController):
or sl_dict.actual_qty < 0
and self.get("is_return")
)
and self.doctype in ["Purchase Invoice", "Purchase Receipt"]
and self.doctype in ["Purchase Invoice", "Purchase Receipt", "Stock Entry"]
) or (
(
sl_dict.actual_qty < 0
@@ -902,6 +902,15 @@ class StockController(AccountsController):
)
and self.doctype in ["Sales Invoice", "Delivery Note", "Stock Entry"]
):
if self.doctype == "Stock Entry":
if row.get("t_warehouse") == sl_dict.warehouse and sl_dict.get("actual_qty") > 0:
fieldname = f"to_{dimension.source_fieldname}"
if dimension.source_fieldname.startswith("to_"):
fieldname = f"{dimension.source_fieldname}"
sl_dict[dimension.target_fieldname] = row.get(fieldname)
return
sl_dict[dimension.target_fieldname] = row.get(dimension.source_fieldname)
else:
fieldname_start_with = "to"

View File

@@ -29,4 +29,10 @@ frappe.ui.form.on("Contract", {
});
}
},
party_name: function (frm) {
let field = frm.doc.party_type.toLowerCase() + "_name";
frappe.db.get_value(frm.doc.party_type, frm.doc.party_name, field, (r) => {
frm.set_value("party_full_name", r[field]);
});
},
});

View File

@@ -14,6 +14,7 @@
"party_user",
"status",
"fulfilment_status",
"party_full_name",
"sb_terms",
"start_date",
"cb_date",
@@ -244,11 +245,18 @@
"fieldname": "authorised_by_section",
"fieldtype": "Section Break",
"label": "Authorised By"
},
{
"fieldname": "party_full_name",
"fieldtype": "Data",
"label": "Party Full Name",
"read_only": 1
}
],
"grid_page_length": 50,
"is_submittable": 1,
"links": [],
"modified": "2020-12-07 11:15:58.385521",
"modified": "2025-05-23 13:54:03.346537",
"modified_by": "Administrator",
"module": "CRM",
"name": "Contract",
@@ -315,9 +323,10 @@
"write": 1
}
],
"row_format": "Dynamic",
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1,
"track_seen": 1
}
}

View File

@@ -34,6 +34,7 @@ class Contract(Document):
fulfilment_terms: DF.Table[ContractFulfilmentChecklist]
ip_address: DF.Data | None
is_signed: DF.Check
party_full_name: DF.Data | None
party_name: DF.DynamicLink
party_type: DF.Literal["Customer", "Supplier", "Employee"]
party_user: DF.Link | None
@@ -59,10 +60,17 @@ class Contract(Document):
self.name = _(name)
def validate(self):
self.set_missing_values()
self.validate_dates()
self.update_contract_status()
self.update_fulfilment_status()
def set_missing_values(self):
if not self.party_full_name:
field = self.party_type.lower() + "_name"
if res := frappe.db.get_value(self.party_type, self.party_name, field):
self.party_full_name = res
def before_submit(self):
self.signed_by_company = frappe.session.user

View File

@@ -404,3 +404,6 @@ erpnext.patches.v15_0.update_payment_schedule_fields_in_invoices
erpnext.patches.v15_0.rename_group_by_to_categorize_by
execute:frappe.db.set_single_value("Accounts Settings", "receivable_payable_fetch_method", "Buffered Cursor")
erpnext.patches.v14_0.set_update_price_list_based_on
erpnext.patches.v15_0.set_cancelled_status_to_cancelled_pos_invoice
erpnext.patches.v15_0.rename_group_by_to_categorize_by_in_custom_reports
erpnext.patches.v14_0.update_full_name_in_contract

View File

@@ -0,0 +1,15 @@
import frappe
from frappe import qb
def execute():
con = qb.DocType("Contract")
for c in (
qb.from_(con)
.select(con.name, con.party_type, con.party_name)
.where(con.party_full_name.isnull())
.run(as_dict=True)
):
field = c.party_type.lower() + "_name"
if res := frappe.db.get_value(c.party_type, c.party_name, field):
frappe.db.set_value("Contract", c.name, "party_full_name", res)

View File

@@ -0,0 +1,24 @@
import json
import frappe
def execute():
custom_reports = frappe.get_all(
"Report",
filters={
"report_type": "Custom Report",
"reference_report": ["in", ["General Ledger", "Supplier Quotation Comparison"]],
},
fields=["name", "json"],
)
for report in custom_reports:
report_json = json.loads(report.json)
if "filters" in report_json and "group_by" in report_json["filters"]:
report_json["filters"]["categorize_by"] = (
report_json["filters"].pop("group_by").replace("Group", "Categorize")
)
frappe.db.set_value("Report", report.name, "json", json.dumps(report_json))

View File

@@ -0,0 +1,8 @@
import frappe
from frappe.query_builder import DocType
def execute():
POSInvoice = DocType("POS Invoice")
frappe.qb.update(POSInvoice).set(POSInvoice.status, "Cancelled").where(POSInvoice.docstatus == 2).run()

View File

@@ -42,6 +42,29 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
}
item.base_rate_with_margin = item.rate_with_margin * flt(frm.doc.conversion_rate);
if (item.item_code && item.rate) {
frappe.call({
method: "frappe.client.get_value",
args: {
doctype: "Item Tax",
parent: "Item",
filters: {
parent: item.item_code,
minimum_net_rate: ["<=", item.rate],
maximum_net_rate: [">=", item.rate]
},
fieldname: "item_tax_template"
},
callback: function(r) {
const tax_rule = r.message;
let matched_template = tax_rule ? tax_rule.item_tax_template : null;
frappe.model.set_value(cdt, cdn, 'item_tax_template', matched_template);
}
});
}
cur_frm.cscript.set_gross_profit(item);
cur_frm.cscript.calculate_taxes_and_totals();
cur_frm.cscript.calculate_stock_uom_rate(frm, cdt, cdn);
@@ -330,7 +353,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
}
const me = this;
if (!this.frm.is_new() && this.frm.doc.docstatus === 0 && frappe.model.can_create("Quality Inspection") && show_qc_button) {
if (!this.frm.is_new()
&& (this.frm.doc.docstatus === 0 || this.frm.doc.__onload?.allow_to_make_qc_after_submission)
&& frappe.model.can_create("Quality Inspection")
&& show_qc_button) {
this.frm.add_custom_button(__("Quality Inspection(s)"), () => {
me.make_quality_inspection();
}, __("Create"));
@@ -2773,3 +2799,19 @@ erpnext.apply_putaway_rule = (frm, purpose=null) => {
}
});
};
erpnext.set_unit_price_items_note = (frm) => {
if (frm.doc.has_unit_price_items && !frm.is_new()) {
// Remove existing note
const $note = $(frm.layout.wrapper.find(".unit-price-items-note"));
if ($note.length) { $note.parent().remove(); }
frm.layout.show_message(
`<div class="unit-price-items-note">
${__("The {0} contains Unit Price Items.", [__(frm.doc.doctype)])}
</div>`,
"yellow",
true
);
}
};

View File

@@ -35,12 +35,20 @@ frappe.ui.form.on("Quotation", {
},
};
});
frm.set_indicator_formatter("item_code", function (doc) {
return !doc.qty && frm.doc.has_unit_price_items ? "yellow" : "";
});
},
refresh: function (frm) {
frm.trigger("set_label");
frm.trigger("set_dynamic_field_label");
if (frm.doc.docstatus === 0) {
erpnext.set_unit_price_items_note(frm);
}
let sbb_field = frm.get_docfield("packed_items", "serial_and_batch_bundle");
if (sbb_field) {
sbb_field.get_route_options_for_new_doc = (row) => {

View File

@@ -21,6 +21,7 @@
"column_break1",
"order_type",
"company",
"has_unit_price_items",
"amended_from",
"currency_and_price_list",
"currency",
@@ -935,6 +936,7 @@
"fieldname": "lost_reasons",
"fieldtype": "Table MultiSelect",
"label": "Lost Reasons",
"no_copy": 1,
"options": "Quotation Lost Reason Detail",
"read_only": 1
},
@@ -1084,13 +1086,21 @@
"label": "Company Contact Person",
"options": "Contact",
"print_hide": 1
},
{
"default": "0",
"fieldname": "has_unit_price_items",
"fieldtype": "Check",
"hidden": 1,
"label": "Has Unit Price Items",
"no_copy": 1
}
],
"icon": "fa fa-shopping-cart",
"idx": 82,
"is_submittable": 1,
"links": [],
"modified": "2024-11-26 12:43:29.293637",
"modified": "2025-05-27 16:04:39.208077",
"modified_by": "Administrator",
"module": "Selling",
"name": "Quotation",
@@ -1181,6 +1191,7 @@
"role": "Maintenance User"
}
],
"row_format": "Dynamic",
"search_fields": "status,transaction_date,party_name,order_type",
"show_name_in_global_search": 1,
"sort_field": "modified",

View File

@@ -67,6 +67,7 @@ class Quotation(SellingController):
enq_det: DF.Text | None
grand_total: DF.Currency
group_same_items: DF.Check
has_unit_price_items: DF.Check
ignore_pricing_rule: DF.Check
in_words: DF.Data | None
incoterm: DF.Link | None
@@ -126,6 +127,10 @@ class Quotation(SellingController):
self.indicator_color = "gray"
self.indicator_title = "Expired"
def before_validate(self):
self.set_has_unit_price_items()
self.flags.allow_zero_qty = self.has_unit_price_items
def validate(self):
super().validate()
self.set_status()
@@ -157,6 +162,17 @@ class Quotation(SellingController):
if not row.is_alternative and row.name in items_with_alternatives:
row.has_alternative_item = 1
def set_has_unit_price_items(self):
"""
If permitted in settings and any item has 0 qty, the SO has unit price items.
"""
if not frappe.db.get_single_value("Selling Settings", "allow_zero_qty_in_quotation"):
return
self.has_unit_price_items = any(
not row.qty for row in self.get("items") if (row.item_code and not row.qty)
)
def get_ordered_status(self):
status = "Open"
ordered_items = frappe._dict(
@@ -367,6 +383,12 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_items", [])]
# 0 qty is accepted, as the qty uncertain for some items
has_unit_price_items = frappe.db.get_value("Quotation", source_name, "has_unit_price_items")
def is_unit_price_row(source) -> bool:
return has_unit_price_items and source.qty == 0
def set_missing_values(source, target):
if customer:
target.customer = customer.name
@@ -395,7 +417,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
target.run_method("calculate_taxes_and_totals")
def update_item(obj, target, source_parent):
balance_qty = obj.qty - ordered_items.get(obj.item_code, 0.0)
balance_qty = obj.qty if is_unit_price_row(obj) else obj.qty - ordered_items.get(obj.item_code, 0.0)
target.qty = balance_qty if balance_qty > 0 else 0
target.stock_qty = flt(target.qty) * flt(obj.conversion_factor)
@@ -409,22 +431,22 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
Row mapping from Quotation to Sales order:
1. If no selections, map all non-alternative rows (that sum up to the grand total)
2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty
3. If selections: Simple row: Map if adequate qty
3. If no selections: Simple row: Map if adequate qty
"""
balance_qty = item.qty - ordered_items.get(item.item_code, 0.0)
if balance_qty <= 0:
return False
has_valid_qty: bool = (balance_qty > 0) or is_unit_price_row(item)
has_qty = balance_qty
if not has_valid_qty:
return False
if not selected_rows:
return not item.is_alternative
if selected_rows and (item.is_alternative or item.has_alternative_item):
return (item.name in selected_rows) and has_qty
return item.name in selected_rows
# Simple row
return has_qty
return True
doclist = get_mapped_doc(
"Quotation",

View File

@@ -2,7 +2,7 @@
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_days, add_months, flt, getdate, nowdate
from erpnext.controllers.accounts_controller import InvalidQtyError
@@ -21,6 +21,15 @@ class TestQuotation(FrappeTestCase):
qo.save()
self.assertEqual(qo.items[0].qty, 1)
def test_quotation_zero_qty(self):
"""
Test if Quote with zero qty (Unit Price Item) is conditionally allowed.
"""
qo = make_quotation(qty=0, do_not_save=True)
with change_settings("Selling Settings", {"allow_zero_qty_in_quotation": 1}):
qo.save()
self.assertEqual(qo.items[0].qty, 0)
def test_make_quotation_without_terms(self):
quotation = make_quotation(do_not_save=1)
self.assertFalse(quotation.get("payment_schedule"))
@@ -773,6 +782,39 @@ class TestQuotation(FrappeTestCase):
self.assertEqual(quotation.rounding_adjustment, 0)
self.assertEqual(quotation.rounded_total, 0)
@change_settings("Selling Settings", {"allow_zero_qty_in_quotation": 1})
def test_so_from_zero_qty_quotation(self):
from erpnext.selling.doctype.quotation.quotation import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
make_item("_Test Item 2", {"is_stock_item": 1})
quotation = make_quotation(qty=0, do_not_save=1)
quotation.append("items", {"item_code": "_Test Item 2", "qty": 10, "rate": 100})
quotation.submit()
sales_order = make_sales_order(quotation.name)
sales_order.delivery_date = nowdate()
self.assertEqual(sales_order.items[0].qty, 0)
self.assertEqual(sales_order.items[1].qty, 10)
sales_order.items[0].qty = 10
sales_order.items[1].qty = 5
sales_order.submit()
quotation.reload()
self.assertEqual(quotation.status, "Partially Ordered")
sales_order_2 = make_sales_order(quotation.name)
sales_order_2.delivery_date = nowdate()
self.assertEqual(sales_order_2.items[0].qty, 0)
self.assertEqual(sales_order_2.items[1].qty, 5)
del sales_order_2.items[0]
sales_order_2.submit()
quotation.reload()
self.assertEqual(quotation.status, "Ordered")
test_records = frappe.get_test_records("Quotation")

View File

@@ -23,7 +23,16 @@ frappe.ui.form.on("Sales Order", {
// formatter for material request item
frm.set_indicator_formatter("item_code", function (doc) {
return doc.stock_qty <= doc.delivered_qty ? "green" : "orange";
let color;
if (!doc.qty && frm.doc.has_unit_price_items) {
color = "yellow";
} else if (doc.stock_qty <= doc.delivered_qty) {
color = "green";
} else {
color = "orange";
}
return color;
});
frm.set_query("bom_no", "items", function (doc, cdt, cdn) {
@@ -97,6 +106,8 @@ frappe.ui.form.on("Sales Order", {
}
if (frm.doc.docstatus === 0) {
erpnext.set_unit_price_items_note(frm);
if (frm.doc.is_internal_customer) {
frm.events.get_items_from_internal_purchase_order(frm);
}
@@ -586,10 +597,12 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
}
if (doc.status !== "Closed") {
if (doc.status !== "On Hold") {
const items_are_deliverable = this.frm.doc.items.some(
(item) => item.delivered_by_supplier === 0 && item.qty > flt(item.delivered_qty)
);
allow_delivery =
this.frm.doc.items.some(
(item) => item.delivered_by_supplier === 0 && item.qty > flt(item.delivered_qty)
) && !this.frm.doc.skip_delivery_note;
(this.frm.doc.has_unit_price_items || items_are_deliverable) &&
!this.frm.doc.skip_delivery_note;
if (this.frm.has_perm("submit")) {
if (flt(doc.per_delivered) < 100 || flt(doc.per_billed) < 100) {

View File

@@ -25,6 +25,7 @@
"po_date",
"company",
"skip_delivery_note",
"has_unit_price_items",
"amended_from",
"accounting_dimensions_section",
"cost_center",
@@ -1649,13 +1650,21 @@
"label": "Company Contact Person",
"options": "Contact",
"print_hide": 1
},
{
"default": "0",
"fieldname": "has_unit_price_items",
"fieldtype": "Check",
"hidden": 1,
"label": "Has Unit Price Items",
"no_copy": 1
}
],
"icon": "fa fa-file-text",
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2025-02-06 16:02:20.320877",
"modified": "2025-03-03 16:49:00.676927",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order",
@@ -1724,6 +1733,7 @@
"write": 1
}
],
"row_format": "Dynamic",
"search_fields": "status,transaction_date,customer,customer_name, territory,order_type,company",
"show_name_in_global_search": 1,
"sort_field": "modified",

View File

@@ -110,6 +110,7 @@ class SalesOrder(SellingController):
from_date: DF.Date | None
grand_total: DF.Currency
group_same_items: DF.Check
has_unit_price_items: DF.Check
ignore_pricing_rule: DF.Check
in_words: DF.Data | None
incoterm: DF.Link | None
@@ -195,6 +196,10 @@ class SalesOrder(SellingController):
if has_reserved_stock(self.doctype, self.name):
self.set_onload("has_reserved_stock", True)
def before_validate(self):
self.set_has_unit_price_items()
self.flags.allow_zero_qty = self.has_unit_price_items
def validate(self):
super().validate()
self.validate_delivery_date()
@@ -231,6 +236,17 @@ class SalesOrder(SellingController):
self.reset_default_field_value("set_warehouse", "items", "warehouse")
def set_has_unit_price_items(self):
"""
If permitted in settings and any item has 0 qty, the SO has unit price items.
"""
if not frappe.db.get_single_value("Selling Settings", "allow_zero_qty_in_sales_order"):
return
self.has_unit_price_items = any(
not row.qty for row in self.get("items") if (row.item_code and not row.qty)
)
def validate_po(self):
# validate p.o date v/s delivery date
if self.po_date and not self.skip_delivery_note:
@@ -946,6 +962,12 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None):
"Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
}
# 0 qty is accepted, as the qty is uncertain for some items
has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items")
def is_unit_price_row(source):
return has_unit_price_items and source.qty == 0
def set_missing_values(source, target):
if kwargs.get("ignore_pricing_rule"):
# Skip pricing rule when the dn is creating from the pick list
@@ -982,12 +1004,16 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None):
if cstr(doc.delivery_date) not in frappe.flags.args.delivery_dates:
return False
return abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier != 1
return (
(abs(doc.delivered_qty) < abs(doc.qty)) or is_unit_price_row(doc)
) and doc.delivered_by_supplier != 1
def update_item(source, target, source_parent):
target.base_amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.base_rate)
target.amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.rate)
target.qty = flt(source.qty) - flt(source.delivered_qty)
target.qty = (
flt(source.qty) if is_unit_price_row(source) else flt(source.qty) - flt(source.delivered_qty)
)
item = get_item_defaults(target.item_code, source_parent.company)
item_group = get_item_group_defaults(target.item_code, source_parent.company)
@@ -1064,6 +1090,12 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None):
@frappe.whitelist()
def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False):
# 0 qty is accepted, as the qty is uncertain for some items
has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items")
def is_unit_price_row(source):
return has_unit_price_items and source.qty == 0
def postprocess(source, target):
set_missing_values(source, target)
# Get the advance paid Journal Entries in Sales Invoice Advance
@@ -1093,12 +1125,18 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False):
target.debit_to = get_party_account("Customer", source.customer, source.company)
def update_item(source, target, source_parent):
target.amount = flt(source.amount) - flt(source.billed_amt)
if source_parent.has_unit_price_items:
# 0 Amount rows (as seen in Unit Price Items) should be mapped as it is
pending_amount = flt(source.amount) - flt(source.billed_amt)
target.amount = pending_amount if flt(source.amount) else 0
else:
target.amount = flt(source.amount) - flt(source.billed_amt)
target.base_amount = target.amount * flt(source_parent.conversion_rate)
target.qty = (
target.amount / flt(source.rate)
if (source.rate and source.billed_amt)
else source.qty - source.returned_qty
else (source.qty if is_unit_price_row(source) else source.qty - source.returned_qty)
)
if source_parent.project:
@@ -1131,8 +1169,11 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False):
"parent": "sales_order",
},
"postprocess": update_item,
"condition": lambda doc: doc.qty
and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount)),
"condition": lambda doc: (
True
if is_unit_price_row(doc)
else (doc.qty and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount)))
),
},
"Sales Taxes and Charges": {
"doctype": "Sales Taxes and Charges",

View File

@@ -109,6 +109,13 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
so.save()
self.assertEqual(so.items[0].qty, 1)
def test_sales_order_zero_qty(self):
po = make_sales_order(qty=0, do_not_save=True)
with change_settings("Selling Settings", {"allow_zero_qty_in_sales_order": 1}):
po.save()
self.assertEqual(po.items[0].qty, 0)
def test_make_material_request(self):
so = make_sales_order(do_not_submit=True)
@@ -2306,6 +2313,77 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
po.submit()
self.assertEqual(po.taxes[0].tax_amount, 2)
@change_settings("Selling Settings", {"allow_zero_qty_in_sales_order": 1})
def test_deliver_zero_qty_purchase_order(self):
"""
Test the flow of a Unit Price SO and DN creation against it until completion.
Flow:
SO Qty 0 -> Deliver +5 -> Update SO Qty +10 -> Deliver +5 -> SO is 100% delivered
"""
so = make_sales_order(qty=0)
dn = make_delivery_note(so.name)
self.assertEqual(dn.items[0].qty, 0)
dn.items[0].qty = 5
dn.submit()
# Test SO impact after DN
so.reload()
self.assertEqual(so.items[0].delivered_qty, 5)
self.assertFalse(so.per_delivered)
self.assertEqual(so.status, "To Deliver and Bill")
# Update SO Qty to final qty
first_item_of_so = so.items[0]
trans_item = json.dumps(
[
{
"item_code": first_item_of_so.item_code,
"rate": first_item_of_so.rate,
"qty": 10,
"docname": first_item_of_so.name,
}
]
)
update_child_qty_rate("Sales Order", trans_item, so.name)
# Test: DN maps pending qty from SO
dn2 = make_delivery_note(so.name)
so.reload()
self.assertEqual(so.items[0].qty, 10)
self.assertEqual(dn2.items[0].qty, 5)
dn2.submit()
so.reload()
self.assertEqual(so.items[0].delivered_qty, 10)
self.assertEqual(so.per_delivered, 100.0)
self.assertEqual(so.status, "To Bill")
@change_settings("Selling Settings", {"allow_zero_qty_in_sales_order": 1})
def test_bill_zero_qty_sales_order(self):
so = make_sales_order(qty=0)
self.assertEqual(so.grand_total, 0)
self.assertFalse(so.per_billed)
self.assertEqual(so.items[0].qty, 0)
self.assertEqual(so.items[0].rate, 100)
si = make_sales_invoice(so.name)
self.assertEqual(si.items[0].qty, 0)
self.assertEqual(si.items[0].rate, 100)
si.items[0].qty = 5
si.submit()
so.reload()
self.assertEqual(so.items[0].amount, 0)
self.assertEqual(so.items[0].billed_amt, si.grand_total)
# SO still has qty 0, so billed % should be unset
self.assertFalse(so.per_billed)
self.assertEqual(so.status, "To Deliver and Bill")
def automatically_fetch_payment_terms(enable=1):
accounts_settings = frappe.get_doc("Accounts Settings")

View File

@@ -32,7 +32,9 @@
"allow_sales_order_creation_for_expired_quotation",
"dont_reserve_sales_order_qty_on_sales_return",
"hide_tax_id",
"enable_discount_accounting"
"enable_discount_accounting",
"allow_zero_qty_in_quotation",
"allow_zero_qty_in_sales_order"
],
"fields": [
{
@@ -200,14 +202,29 @@
"fieldname": "blanket_order_allowance",
"fieldtype": "Float",
"label": "Blanket Order Allowance (%)"
},
{
"default": "0",
"description": "Allows users to submit Sales Orders with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.",
"fieldname": "allow_zero_qty_in_sales_order",
"fieldtype": "Check",
"label": "Allow Sales Order with Zero Quantity"
},
{
"default": "0",
"description": "Allows users to submit Quotations with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.",
"fieldname": "allow_zero_qty_in_quotation",
"fieldtype": "Check",
"label": "Allow Quotation with Zero Quantity"
}
],
"grid_page_length": 50,
"icon": "fa fa-cog",
"idx": 1,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2023-10-25 14:03:03.966701",
"modified": "2025-05-06 15:23:14.332971",
"modified_by": "Administrator",
"module": "Selling",
"name": "Selling Settings",
@@ -236,4 +253,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -23,6 +23,8 @@ class SellingSettings(Document):
allow_multiple_items: DF.Check
allow_negative_rates_for_items: DF.Check
allow_sales_order_creation_for_expired_quotation: DF.Check
allow_zero_qty_in_quotation: DF.Check
allow_zero_qty_in_sales_order: DF.Check
blanket_order_allowance: DF.Float
cust_master_name: DF.Literal["Customer Name", "Naming Series", "Auto Name"]
customer_group: DF.Link | None

View File

@@ -321,17 +321,25 @@ erpnext.PointOfSale.PastOrderSummary = class {
get_condition_btn_map(after_submission) {
if (after_submission)
return [{ condition: true, visible_btns: ["Print Receipt", "Email Receipt", "New Order"] }];
return [
{
condition: true,
visible_btns: [__("Print Receipt"), __("Email Receipt"), __("New Order")],
},
];
return [
{ condition: this.doc.docstatus === 0, visible_btns: ["Edit Order", "Delete Order"] },
{
condition: this.doc.docstatus === 0,
visible_btns: [__("Edit Order"), __("Delete Order")],
},
{
condition: !this.doc.is_return && this.doc.docstatus === 1,
visible_btns: ["Print Receipt", "Email Receipt", "Return"],
visible_btns: [__("Print Receipt"), __("Email Receipt"), __("Return")],
},
{
condition: this.doc.is_return && this.doc.docstatus === 1,
visible_btns: ["Print Receipt", "Email Receipt"],
visible_btns: [__("Print Receipt"), __("Email Receipt")],
},
];
}

View File

@@ -2,14 +2,15 @@
"chart_name": "Warehouse wise Stock Value",
"chart_type": "Custom",
"creation": "2022-03-30 00:58:02.018824",
"custom_options": "{\n \"fieldtype\": \"Currency\"\n}",
"docstatus": 0,
"doctype": "Dashboard Chart",
"filters_json": "{}",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"last_synced_on": "2024-12-23 18:44:46.822164",
"modified": "2024-12-23 19:31:17.003946",
"last_synced_on": "2025-04-25 11:38:43.644402",
"modified": "2025-04-25 11:39:12.585542",
"modified_by": "Administrator",
"module": "Stock",
"name": "Warehouse wise Stock Value",

View File

@@ -560,25 +560,23 @@ erpnext.buying.MaterialRequestController = class MaterialRequestController exten
onload() {
this.frm.set_query("item_code", "items", function (doc, cdt, cdn) {
let filters = { is_stock_item: 1 };
if (doc.material_request_type == "Customer Provided") {
return {
query: "erpnext.controllers.queries.item_query",
filters: {
customer: doc.customer,
is_stock_item: 1,
},
};
} else if (doc.material_request_type == "Purchase") {
return {
query: "erpnext.controllers.queries.item_query",
filters: { is_purchase_item: 1 },
};
} else {
return {
query: "erpnext.controllers.queries.item_query",
filters: { is_stock_item: 1 },
};
filters.customer = doc.customer;
} else if (
doc.material_request_type == "Purchase" ||
doc.material_request_type == "Subcontracting"
) {
filters = { is_purchase_item: 1 };
} else if (doc.material_request_type == "Manufacture") {
filters.include_item_in_manufacturing = 1;
}
return {
query: "erpnext.controllers.queries.item_query",
filters: filters,
};
});
}

View File

@@ -643,6 +643,7 @@ def make_supplier_quotation(source_name, target_doc=None):
postprocess,
)
doclist.set_onload("load_after_mapping", False)
return doclist

View File

@@ -1106,6 +1106,10 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate
if pr_doc.get("is_return") and not total_amount and total_billed_amount:
total_amount = total_billed_amount
amount = item.amount
if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"):
amount += flt(item.rejected_qty * item.rate, item.precision("amount"))
if adjust_incoming_rate:
adjusted_amt = 0.0
@@ -1120,8 +1124,8 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate
adjusted_amt = flt(adjusted_amt * flt(pr_doc.conversion_rate), item.precision("amount"))
item.db_set("amount_difference_with_purchase_invoice", adjusted_amt, update_modified=False)
elif item.billed_amt > item.amount:
per_over_billed = (flt(item.billed_amt / item.amount, 2) * 100) - 100
elif item.billed_amt > amount:
per_over_billed = (flt(item.billed_amt / amount, 2) * 100) - 100
if per_over_billed > over_billing_allowance:
frappe.throw(
_("Over Billing Allowance exceeded for Purchase Receipt Item {0} ({1}) by {2}%").format(

View File

@@ -882,6 +882,12 @@ class update_entries_after:
self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(
self.wh_data.valuation_rate
)
if sle.actual_qty < 0 and self.wh_data.qty_after_transaction != 0:
self.wh_data.valuation_rate = flt(
self.wh_data.stock_value, self.currency_precision
) / flt(self.wh_data.qty_after_transaction, self.flt_precision)
else:
self.update_queue_values(sle)

View File

@@ -163,6 +163,9 @@ class TransactionBase(StatusUpdater):
child_table_values = set()
for row in self.get(child_table):
if default_field == "set_warehouse" and row.get("delivered_by_supplier"):
continue
child_table_values.add(row.get(child_table_field))
if len(child_table_values) > 1: