mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-25 13:45:04 +00:00
Compare commits
149 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
916511ef1a | ||
|
|
c614ff419b | ||
|
|
d5163ed502 | ||
|
|
5bac652b5f | ||
|
|
3582b32f03 | ||
|
|
2815a0d827 | ||
|
|
7d828dc17e | ||
|
|
01dd7337a2 | ||
|
|
470534af78 | ||
|
|
a4fe89f65c | ||
|
|
4a1966c680 | ||
|
|
cd7462dd87 | ||
|
|
3c697e90a3 | ||
|
|
6dde327713 | ||
|
|
16b10274cf | ||
|
|
4c1b415b9d | ||
|
|
d7124779bf | ||
|
|
053a5b93ca | ||
|
|
9f5cfdd65b | ||
|
|
5ebf1b9cc4 | ||
|
|
34b62d226c | ||
|
|
9bf8904c80 | ||
|
|
ba8a316b06 | ||
|
|
3879cbd86d | ||
|
|
9df3b9b059 | ||
|
|
0131800df2 | ||
|
|
2e3ebec53c | ||
|
|
ef9ccd7a57 | ||
|
|
903d9b50fe | ||
|
|
0c612be6fe | ||
|
|
893a86e173 | ||
|
|
5a23d7cdca | ||
|
|
8623a5650b | ||
|
|
73bc57f53e | ||
|
|
9192913832 | ||
|
|
f59093c6b7 | ||
|
|
7ede5392bd | ||
|
|
0f5c7d95a0 | ||
|
|
f783bf60a4 | ||
|
|
4c49ab19d6 | ||
|
|
de1afee75a | ||
|
|
db97dbd394 | ||
|
|
fb2df77da2 | ||
|
|
260073f14a | ||
|
|
92d5e91e1f | ||
|
|
4fd1af2118 | ||
|
|
5997e37454 | ||
|
|
63ba27e359 | ||
|
|
fd19f284c4 | ||
|
|
74e29f1218 | ||
|
|
5dee1d40ac | ||
|
|
895231a8a7 | ||
|
|
8ed6e98565 | ||
|
|
70f9c13f3c | ||
|
|
a2f5975133 | ||
|
|
d3d22f699e | ||
|
|
60dfe36195 | ||
|
|
b9698366c3 | ||
|
|
1fb4ac44fe | ||
|
|
0f2264658f | ||
|
|
fe78bb60c4 | ||
|
|
194e41a2d9 | ||
|
|
2c8db092a0 | ||
|
|
c44493fd7e | ||
|
|
01b0d1057e | ||
|
|
9d2f396d75 | ||
|
|
b3cbbf2ce3 | ||
|
|
0cbb7223be | ||
|
|
c09b258d57 | ||
|
|
d5d1a51b92 | ||
|
|
9abac4c6df | ||
|
|
48f786e493 | ||
|
|
a137944955 | ||
|
|
bb54bebe94 | ||
|
|
ba009f4626 | ||
|
|
e05888502f | ||
|
|
2961e595c2 | ||
|
|
f41bcc6fec | ||
|
|
a6b1bdc78b | ||
|
|
f17b7b5ee9 | ||
|
|
5529a17831 | ||
|
|
62aa1cdb33 | ||
|
|
3d2d1ba072 | ||
|
|
f3bc80c89d | ||
|
|
6892994ef6 | ||
|
|
2c22615b6b | ||
|
|
cd1c10a43f | ||
|
|
db318a4e9b | ||
|
|
9a78283ecb | ||
|
|
52a5cd9702 | ||
|
|
f3052a446f | ||
|
|
ce9da48a5e | ||
|
|
a450f4ce64 | ||
|
|
a029f2e8a3 | ||
|
|
32eeedac24 | ||
|
|
ecb2bab70f | ||
|
|
cf354c0da3 | ||
|
|
73fc8c374f | ||
|
|
8c4ce03f44 | ||
|
|
69337cf18b | ||
|
|
b773b494a0 | ||
|
|
70e190dbbb | ||
|
|
be280a408e | ||
|
|
9584c80857 | ||
|
|
1a69d8137f | ||
|
|
35c7af1b9d | ||
|
|
da4ed5cc18 | ||
|
|
927d0f686f | ||
|
|
8ee9a46d96 | ||
|
|
86aa072235 | ||
|
|
72ae80e2e3 | ||
|
|
829550cd99 | ||
|
|
249d18962c | ||
|
|
b9f12ed4c7 | ||
|
|
80c76618ae | ||
|
|
073d06c44f | ||
|
|
612fa7c672 | ||
|
|
e7dc31191c | ||
|
|
926c0c5cf4 | ||
|
|
52cab02a5c | ||
|
|
31fa1c9a58 | ||
|
|
631a9bfa7c | ||
|
|
67741f1a21 | ||
|
|
f225e1986e | ||
|
|
9355782397 | ||
|
|
30ec69c977 | ||
|
|
b22831bd94 | ||
|
|
5a3eff05a1 | ||
|
|
09a46fcf0e | ||
|
|
37f4cf5367 | ||
|
|
f95a3f5b8b | ||
|
|
0286788e97 | ||
|
|
8891f46a22 | ||
|
|
890ce4a676 | ||
|
|
2960d0dce1 | ||
|
|
5b50d5abf2 | ||
|
|
9cede83de1 | ||
|
|
a8b982dd0a | ||
|
|
cf45ffdabe | ||
|
|
e91a0acbb3 | ||
|
|
3deb11e5b2 | ||
|
|
f94a14c06a | ||
|
|
d9636018f5 | ||
|
|
2d96a62530 | ||
|
|
eba73df88e | ||
|
|
c19065e675 | ||
|
|
f8fa775af3 | ||
|
|
91e167fe72 | ||
|
|
33366fce6c |
@@ -4,7 +4,7 @@ import inspect
|
||||
import frappe
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "15.61.0"
|
||||
__version__ = "15.64.0"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -168,8 +168,9 @@ def validate_loyalty_points(ref_doc, points_to_redeem):
|
||||
|
||||
loyalty_amount = flt(points_to_redeem * loyalty_program_details.conversion_factor)
|
||||
|
||||
if loyalty_amount > ref_doc.rounded_total:
|
||||
frappe.throw(_("You can't redeem Loyalty Points having more value than the Rounded Total."))
|
||||
total_amount = ref_doc.grand_total if ref_doc.is_rounded_total_disabled() else ref_doc.rounded_total
|
||||
if loyalty_amount > total_amount:
|
||||
frappe.throw(_("You can't redeem Loyalty Points having more value than the Total Amount."))
|
||||
|
||||
if not ref_doc.loyalty_amount and ref_doc.loyalty_amount != loyalty_amount:
|
||||
ref_doc.loyalty_amount = loyalty_amount
|
||||
|
||||
@@ -228,6 +228,7 @@
|
||||
"fieldname": "party_balance",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Party Balance",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -799,7 +800,7 @@
|
||||
"table_fieldname": "payment_entries"
|
||||
}
|
||||
],
|
||||
"modified": "2025-05-08 11:18:10.238085",
|
||||
"modified": "2025-05-15 18:01:04.013025",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payment Entry",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -60,6 +60,20 @@ frappe.ui.form.on("Process Statement Of Accounts", {
|
||||
},
|
||||
};
|
||||
});
|
||||
frm.set_query("cost_center", function () {
|
||||
return {
|
||||
filters: {
|
||||
company: frm.doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
frm.set_query("project", function () {
|
||||
return {
|
||||
filters: {
|
||||
company: frm.doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
if (frm.doc.__islocal) {
|
||||
frm.set_value("from_date", frappe.datetime.add_months(frappe.datetime.get_today(), -1));
|
||||
frm.set_value("to_date", frappe.datetime.get_today());
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@
|
||||
<tr>
|
||||
{% if(report.report_name == "Accounts Receivable" or report.report_name == "Accounts Payable") %}
|
||||
{% if(data[i]["party"]) %}
|
||||
<td>{{ (data[i]["posting_date"]) }}</td>
|
||||
<td>{{ frappe.format((data[i]["posting_date"]), 'Date') }}</td>
|
||||
<td style="text-align: right">{{ data[i]["age"] }}</td>
|
||||
<td>
|
||||
{% if not(filters.show_future_payments) %}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -16,7 +16,7 @@ from erpnext.buying.doctype.purchase_order.test_purchase_order import (
|
||||
create_purchase_order,
|
||||
)
|
||||
from erpnext.buying.doctype.supplier.test_supplier import create_supplier
|
||||
from erpnext.controllers.accounts_controller import get_payment_terms
|
||||
from erpnext.controllers.accounts_controller import InvalidQtyError, get_payment_terms
|
||||
from erpnext.controllers.buying_controller import QtyMismatchError
|
||||
from erpnext.exceptions import InvalidCurrency
|
||||
from erpnext.projects.doctype.project.test_project import make_project
|
||||
@@ -55,6 +55,16 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
|
||||
def test_purchase_invoice_qty(self):
|
||||
pi = make_purchase_invoice(qty=0, do_not_save=True)
|
||||
with self.assertRaises(InvalidQtyError):
|
||||
pi.save()
|
||||
|
||||
# No error with qty=1
|
||||
pi.items[0].qty = 1
|
||||
pi.save()
|
||||
self.assertEqual(pi.items[0].qty, 1)
|
||||
|
||||
def test_purchase_invoice_received_qty(self):
|
||||
"""
|
||||
1. Test if received qty is validated against accepted + rejected
|
||||
@@ -2905,7 +2915,7 @@ def make_purchase_invoice(**args):
|
||||
bundle_id = None
|
||||
if not args.use_serial_batch_fields and (args.get("batch_no") or args.get("serial_no")):
|
||||
batches = {}
|
||||
qty = args.qty or 5
|
||||
qty = args.qty if args.qty is not None else 5
|
||||
item_code = args.item or args.item_code or "_Test Item"
|
||||
if args.get("batch_no"):
|
||||
batches = frappe._dict({args.batch_no: qty})
|
||||
@@ -2934,7 +2944,7 @@ def make_purchase_invoice(**args):
|
||||
"item_code": args.item or args.item_code or "_Test Item",
|
||||
"item_name": args.item_name,
|
||||
"warehouse": args.warehouse or "_Test Warehouse - _TC",
|
||||
"qty": args.qty or 5,
|
||||
"qty": args.qty if args.qty is not None else 5,
|
||||
"received_qty": args.received_qty or 0,
|
||||
"rejected_qty": args.rejected_qty or 0,
|
||||
"rate": args.rate or 50,
|
||||
|
||||
@@ -2286,6 +2286,18 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
set_purchase_references(target)
|
||||
|
||||
def update_details(source_doc, target_doc, source_parent):
|
||||
def _validate_address_link(address, link_doctype, link_name):
|
||||
return frappe.db.get_value(
|
||||
"Dynamic Link",
|
||||
{
|
||||
"parent": address,
|
||||
"parenttype": "Address",
|
||||
"link_doctype": link_doctype,
|
||||
"link_name": link_name,
|
||||
},
|
||||
"parent",
|
||||
)
|
||||
|
||||
target_doc.inter_company_invoice_reference = source_doc.name
|
||||
if target_doc.doctype in ["Purchase Invoice", "Purchase Order"]:
|
||||
currency = frappe.db.get_value("Supplier", details.get("party"), "default_currency")
|
||||
@@ -2296,16 +2308,34 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
target_doc.buying_price_list = source_doc.selling_price_list
|
||||
|
||||
# Invert Addresses
|
||||
update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
|
||||
update_address(
|
||||
target_doc, "dispatch_address", "dispatch_address_display", source_doc.dispatch_address_name
|
||||
)
|
||||
update_address(
|
||||
target_doc, "shipping_address", "shipping_address_display", source_doc.shipping_address_name
|
||||
)
|
||||
update_address(
|
||||
target_doc, "billing_address", "billing_address_display", source_doc.customer_address
|
||||
)
|
||||
if source_doc.company_address and _validate_address_link(
|
||||
source_doc.company_address, "Supplier", details.get("party")
|
||||
):
|
||||
update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
|
||||
if source_doc.dispatch_address_name and _validate_address_link(
|
||||
source_doc.dispatch_address_name, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc,
|
||||
"dispatch_address",
|
||||
"dispatch_address_display",
|
||||
source_doc.dispatch_address_name,
|
||||
)
|
||||
if source_doc.shipping_address_name and _validate_address_link(
|
||||
source_doc.shipping_address_name, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc,
|
||||
"shipping_address",
|
||||
"shipping_address_display",
|
||||
source_doc.shipping_address_name,
|
||||
)
|
||||
if source_doc.customer_address and _validate_address_link(
|
||||
source_doc.customer_address, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc, "billing_address", "billing_address_display", source_doc.customer_address
|
||||
)
|
||||
|
||||
if currency:
|
||||
target_doc.currency = currency
|
||||
@@ -2326,13 +2356,22 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
target_doc.customer = details.get("party")
|
||||
target_doc.selling_price_list = source_doc.buying_price_list
|
||||
|
||||
update_address(
|
||||
target_doc, "company_address", "company_address_display", source_doc.supplier_address
|
||||
)
|
||||
update_address(
|
||||
target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address
|
||||
)
|
||||
update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address)
|
||||
if source_doc.supplier_address and _validate_address_link(
|
||||
source_doc.supplier_address, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc, "company_address", "company_address_display", source_doc.supplier_address
|
||||
)
|
||||
if source_doc.shipping_address and _validate_address_link(
|
||||
source_doc.shipping_address, "Customer", details.get("party")
|
||||
):
|
||||
update_address(
|
||||
target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address
|
||||
)
|
||||
if source_doc.shipping_address and _validate_address_link(
|
||||
source_doc.shipping_address, "Customer", details.get("party")
|
||||
):
|
||||
update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address)
|
||||
|
||||
if currency:
|
||||
target_doc.currency = currency
|
||||
|
||||
@@ -27,7 +27,7 @@ from erpnext.assets.doctype.asset.test_asset import create_asset, create_asset_d
|
||||
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
|
||||
get_depr_schedule,
|
||||
)
|
||||
from erpnext.controllers.accounts_controller import update_invoice_status
|
||||
from erpnext.controllers.accounts_controller import InvalidQtyError, update_invoice_status
|
||||
from erpnext.controllers.taxes_and_totals import get_itemised_tax_breakup_data
|
||||
from erpnext.exceptions import InvalidAccountCurrency, InvalidCurrency
|
||||
from erpnext.selling.doctype.customer.test_customer import get_customer_dict
|
||||
@@ -82,6 +82,16 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
def tearDownClass(self):
|
||||
unlink_payment_on_cancel_of_invoice(0)
|
||||
|
||||
def test_sales_invoice_qty(self):
|
||||
si = create_sales_invoice(qty=0, do_not_save=True)
|
||||
with self.assertRaises(InvalidQtyError):
|
||||
si.save()
|
||||
|
||||
# No error with qty=1
|
||||
si.items[0].qty = 1
|
||||
si.save()
|
||||
self.assertEqual(si.items[0].qty, 1)
|
||||
|
||||
def test_timestamp_change(self):
|
||||
w = frappe.copy_doc(test_records[0])
|
||||
w.docstatus = 0
|
||||
@@ -2571,6 +2581,62 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
acc_settings.book_deferred_entries_based_on = "Days"
|
||||
acc_settings.save()
|
||||
|
||||
def test_validate_inter_company_transaction_address_links(self):
|
||||
def _validate_address_link(address, link_doctype, link_name):
|
||||
return frappe.db.get_value(
|
||||
"Dynamic Link",
|
||||
{
|
||||
"parent": address,
|
||||
"parenttype": "Address",
|
||||
"link_doctype": link_doctype,
|
||||
"link_name": link_name,
|
||||
},
|
||||
"parent",
|
||||
)
|
||||
|
||||
si = create_sales_invoice(
|
||||
company="Wind Power LLC",
|
||||
customer="_Test Internal Customer",
|
||||
debit_to="Debtors - WP",
|
||||
warehouse="Stores - WP",
|
||||
income_account="Sales - WP",
|
||||
expense_account="Cost of Goods Sold - WP",
|
||||
cost_center="Main - WP",
|
||||
currency="USD",
|
||||
do_not_save=1,
|
||||
)
|
||||
|
||||
si.selling_price_list = "_Test Price List Rest of the World"
|
||||
si.submit()
|
||||
|
||||
target_doc = make_inter_company_transaction("Sales Invoice", si.name)
|
||||
target_doc.items[0].update(
|
||||
{
|
||||
"expense_account": "Cost of Goods Sold - _TC1",
|
||||
"cost_center": "Main - _TC1",
|
||||
"warehouse": "Stores - _TC1",
|
||||
}
|
||||
)
|
||||
target_doc.save()
|
||||
|
||||
if target_doc.doctype in ["Purchase Invoice", "Purchase Order"]:
|
||||
for details in [
|
||||
("supplier_address", "Supplier", target_doc.supplier),
|
||||
("dispatch_address", "Company", target_doc.company),
|
||||
("shipping_address", "Company", target_doc.company),
|
||||
("billing_address", "Company", target_doc.company),
|
||||
]:
|
||||
if address := target_doc.get(details[0]):
|
||||
self.assertEqual(address, _validate_address_link(address, details[1], details[2]))
|
||||
else:
|
||||
for details in [
|
||||
("company_address", "Company", target_doc.company),
|
||||
("shipping_address_name", "Customer", target_doc.customer),
|
||||
("customer_address", "Customer", target_doc.customer),
|
||||
]:
|
||||
if address := target_doc.get(details[0]):
|
||||
self.assertEqual(address, _validate_address_link(address, details[1], details[2]))
|
||||
|
||||
def test_inter_company_transaction(self):
|
||||
si = create_sales_invoice(
|
||||
company="Wind Power LLC",
|
||||
@@ -4379,7 +4445,7 @@ def create_sales_invoice(**args):
|
||||
bundle_id = None
|
||||
if si.update_stock and (args.get("batch_no") or args.get("serial_no")):
|
||||
batches = {}
|
||||
qty = args.qty or 1
|
||||
qty = args.qty if args.qty is not None else 1
|
||||
item_code = args.item or args.item_code or "_Test Item"
|
||||
if args.get("batch_no"):
|
||||
batches = frappe._dict({args.batch_no: qty})
|
||||
@@ -4411,7 +4477,7 @@ def create_sales_invoice(**args):
|
||||
"description": args.description or "_Test Item",
|
||||
"warehouse": args.warehouse or "_Test Warehouse - _TC",
|
||||
"target_warehouse": args.target_warehouse,
|
||||
"qty": args.qty or 1,
|
||||
"qty": args.qty if args.qty is not None else 1,
|
||||
"uom": args.uom or "Nos",
|
||||
"stock_uom": args.uom or "Nos",
|
||||
"rate": args.rate if args.get("rate") is not None else 100,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -60,6 +60,13 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
options: "Posting Date\nDue Date\nSupplier Invoice Date",
|
||||
default: "Due Date",
|
||||
},
|
||||
{
|
||||
fieldname: "calculate_ageing_with",
|
||||
label: __("Calculate Ageing With"),
|
||||
fieldtype: "Select",
|
||||
options: "Report Date\nToday Date",
|
||||
default: "Report Date",
|
||||
},
|
||||
{
|
||||
fieldname: "range",
|
||||
label: __("Ageing Range"),
|
||||
|
||||
@@ -89,6 +89,13 @@ frappe.query_reports["Accounts Receivable"] = {
|
||||
options: "Posting Date\nDue Date",
|
||||
default: "Due Date",
|
||||
},
|
||||
{
|
||||
fieldname: "calculate_ageing_with",
|
||||
label: __("Calculate Ageing With"),
|
||||
fieldtype: "Select",
|
||||
options: "Report Date\nToday Date",
|
||||
default: "Report Date",
|
||||
},
|
||||
{
|
||||
fieldname: "range",
|
||||
label: __("Ageing Range"),
|
||||
|
||||
@@ -47,7 +47,9 @@ class ReceivablePayableReport:
|
||||
self.ple = qb.DocType("Payment Ledger Entry")
|
||||
self.filters.report_date = getdate(self.filters.report_date or nowdate())
|
||||
self.age_as_on = (
|
||||
getdate(nowdate()) if self.filters.report_date > getdate(nowdate()) else self.filters.report_date
|
||||
getdate(nowdate())
|
||||
if self.filters.calculate_ageing_with == "Today Date"
|
||||
else self.filters.report_date
|
||||
)
|
||||
|
||||
if not self.filters.range:
|
||||
@@ -96,9 +98,6 @@ class ReceivablePayableReport:
|
||||
def get_data(self):
|
||||
self.get_sales_invoices_or_customers_based_on_sales_person()
|
||||
|
||||
# Build delivery note map against all sales invoices
|
||||
self.build_delivery_note_map()
|
||||
|
||||
# Get invoice details like bill_no, due_date etc for all invoices
|
||||
self.get_invoice_details()
|
||||
|
||||
@@ -106,7 +105,8 @@ class ReceivablePayableReport:
|
||||
self.get_future_payments()
|
||||
|
||||
# Get return entries
|
||||
self.get_return_entries()
|
||||
if not self.filters.party_type or self.filters.party_type in ["Customer", "Supplier"]:
|
||||
self.get_return_entries()
|
||||
|
||||
# Get Exchange Rate Revaluations
|
||||
self.get_exchange_rate_revaluations()
|
||||
@@ -120,10 +120,14 @@ class ReceivablePayableReport:
|
||||
elif self.ple_fetch_method == "UnBuffered Cursor":
|
||||
self.fetch_ple_in_unbuffered_cursor()
|
||||
|
||||
# Build delivery note map against all sales invoices
|
||||
self.build_delivery_note_map()
|
||||
|
||||
self.build_data()
|
||||
|
||||
def fetch_ple_in_buffered_cursor(self):
|
||||
self.ple_entries = frappe.db.sql(self.ple_query.get_sql(), as_dict=True)
|
||||
query, param = self.ple_query.walk()
|
||||
self.ple_entries = frappe.db.sql(query, param, as_dict=True)
|
||||
|
||||
for ple in self.ple_entries:
|
||||
self.init_voucher_balance(ple) # invoiced, paid, credit_note, outstanding
|
||||
@@ -136,8 +140,9 @@ class ReceivablePayableReport:
|
||||
|
||||
def fetch_ple_in_unbuffered_cursor(self):
|
||||
self.ple_entries = []
|
||||
query, param = self.ple_query.walk()
|
||||
with frappe.db.unbuffered_cursor():
|
||||
for ple in frappe.db.sql(self.ple_query.get_sql(), as_dict=True, as_iterator=True):
|
||||
for ple in frappe.db.sql(query, param, as_dict=True, as_iterator=True):
|
||||
self.init_voucher_balance(ple) # invoiced, paid, credit_note, outstanding
|
||||
self.ple_entries.append(ple)
|
||||
|
||||
@@ -784,7 +789,7 @@ class ReceivablePayableReport:
|
||||
self.get_ageing_data(entry_date, row)
|
||||
|
||||
# ageing buckets should not have amounts if due date is not reached
|
||||
if getdate(entry_date) > getdate(self.filters.report_date):
|
||||
if getdate(entry_date) > getdate(self.age_as_on):
|
||||
[setattr(row, f"range{i}", 0.0) for i in self.range_numbers]
|
||||
|
||||
row.total_due = sum(row[f"range{i}"] for i in self.range_numbers)
|
||||
|
||||
@@ -75,7 +75,11 @@ def execute(filters=None):
|
||||
# add first net income in operations section
|
||||
if net_profit_loss:
|
||||
net_profit_loss.update(
|
||||
{"indent": 1, "parent_section": cash_flow_sections[0]["section_header"]}
|
||||
{
|
||||
"indent": 1,
|
||||
"parent_section": cash_flow_sections[0]["section_header"],
|
||||
"section": net_profit_loss["account"],
|
||||
}
|
||||
)
|
||||
data.append(net_profit_loss)
|
||||
section_data.append(net_profit_loss)
|
||||
|
||||
@@ -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)
|
||||
@@ -405,7 +405,9 @@ class PartyLedgerSummaryReport:
|
||||
gl = qb.DocType("GL Entry")
|
||||
query = (
|
||||
qb.from_(gl)
|
||||
.select(gl.voucher_type, gl.voucher_no)
|
||||
.select(
|
||||
gl.posting_date, gl.account, gl.party, gl.voucher_type, gl.voucher_no, gl.debit, gl.credit
|
||||
)
|
||||
.where(
|
||||
(gl.docstatus < 2)
|
||||
& (gl.is_cancelled == 0)
|
||||
@@ -456,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):
|
||||
|
||||
@@ -47,12 +47,12 @@
|
||||
{% for(let j=0, k=data.length; j<k; j++) { %}
|
||||
{%
|
||||
var row = data[j];
|
||||
var row_class = data[j].parent_account ? "" : "financial-statements-important";
|
||||
row_class += data[j].account_name ? "" : " financial-statements-blank-row";
|
||||
var row_class = data[j].parent_account || data[j].parent_section ? "" : "financial-statements-important";
|
||||
row_class += data[j].account_name || data[j].section ? "" : " financial-statements-blank-row";
|
||||
%}
|
||||
<tr class="{%= row_class %}">
|
||||
<td>
|
||||
<span style="padding-left: {%= cint(data[j].indent) * 2 %}em">{%= row.account_name %}</span>
|
||||
<span style="padding-left: {%= cint(data[j].indent) * 2 %}em">{%= row.account_name || row.section %}</span>
|
||||
</td>
|
||||
{% for(let i=1, l=report_columns.length; i<l; i++) { %}
|
||||
<td class="text-right">
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fetch_from": "item_code.image",
|
||||
"fetch_if_empty": 1,
|
||||
"fieldname": "image",
|
||||
"fieldtype": "Attach Image",
|
||||
"hidden": 1,
|
||||
@@ -595,7 +596,7 @@
|
||||
"link_fieldname": "target_asset"
|
||||
}
|
||||
],
|
||||
"modified": "2025-04-24 15:31:47.373274",
|
||||
"modified": "2025-05-20 00:44:06.229177",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset",
|
||||
|
||||
@@ -42,16 +42,15 @@ from erpnext.controllers.accounts_controller import AccountsController
|
||||
|
||||
class Asset(AccountsController):
|
||||
# begin: auto-generated types
|
||||
# ruff: noqa
|
||||
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from erpnext.assets.doctype.asset_finance_book.asset_finance_book import AssetFinanceBook
|
||||
from frappe.types import DF
|
||||
|
||||
from erpnext.assets.doctype.asset_finance_book.asset_finance_book import AssetFinanceBook
|
||||
|
||||
additional_asset_cost: DF.Currency
|
||||
amended_from: DF.Link | None
|
||||
asset_category: DF.Link | None
|
||||
@@ -118,7 +117,6 @@ class Asset(AccountsController):
|
||||
total_asset_cost: DF.Currency
|
||||
total_number_of_depreciations: DF.Int
|
||||
value_after_depreciation: DF.Currency
|
||||
# ruff: noqa
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
|
||||
@@ -281,7 +281,6 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s
|
||||
if (me.frm.doc.target_item_code) {
|
||||
return me.frm.call({
|
||||
method: "erpnext.assets.doctype.asset_capitalization.asset_capitalization.get_target_item_details",
|
||||
child: me.frm.doc,
|
||||
args: {
|
||||
item_code: me.frm.doc.target_item_code,
|
||||
company: me.frm.doc.company,
|
||||
@@ -301,7 +300,6 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s
|
||||
if (me.frm.doc.target_asset) {
|
||||
return me.frm.call({
|
||||
method: "erpnext.assets.doctype.asset_capitalization.asset_capitalization.get_target_asset_details",
|
||||
child: me.frm.doc,
|
||||
args: {
|
||||
asset: me.frm.doc.target_asset,
|
||||
company: me.frm.doc.company,
|
||||
@@ -404,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,
|
||||
|
||||
@@ -875,8 +875,8 @@ def get_items_tagged_to_wip_composite_asset(params):
|
||||
"valuation_rate",
|
||||
"amount",
|
||||
"is_fixed_asset",
|
||||
"parent",
|
||||
"name",
|
||||
"parent as purchase_receipt",
|
||||
"name as purchase_receipt_item",
|
||||
]
|
||||
|
||||
pr_items = frappe.get_all(
|
||||
@@ -905,7 +905,7 @@ def process_stock_item(d):
|
||||
stock_capitalized = frappe.db.exists(
|
||||
"Asset Capitalization Stock Item",
|
||||
{
|
||||
"purchase_receipt_item": d.name,
|
||||
"purchase_receipt_item": d.purchase_receipt_item,
|
||||
"parentfield": "stock_items",
|
||||
"parenttype": "Asset Capitalization",
|
||||
"docstatus": 1,
|
||||
@@ -916,7 +916,7 @@ def process_stock_item(d):
|
||||
return None
|
||||
|
||||
stock_item_data = frappe._dict(d)
|
||||
stock_item_data.purchase_receipt_item = d.name
|
||||
stock_item_data.purchase_receipt_item = d.purchase_receipt_item
|
||||
return stock_item_data
|
||||
|
||||
|
||||
@@ -925,7 +925,7 @@ def process_fixed_asset(d):
|
||||
"Asset",
|
||||
{
|
||||
"item_code": d.item_code,
|
||||
"purchase_receipt": d.parent,
|
||||
"purchase_receipt": d.purchase_receipt,
|
||||
"status": ("not in", ["Draft", "Scrapped", "Sold", "Capitalized"]),
|
||||
},
|
||||
["name as asset", "asset_name", "company"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -153,8 +153,6 @@ class AssetMovement(Document):
|
||||
args,
|
||||
)
|
||||
|
||||
self.validate_movement_cancellation(d, latest_movement_entry)
|
||||
|
||||
if latest_movement_entry:
|
||||
current_location = latest_movement_entry[0][0]
|
||||
current_employee = latest_movement_entry[0][1]
|
||||
@@ -182,12 +180,3 @@ class AssetMovement(Document):
|
||||
d.asset,
|
||||
_("Asset issued to Employee {0}").format(get_link_to_form("Employee", current_employee)),
|
||||
)
|
||||
|
||||
def validate_movement_cancellation(self, row, latest_movement_entry):
|
||||
asset_doc = frappe.get_doc("Asset", row.asset)
|
||||
if not latest_movement_entry and asset_doc.docstatus == 1:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Asset {0} has only one movement record. Please create another movement before deleting this one to maintain asset tracking."
|
||||
).format(row.asset)
|
||||
)
|
||||
|
||||
@@ -147,45 +147,6 @@ class TestAssetMovement(unittest.TestCase):
|
||||
movement1.cancel()
|
||||
self.assertEqual(frappe.db.get_value("Asset", asset.name, "location"), "Test Location")
|
||||
|
||||
def test_last_movement_cancellation_validation(self):
|
||||
pr = make_purchase_receipt(item_code="Macbook Pro", qty=1, rate=100000.0, location="Test Location")
|
||||
|
||||
asset_name = frappe.db.get_value("Asset", {"purchase_receipt": pr.name}, "name")
|
||||
asset = frappe.get_doc("Asset", asset_name)
|
||||
asset.calculate_depreciation = 1
|
||||
asset.available_for_use_date = "2020-06-06"
|
||||
asset.purchase_date = "2020-06-06"
|
||||
asset.append(
|
||||
"finance_books",
|
||||
{
|
||||
"expected_value_after_useful_life": 10000,
|
||||
"next_depreciation_date": "2020-12-31",
|
||||
"depreciation_method": "Straight Line",
|
||||
"total_number_of_depreciations": 3,
|
||||
"frequency_of_depreciation": 10,
|
||||
},
|
||||
)
|
||||
if asset.docstatus == 0:
|
||||
asset.submit()
|
||||
|
||||
AssetMovement = frappe.qb.DocType("Asset Movement")
|
||||
AssetMovementItem = frappe.qb.DocType("Asset Movement Item")
|
||||
|
||||
asset_movement = (
|
||||
frappe.qb.from_(AssetMovement)
|
||||
.join(AssetMovementItem)
|
||||
.on(AssetMovementItem.parent == AssetMovement.name)
|
||||
.select(AssetMovement.name)
|
||||
.where(
|
||||
(AssetMovementItem.asset == asset.name)
|
||||
& (AssetMovement.company == asset.company)
|
||||
& (AssetMovement.docstatus == 1)
|
||||
)
|
||||
).run(as_dict=True)
|
||||
|
||||
asset_movement_doc = frappe.get_doc("Asset Movement", asset_movement[0].name)
|
||||
self.assertRaises(frappe.ValidationError, asset_movement_doc.cancel)
|
||||
|
||||
|
||||
def create_asset_movement(**args):
|
||||
args = frappe._dict(args)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -12,19 +12,26 @@
|
||||
"column_break_4",
|
||||
"maintain_same_rate_action",
|
||||
"role_to_override_stop_action",
|
||||
"transaction_settings_section",
|
||||
"section_break_xmlt",
|
||||
"po_required",
|
||||
"pr_required",
|
||||
"blanket_order_allowance",
|
||||
"column_break_sbwq",
|
||||
"pr_required",
|
||||
"project_update_frequency",
|
||||
"transaction_settings_section",
|
||||
"column_break_fcyl",
|
||||
"set_landed_cost_based_on_purchase_invoice_rate",
|
||||
"allow_zero_qty_in_supplier_quotation",
|
||||
"use_transaction_date_exchange_rate",
|
||||
"allow_zero_qty_in_request_for_quotation",
|
||||
"column_break_12",
|
||||
"maintain_same_rate",
|
||||
"set_landed_cost_based_on_purchase_invoice_rate",
|
||||
"allow_multiple_items",
|
||||
"bill_for_rejected_quantity_in_purchase_invoice",
|
||||
"set_valuation_rate_for_rejected_materials",
|
||||
"disable_last_purchase_rate",
|
||||
"show_pay_button",
|
||||
"use_transaction_date_exchange_rate",
|
||||
"allow_zero_qty_in_purchase_order",
|
||||
"subcontract",
|
||||
"backflush_raw_materials_of_subcontract_based_on",
|
||||
"column_break_11",
|
||||
@@ -207,14 +214,56 @@
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_xmlt",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_sbwq",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_fcyl",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "bill_for_rejected_quantity_in_purchase_invoice",
|
||||
"description": "If enabled, the system will generate an accounting entry for materials rejected in the Purchase Receipt.",
|
||||
"fieldname": "set_valuation_rate_for_rejected_materials",
|
||||
"fieldtype": "Check",
|
||||
"label": "Set Valuation Rate for Rejected Materials"
|
||||
}
|
||||
],
|
||||
"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-16 15:56:38.321369",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Buying Settings",
|
||||
|
||||
@@ -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[
|
||||
@@ -35,6 +38,7 @@ class BuyingSettings(Document):
|
||||
project_update_frequency: DF.Literal["Each Transaction", "Manual"]
|
||||
role_to_override_stop_action: DF.Link | None
|
||||
set_landed_cost_based_on_purchase_invoice_rate: DF.Check
|
||||
set_valuation_rate_for_rejected_materials: DF.Check
|
||||
show_pay_button: DF.Check
|
||||
supp_master_name: DF.Literal["Supplier Name", "Naming Series", "Auto Name"]
|
||||
supplier_group: DF.Link | None
|
||||
@@ -54,6 +58,9 @@ class BuyingSettings(Document):
|
||||
hide_name_field=False,
|
||||
)
|
||||
|
||||
if not self.bill_for_rejected_quantity_in_purchase_invoice:
|
||||
self.set_valuation_rate_for_rejected_materials = 0
|
||||
|
||||
def before_save(self):
|
||||
self.check_maintain_same_rate()
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -31,6 +31,8 @@ from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
|
||||
class TestPurchaseOrder(FrappeTestCase):
|
||||
def test_purchase_order_qty(self):
|
||||
po = create_purchase_order(qty=1, do_not_save=True)
|
||||
|
||||
# NonNegativeError with qty=-1
|
||||
po.append(
|
||||
"items",
|
||||
{
|
||||
@@ -41,9 +43,22 @@ class TestPurchaseOrder(FrappeTestCase):
|
||||
)
|
||||
self.assertRaises(frappe.NonNegativeError, po.save)
|
||||
|
||||
# InvalidQtyError with qty=0
|
||||
po.items[1].qty = 0
|
||||
self.assertRaises(InvalidQtyError, po.save)
|
||||
|
||||
# No error with qty=1
|
||||
po.items[1].qty = 1
|
||||
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)
|
||||
@@ -793,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()
|
||||
|
||||
@@ -1199,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 (
|
||||
@@ -1341,7 +1428,7 @@ def create_purchase_order(**args):
|
||||
"item_code": args.item or args.item_code or "_Test Item",
|
||||
"warehouse": args.warehouse or "_Test Warehouse - _TC",
|
||||
"from_warehouse": args.from_warehouse,
|
||||
"qty": args.qty or 10,
|
||||
"qty": args.qty if args.qty is not None else 10,
|
||||
"rate": args.rate or 500,
|
||||
"schedule_date": add_days(nowdate(), 1),
|
||||
"include_exploded_items": args.get("include_exploded_items", 1),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,9 +62,14 @@ 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()
|
||||
super().validate_qty_is_not_zero()
|
||||
validate_for_items(self)
|
||||
super().set_qty_as_per_stock_uom()
|
||||
self.update_email_id()
|
||||
@@ -72,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)):
|
||||
@@ -439,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):
|
||||
|
||||
@@ -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 (
|
||||
@@ -14,6 +14,7 @@ from erpnext.buying.doctype.request_for_quotation.request_for_quotation import (
|
||||
get_pdf,
|
||||
make_supplier_quotation_from_rfq,
|
||||
)
|
||||
from erpnext.controllers.accounts_controller import InvalidQtyError
|
||||
from erpnext.crm.doctype.opportunity.opportunity import make_request_for_quotation as make_rfq
|
||||
from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
@@ -21,6 +22,26 @@ from erpnext.templates.pages.rfq import check_supplier_has_docname_access
|
||||
|
||||
|
||||
class TestRequestforQuotation(FrappeTestCase):
|
||||
def test_rfq_qty(self):
|
||||
rfq = make_request_for_quotation(qty=0, do_not_save=True)
|
||||
with self.assertRaises(InvalidQtyError):
|
||||
rfq.save()
|
||||
|
||||
# No error with qty=1
|
||||
rfq.items[0].qty = 1
|
||||
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()
|
||||
|
||||
@@ -161,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":
|
||||
"""
|
||||
@@ -184,14 +231,17 @@ def make_request_for_quotation(**args) -> "RequestforQuotation":
|
||||
"description": "_Test Item",
|
||||
"uom": args.uom or "_Test UOM",
|
||||
"stock_uom": args.stock_uom or "_Test UOM",
|
||||
"qty": args.qty or 5,
|
||||
"qty": args.qty if args.qty is not None else 5,
|
||||
"conversion_factor": args.conversion_factor or 1.0,
|
||||
"warehouse": args.warehouse or "_Test Warehouse - _TC",
|
||||
"schedule_date": nowdate(),
|
||||
},
|
||||
)
|
||||
|
||||
rfq.submit()
|
||||
if not args.do_not_save:
|
||||
rfq.insert()
|
||||
if not args.do_not_submit:
|
||||
rfq.submit()
|
||||
|
||||
return rfq
|
||||
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -3,14 +3,26 @@
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestPurchaseOrder(FrappeTestCase):
|
||||
def test_make_purchase_order(self):
|
||||
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order
|
||||
def test_supplier_quotation_qty(self):
|
||||
sq = frappe.copy_doc(test_records[0])
|
||||
sq.items[0].qty = 0
|
||||
with self.assertRaises(InvalidQtyError):
|
||||
sq.save()
|
||||
|
||||
# No error with qty=1
|
||||
sq.items[0].qty = 1
|
||||
sq.save()
|
||||
self.assertEqual(sq.items[0].qty, 1)
|
||||
|
||||
def test_make_purchase_order(self):
|
||||
sq = frappe.copy_doc(test_records[0]).insert()
|
||||
|
||||
self.assertRaises(frappe.ValidationError, make_purchase_order, sq.name)
|
||||
@@ -30,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")
|
||||
|
||||
@@ -10,7 +10,7 @@ frappe.query_reports["Purchase Order Analysis"] = {
|
||||
width: "80",
|
||||
options: "Company",
|
||||
reqd: 1,
|
||||
default: frappe.defaults.get_default("company"),
|
||||
default: frappe.defaults.get_user_default("company"),
|
||||
},
|
||||
{
|
||||
fieldname: "from_date",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -46,11 +49,6 @@ def update_last_purchase_rate(doc, is_submit) -> None:
|
||||
def validate_for_items(doc) -> None:
|
||||
items = []
|
||||
for d in doc.get("items"):
|
||||
if not d.qty:
|
||||
if doc.doctype == "Purchase Receipt" and d.rejected_qty:
|
||||
continue
|
||||
frappe.throw(_("Please enter quantity for Item {0}").format(d.item_code))
|
||||
|
||||
set_stock_levels(row=d) # update with latest quantities
|
||||
item = validate_item_and_get_basic_data(row=d)
|
||||
validate_stock_item_warehouse(row=d, item=item)
|
||||
|
||||
@@ -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,13 +1255,18 @@ class AccountsController(TransactionBase):
|
||||
)
|
||||
|
||||
def validate_qty_is_not_zero(self):
|
||||
if self.doctype == "Purchase Receipt":
|
||||
if self.flags.allow_zero_qty:
|
||||
return
|
||||
|
||||
for item in self.items:
|
||||
if self.doctype == "Purchase Receipt" and item.rejected_qty:
|
||||
continue
|
||||
|
||||
if not flt(item.qty):
|
||||
frappe.throw(
|
||||
msg=_("Row #{0}: Item quantity cannot be zero").format(item.idx),
|
||||
msg=_("Row #{0}: Quantity for Item {1} cannot be zero.").format(
|
||||
item.idx, frappe.bold(item.item_code)
|
||||
),
|
||||
title=_("Invalid Quantity"),
|
||||
exc=InvalidQtyError,
|
||||
)
|
||||
@@ -3589,7 +3597,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
def validate_quantity(child_item, new_data):
|
||||
if not flt(new_data.get("qty")):
|
||||
frappe.throw(
|
||||
_("Row # {0}: Quantity for Item {1} cannot be zero").format(
|
||||
_("Row #{0}: Quantity for Item {1} cannot be zero.").format(
|
||||
new_data.get("idx"), frappe.bold(new_data.get("item_code"))
|
||||
),
|
||||
title=_("Invalid Qty"),
|
||||
@@ -3730,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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -650,6 +658,10 @@ class BuyingController(SubcontractingController):
|
||||
sl_entries.append(from_warehouse_sle)
|
||||
|
||||
if flt(d.rejected_qty) != 0:
|
||||
valuation_rate_for_rejected_item = 0.0
|
||||
if frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials"):
|
||||
valuation_rate_for_rejected_item = d.valuation_rate
|
||||
|
||||
sl_entries.append(
|
||||
self.get_sl_entries(
|
||||
d,
|
||||
@@ -658,7 +670,8 @@ class BuyingController(SubcontractingController):
|
||||
"actual_qty": flt(
|
||||
flt(d.rejected_qty) * flt(d.conversion_factor), d.precision("stock_qty")
|
||||
),
|
||||
"incoming_rate": 0.0,
|
||||
"incoming_rate": valuation_rate_for_rejected_item if not self.is_return else 0.0,
|
||||
"outgoing_rate": valuation_rate_for_rejected_item if self.is_return else 0.0,
|
||||
"serial_and_batch_bundle": d.rejected_serial_and_batch_bundle,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -342,7 +342,7 @@ def get_returned_qty_map_for_row(return_against, party, row_name, doctype):
|
||||
def make_return_doc(doctype: str, source_name: str, target_doc=None, return_against_rejected_qty=False):
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
|
||||
company = frappe.db.get_value("Delivery Note", source_name, "company")
|
||||
company = frappe.db.get_value(doctype, source_name, "company")
|
||||
default_warehouse_for_sales_return = frappe.get_cached_value(
|
||||
"Company", company, "default_warehouse_for_sales_return"
|
||||
)
|
||||
@@ -431,99 +431,6 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
|
||||
else:
|
||||
doc.run_method("calculate_taxes_and_totals")
|
||||
|
||||
def update_serial_batch_no(source_doc, target_doc, source_parent, item_details, qty_field):
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.serial_batch_bundle import SerialBatchCreation
|
||||
|
||||
returned_serial_nos = []
|
||||
returned_batches = frappe._dict()
|
||||
serial_and_batch_field = (
|
||||
"serial_and_batch_bundle" if qty_field == "stock_qty" else "rejected_serial_and_batch_bundle"
|
||||
)
|
||||
old_serial_no_field = "serial_no" if qty_field == "stock_qty" else "rejected_serial_no"
|
||||
old_batch_no_field = "batch_no"
|
||||
|
||||
if (
|
||||
source_doc.get(serial_and_batch_field)
|
||||
or source_doc.get(old_serial_no_field)
|
||||
or source_doc.get(old_batch_no_field)
|
||||
):
|
||||
if item_details.has_serial_no:
|
||||
returned_serial_nos = get_returned_serial_nos(
|
||||
source_doc, source_parent, serial_no_field=serial_and_batch_field
|
||||
)
|
||||
else:
|
||||
returned_batches = get_returned_batches(
|
||||
source_doc, source_parent, batch_no_field=serial_and_batch_field
|
||||
)
|
||||
|
||||
type_of_transaction = "Inward"
|
||||
if source_doc.get(serial_and_batch_field) and (
|
||||
frappe.db.get_value(
|
||||
"Serial and Batch Bundle", source_doc.get(serial_and_batch_field), "type_of_transaction"
|
||||
)
|
||||
== "Inward"
|
||||
):
|
||||
type_of_transaction = "Outward"
|
||||
elif source_parent.doctype in [
|
||||
"Purchase Invoice",
|
||||
"Purchase Receipt",
|
||||
"Subcontracting Receipt",
|
||||
]:
|
||||
type_of_transaction = "Outward"
|
||||
|
||||
warehouse = source_doc.warehouse if qty_field == "stock_qty" else source_doc.rejected_warehouse
|
||||
if source_parent.doctype in [
|
||||
"Sales Invoice",
|
||||
"POS Invoice",
|
||||
"Delivery Note",
|
||||
] and source_parent.get("is_internal_customer"):
|
||||
type_of_transaction = "Outward"
|
||||
warehouse = source_doc.target_warehouse
|
||||
|
||||
cls_obj = SerialBatchCreation(
|
||||
{
|
||||
"type_of_transaction": type_of_transaction,
|
||||
"serial_and_batch_bundle": source_doc.get(serial_and_batch_field),
|
||||
"returned_against": source_doc.name,
|
||||
"item_code": source_doc.item_code,
|
||||
"returned_serial_nos": returned_serial_nos,
|
||||
"voucher_type": source_parent.doctype,
|
||||
"do_not_submit": True,
|
||||
"warehouse": warehouse,
|
||||
"has_serial_no": item_details.has_serial_no,
|
||||
"has_batch_no": item_details.has_batch_no,
|
||||
}
|
||||
)
|
||||
|
||||
serial_nos = []
|
||||
batches = frappe._dict()
|
||||
if source_doc.get(old_batch_no_field):
|
||||
batches = frappe._dict({source_doc.batch_no: source_doc.get(qty_field)})
|
||||
elif source_doc.get(old_serial_no_field):
|
||||
serial_nos = get_serial_nos(source_doc.get(old_serial_no_field))
|
||||
elif source_doc.get(serial_and_batch_field):
|
||||
if item_details.has_serial_no:
|
||||
serial_nos = get_serial_nos_from_bundle(source_doc.get(serial_and_batch_field))
|
||||
else:
|
||||
batches = get_batches_from_bundle(source_doc.get(serial_and_batch_field))
|
||||
|
||||
if serial_nos:
|
||||
cls_obj.serial_nos = sorted(list(set(serial_nos) - set(returned_serial_nos)))
|
||||
elif batches:
|
||||
for batch in batches:
|
||||
if batch in returned_batches:
|
||||
batches[batch] -= flt(returned_batches.get(batch))
|
||||
|
||||
cls_obj.batches = batches
|
||||
|
||||
if source_doc.get(serial_and_batch_field):
|
||||
cls_obj.duplicate_package()
|
||||
if cls_obj.serial_and_batch_bundle:
|
||||
target_doc.set(serial_and_batch_field, cls_obj.serial_and_batch_bundle)
|
||||
else:
|
||||
target_doc.set(serial_and_batch_field, cls_obj.make_serial_and_batch_bundle().name)
|
||||
|
||||
def update_item(source_doc, target_doc, source_parent):
|
||||
target_doc.qty = -1 * source_doc.qty
|
||||
target_doc.pricing_rules = None
|
||||
@@ -866,8 +773,6 @@ def get_returned_serial_nos(child_doc, parent_doc, serial_no_field=None, ignore_
|
||||
|
||||
|
||||
def get_returned_batches(child_doc, parent_doc, batch_no_field=None, ignore_voucher_detail_no=None):
|
||||
from erpnext.stock.serial_batch_bundle import get_batches_from_bundle
|
||||
|
||||
batches = frappe._dict()
|
||||
|
||||
old_field = "batch_no"
|
||||
|
||||
@@ -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()
|
||||
@@ -316,9 +324,6 @@ class SellingController(StockController):
|
||||
def get_item_list(self):
|
||||
il = []
|
||||
for d in self.get("items"):
|
||||
if d.qty is None:
|
||||
frappe.throw(_("Row {0}: Qty is mandatory").format(d.idx))
|
||||
|
||||
if self.has_product_bundle(d.item_code):
|
||||
for p in self.get("packed_items"):
|
||||
if p.parent_detail_docname == d.name and p.parent_item == d.item_code:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -13,6 +13,7 @@ from frappe.utils.deprecations import deprecated
|
||||
import erpnext
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import get_exchange_rate
|
||||
from erpnext.accounts.doctype.pricing_rule.utils import get_applied_pricing_rules
|
||||
from erpnext.accounts.utils import get_currency_precision
|
||||
from erpnext.controllers.accounts_controller import (
|
||||
validate_conversion_rate,
|
||||
validate_inclusive_tax,
|
||||
@@ -674,7 +675,16 @@ class calculate_taxes_and_totals:
|
||||
tax.item_wise_tax_detail = json.dumps(tax.item_wise_tax_detail, separators=(",", ":"))
|
||||
|
||||
def set_discount_amount(self):
|
||||
if self.doc.additional_discount_percentage:
|
||||
if self.doc.discount_amount:
|
||||
self.doc.additional_discount_percentage = flt(
|
||||
flt(
|
||||
self.doc.discount_amount / flt(self.doc.get(scrub(self.doc.apply_discount_on))),
|
||||
get_currency_precision(),
|
||||
)
|
||||
* 100,
|
||||
self.doc.precision("additional_discount_percentage"),
|
||||
)
|
||||
elif self.doc.additional_discount_percentage:
|
||||
self.doc.discount_amount = flt(
|
||||
flt(self.doc.get(scrub(self.doc.apply_discount_on)))
|
||||
* self.doc.additional_discount_percentage
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -480,6 +480,10 @@ erpnext.bom.BomController = class BomController extends erpnext.TransactionContr
|
||||
child.bom_no = "";
|
||||
}
|
||||
|
||||
if (doc.item == child.item_code) {
|
||||
child.do_not_explode = 1;
|
||||
}
|
||||
|
||||
get_bom_material_detail(doc, cdt, cdn, scrap_items);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from collections import deque
|
||||
from operator import itemgetter
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe import _, bold
|
||||
from frappe.core.doctype.version.version import get_diff
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.utils import cint, cstr, flt, today
|
||||
@@ -655,9 +655,16 @@ class BOM(WebsiteGenerator):
|
||||
def check_recursion(self, bom_list=None):
|
||||
"""Check whether recursion occurs in any bom"""
|
||||
|
||||
def _throw_error(bom_name):
|
||||
def _throw_error(bom_name, production_item=None):
|
||||
msg = _("BOM recursion: {1} cannot be parent or child of {0}").format(self.name, bom_name)
|
||||
if production_item and bom_name != self.name:
|
||||
msg += "<br><br>"
|
||||
msg += _(
|
||||
"Note: If you want to use the finished good {0} as a raw material, then enable the 'Do Not Explode' checkbox in the Items table against the same raw material."
|
||||
).format(bold(production_item))
|
||||
|
||||
frappe.throw(
|
||||
_("BOM recursion: {1} cannot be parent or child of {0}").format(self.name, bom_name),
|
||||
msg,
|
||||
exc=BOMRecursionError,
|
||||
)
|
||||
|
||||
@@ -674,7 +681,7 @@ class BOM(WebsiteGenerator):
|
||||
if self.item == item.item_code and item.bom_no:
|
||||
# Same item but with different BOM should not be allowed.
|
||||
# Same item can appear recursively once as long as it doesn't have BOM.
|
||||
_throw_error(item.bom_no)
|
||||
_throw_error(item.bom_no, self.item)
|
||||
|
||||
if self.name in {d.bom_no for d in self.items}:
|
||||
_throw_error(self.name)
|
||||
|
||||
@@ -343,6 +343,7 @@ def get_children(doctype=None, parent=None, **kwargs):
|
||||
|
||||
fields = [
|
||||
"item_code as value",
|
||||
"item_name as title",
|
||||
"is_expandable as expandable",
|
||||
"parent as parent_id",
|
||||
"qty",
|
||||
|
||||
@@ -705,7 +705,7 @@ class JobCard(Document):
|
||||
bold("Job Card"), get_link_to_form("Job Card", self.name)
|
||||
)
|
||||
)
|
||||
else:
|
||||
elif frappe.db.get_single_value("Manufacturing Settings", "enforce_time_logs"):
|
||||
for row in self.time_logs:
|
||||
if not row.from_time or not row.to_time:
|
||||
frappe.throw(
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"overproduction_percentage_for_work_order",
|
||||
"job_card_section",
|
||||
"add_corrective_operation_cost_in_finished_good_valuation",
|
||||
"enforce_time_logs",
|
||||
"column_break_24",
|
||||
"job_card_excess_transfer",
|
||||
"capacity_planning",
|
||||
@@ -235,13 +236,20 @@
|
||||
"fieldname": "set_op_cost_and_scrap_from_sub_assemblies",
|
||||
"fieldtype": "Check",
|
||||
"label": "Set Operating Cost / Scrap Items From Sub-assemblies"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Enabling this checkbox will force each Job Card Time Log to have From Time and To Time",
|
||||
"fieldname": "enforce_time_logs",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enforce Time Logs"
|
||||
}
|
||||
],
|
||||
"icon": "icon-wrench",
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-02-05 16:11:11.639916",
|
||||
"modified": "2025-05-16 11:23:16.916512",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Manufacturing Settings",
|
||||
@@ -259,4 +267,4 @@
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ class ManufacturingSettings(Document):
|
||||
default_scrap_warehouse: DF.Link | None
|
||||
default_wip_warehouse: DF.Link | None
|
||||
disable_capacity_planning: DF.Check
|
||||
enforce_time_logs: DF.Check
|
||||
get_rm_cost_from_consumption_entry: DF.Check
|
||||
job_card_excess_transfer: DF.Check
|
||||
make_serial_no_batch_from_work_order: DF.Check
|
||||
|
||||
@@ -23,6 +23,7 @@ def get_columns():
|
||||
"""return columns"""
|
||||
columns = [
|
||||
_("Item") + ":Link/Item:150",
|
||||
_("Item Name") + "::240",
|
||||
_("Description") + "::300",
|
||||
_("BOM Qty") + ":Float:160",
|
||||
_("BOM UoM") + "::160",
|
||||
@@ -73,6 +74,7 @@ def get_bom_stock(filters):
|
||||
.on((BOM_ITEM.item_code == BIN.item_code) & (CONDITIONS))
|
||||
.select(
|
||||
BOM_ITEM.item_code,
|
||||
BOM_ITEM.item_name,
|
||||
BOM_ITEM.description,
|
||||
BOM_ITEM.stock_qty,
|
||||
BOM_ITEM.stock_uom,
|
||||
|
||||
@@ -94,6 +94,7 @@ def get_expected_data(bom, warehouse, qty_to_produce, show_exploded_view=False):
|
||||
expected_data.append(
|
||||
[
|
||||
item.item_code,
|
||||
item.item_name,
|
||||
item.description,
|
||||
item.stock_qty,
|
||||
item.stock_uom,
|
||||
|
||||
@@ -404,3 +404,7 @@ 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
|
||||
erpnext.patches.v15_0.drop_sle_indexes
|
||||
|
||||
15
erpnext/patches/v14_0/update_full_name_in_contract.py
Normal file
15
erpnext/patches/v14_0/update_full_name_in_contract.py
Normal 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)
|
||||
17
erpnext/patches/v15_0/drop_sle_indexes.py
Normal file
17
erpnext/patches/v15_0/drop_sle_indexes.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import click
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
table = "tabStock Ledger Entry"
|
||||
index_list = ["posting_datetime_creation_index", "item_warehouse"]
|
||||
|
||||
for index in index_list:
|
||||
if not frappe.db.has_index(table, index):
|
||||
continue
|
||||
|
||||
try:
|
||||
frappe.db.sql_ddl(f"ALTER TABLE `{table}` DROP INDEX `{index}`")
|
||||
click.echo(f"✓ dropped {index} index from {table}")
|
||||
except Exception:
|
||||
frappe.log_error("Failed to drop index")
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -7,7 +7,7 @@ import json
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import add_to_date, flt, get_datetime, getdate, time_diff_in_hours
|
||||
from frappe.utils import add_to_date, flt, get_datetime, getdate, time_diff_in_hours, time_diff_in_seconds
|
||||
|
||||
from erpnext.controllers.queries import get_match_cond
|
||||
from erpnext.setup.utils import get_exchange_rate
|
||||
@@ -194,7 +194,7 @@ class Timesheet(Document):
|
||||
return
|
||||
|
||||
_to_time = get_datetime(add_to_date(data.from_time, hours=data.hours, as_datetime=True))
|
||||
if data.to_time != _to_time:
|
||||
if abs(time_diff_in_seconds(_to_time, data.to_time)) >= 1:
|
||||
data.to_time = _to_time
|
||||
|
||||
def validate_overlap(self, data):
|
||||
|
||||
@@ -64,7 +64,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
&& this.frm.doc.is_pos
|
||||
&& this.frm.doc.is_return
|
||||
) {
|
||||
this.set_total_amount_to_default_mop();
|
||||
await this.set_total_amount_to_default_mop();
|
||||
this.calculate_paid_amount();
|
||||
}
|
||||
|
||||
@@ -896,23 +896,25 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
it should set the return to that mode of payment only.
|
||||
*/
|
||||
|
||||
let return_against_mop = await frappe.call({
|
||||
method: 'erpnext.controllers.sales_and_purchase_return.get_payment_data',
|
||||
args: {
|
||||
invoice: this.frm.doc.return_against
|
||||
}
|
||||
});
|
||||
|
||||
if (return_against_mop.message.length === 1) {
|
||||
this.frm.doc.payments.forEach(payment => {
|
||||
if (payment.mode_of_payment == return_against_mop.message[0].mode_of_payment) {
|
||||
payment.amount = total_amount_to_pay;
|
||||
} else {
|
||||
payment.amount = 0;
|
||||
if(this.frm.doc.return_against){
|
||||
let {message : return_against_mop } = await frappe.call({
|
||||
method: 'erpnext.controllers.sales_and_purchase_return.get_payment_data',
|
||||
args: {
|
||||
invoice: this.frm.doc.return_against
|
||||
}
|
||||
});
|
||||
this.frm.refresh_fields();
|
||||
return;
|
||||
|
||||
if (return_against_mop.length === 1) {
|
||||
this.frm.doc.payments.forEach(payment => {
|
||||
if (payment.mode_of_payment == return_against_mop[0].mode_of_payment) {
|
||||
payment.amount = total_amount_to_pay;
|
||||
} else {
|
||||
payment.amount = 0;
|
||||
}
|
||||
});
|
||||
this.frm.refresh_fields();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.frm.doc.payments.find(payment => {
|
||||
|
||||
@@ -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"));
|
||||
@@ -844,8 +870,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
frappe.model.set_value(item.doctype, item.name, "stock_qty", valid_serial_nos.length);
|
||||
}
|
||||
|
||||
validate() {
|
||||
this.calculate_taxes_and_totals(false);
|
||||
async validate() {
|
||||
await this.calculate_taxes_and_totals(false);
|
||||
}
|
||||
|
||||
update_stock() {
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@ erpnext.financial_statements = {
|
||||
}
|
||||
}
|
||||
|
||||
if (data && column.fieldname == "account") {
|
||||
if (data && column.fieldname == this.name_field) {
|
||||
// first column
|
||||
value = data.section_name || data.account_name || value;
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
@@ -278,7 +286,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldtype: "Data",
|
||||
fieldtype: "Text Editor",
|
||||
fieldname: "description",
|
||||
label: __("Description"),
|
||||
in_list_view: 1,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,30 +162,34 @@ class Quotation(SellingController):
|
||||
if not row.is_alternative and row.name in items_with_alternatives:
|
||||
row.has_alternative_item = 1
|
||||
|
||||
def get_ordered_status(self):
|
||||
status = "Open"
|
||||
ordered_items = frappe._dict(
|
||||
frappe.db.get_all(
|
||||
"Sales Order Item",
|
||||
{"prevdoc_docname": self.name, "docstatus": 1},
|
||||
["item_code", "sum(qty)"],
|
||||
group_by="item_code",
|
||||
as_list=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):
|
||||
ordered_items = get_ordered_items(self.name)
|
||||
|
||||
if not ordered_items:
|
||||
return status
|
||||
return "Open"
|
||||
|
||||
has_alternatives = any(row.is_alternative for row in self.get("items"))
|
||||
self._items = self.get_valid_items() if has_alternatives else self.get("items")
|
||||
self._items = (
|
||||
self.get_valid_items()
|
||||
if any(row.is_alternative for row in self.get("items"))
|
||||
else self.get("items")
|
||||
)
|
||||
|
||||
if any(row.qty > ordered_items.get(row.item_code, 0.0) for row in self._items):
|
||||
status = "Partially Ordered"
|
||||
else:
|
||||
status = "Ordered"
|
||||
for row in self._items:
|
||||
if row.name not in ordered_items or row.qty > ordered_items[row.name]:
|
||||
return "Partially Ordered"
|
||||
|
||||
return status
|
||||
return "Ordered"
|
||||
|
||||
def get_valid_items(self):
|
||||
"""
|
||||
@@ -355,18 +364,16 @@ def make_sales_order(source_name: str, target_doc=None):
|
||||
|
||||
def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
|
||||
customer = _make_customer(source_name, ignore_permissions)
|
||||
ordered_items = frappe._dict(
|
||||
frappe.db.get_all(
|
||||
"Sales Order Item",
|
||||
{"prevdoc_docname": source_name, "docstatus": 1},
|
||||
["item_code", "sum(qty)"],
|
||||
group_by="item_code",
|
||||
as_list=1,
|
||||
)
|
||||
)
|
||||
ordered_items = get_ordered_items(source_name)
|
||||
|
||||
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 +402,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.name, 0.0)
|
||||
target.qty = balance_qty if balance_qty > 0 else 0
|
||||
target.stock_qty = flt(target.qty) * flt(obj.conversion_factor)
|
||||
|
||||
@@ -409,22 +416,19 @@ 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:
|
||||
if not ((item.qty > ordered_items.get(item.name, 0.0)) or is_unit_price_row(item)):
|
||||
return False
|
||||
|
||||
has_qty = balance_qty
|
||||
|
||||
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",
|
||||
@@ -581,3 +585,28 @@ def handle_mandatory_error(e, customer, lead_name):
|
||||
message += _("Please create Customer from Lead {0}.").format(get_link_to_form("Lead", lead_name))
|
||||
|
||||
frappe.throw(message, title=_("Mandatory Missing"))
|
||||
|
||||
|
||||
def get_ordered_items(quotation: str):
|
||||
"""
|
||||
Returns a dict of ordered items with their total qty based on quotation row name.
|
||||
|
||||
In `Sales Order Item`, `quotation_item` is the row name of `Quotation Item`.
|
||||
|
||||
Example:
|
||||
```
|
||||
{
|
||||
"refsdjhd2": 10,
|
||||
"ygdhdshrt": 5,
|
||||
}
|
||||
```
|
||||
"""
|
||||
return frappe._dict(
|
||||
frappe.get_all(
|
||||
"Sales Order Item",
|
||||
filters={"prevdoc_docname": quotation, "docstatus": 1},
|
||||
fields=["quotation_item", "sum(qty)"],
|
||||
group_by="quotation_item",
|
||||
as_list=1,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,13 +2,34 @@
|
||||
# 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
|
||||
|
||||
test_dependencies = ["Product Bundle"]
|
||||
|
||||
|
||||
class TestQuotation(FrappeTestCase):
|
||||
def test_quotation_qty(self):
|
||||
qo = make_quotation(qty=0, do_not_save=True)
|
||||
with self.assertRaises(InvalidQtyError):
|
||||
qo.save()
|
||||
|
||||
# No error with qty=1
|
||||
qo.items[0].qty = 1
|
||||
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"))
|
||||
@@ -761,6 +782,85 @@ 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")
|
||||
|
||||
def test_duplicate_items_in_quotation(self):
|
||||
from erpnext.selling.doctype.quotation.quotation import make_sales_order
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
# item code same but description different
|
||||
make_item("_Test Item 2", {"is_stock_item": 1})
|
||||
|
||||
quotation = make_quotation(qty=1, rate=100, do_not_submit=1)
|
||||
|
||||
# duplicate items
|
||||
for qty in [1, 1, 2, 3]:
|
||||
quotation.append("items", {"item_code": "_Test Item", "qty": qty, "rate": 100})
|
||||
|
||||
quotation.append("items", {"item_code": "_Test Item 2", "qty": 5, "rate": 100})
|
||||
|
||||
quotation.submit()
|
||||
|
||||
sales_order = make_sales_order(quotation.name)
|
||||
sales_order.delivery_date = nowdate()
|
||||
|
||||
self.assertEqual(len(sales_order.items), 6)
|
||||
self.assertEqual(sales_order.items[0].qty, 1)
|
||||
self.assertEqual(sales_order.items[-1].qty, 5)
|
||||
|
||||
# Row 1: 10, Row 4: 1, Row 5: 1
|
||||
sales_order.items[0].qty = 10
|
||||
sales_order.items[3].qty = 1
|
||||
sales_order.items[4].qty = 1
|
||||
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(len(sales_order_2.items), 2)
|
||||
self.assertEqual(sales_order_2.items[0].qty, 1)
|
||||
self.assertEqual(sales_order_2.items[1].qty, 2)
|
||||
|
||||
self.assertEqual(sales_order_2.items[0].quotation_item, quotation.items[3].name)
|
||||
self.assertEqual(sales_order_2.items[1].quotation_item, quotation.items[4].name)
|
||||
|
||||
sales_order_2.submit()
|
||||
quotation.reload()
|
||||
self.assertEqual(quotation.status, "Ordered")
|
||||
|
||||
|
||||
test_records = frappe.get_test_records("Quotation")
|
||||
|
||||
@@ -809,7 +909,7 @@ def make_quotation(**args):
|
||||
{
|
||||
"item_code": args.item or args.item_code or "_Test Item",
|
||||
"warehouse": args.warehouse,
|
||||
"qty": args.qty or 10,
|
||||
"qty": args.qty if args.qty is not None else 10,
|
||||
"uom": args.uom or None,
|
||||
"rate": args.rate or 100,
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
@@ -1328,6 +1369,7 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t
|
||||
{
|
||||
"Sales Order": {
|
||||
"doctype": "Purchase Order",
|
||||
"field_map": {"dispatch_address_name": "dispatch_address"},
|
||||
"field_no_map": [
|
||||
"address_display",
|
||||
"contact_display",
|
||||
@@ -1447,6 +1489,7 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None):
|
||||
{
|
||||
"Sales Order": {
|
||||
"doctype": "Purchase Order",
|
||||
"field_map": {"dispatch_address_name": "dispatch_address"},
|
||||
"field_no_map": [
|
||||
"address_display",
|
||||
"contact_display",
|
||||
|
||||
@@ -17,6 +17,7 @@ def get_data():
|
||||
"Quotation": ["items", "prevdoc_docname"],
|
||||
"BOM": ["items", "bom_no"],
|
||||
"Blanket Order": ["items", "blanket_order"],
|
||||
"Purchase Order": ["items", "purchase_order"],
|
||||
},
|
||||
"transactions": [
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ from frappe.tests.utils import FrappeTestCase, change_settings
|
||||
from frappe.utils import add_days, flt, getdate, nowdate, today
|
||||
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
from erpnext.controllers.accounts_controller import update_child_qty_rate
|
||||
from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate
|
||||
from erpnext.maintenance.doctype.maintenance_schedule.test_maintenance_schedule import (
|
||||
make_maintenance_schedule,
|
||||
)
|
||||
@@ -86,6 +86,36 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
|
||||
)
|
||||
update_child_qty_rate("Sales Order", trans_item, so.name)
|
||||
|
||||
def test_sales_order_qty(self):
|
||||
so = make_sales_order(qty=1, do_not_save=True)
|
||||
|
||||
# NonNegativeError with qty=-1
|
||||
so.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"qty": -1,
|
||||
"rate": 10,
|
||||
},
|
||||
)
|
||||
self.assertRaises(frappe.NonNegativeError, so.save)
|
||||
|
||||
# InvalidQtyError with qty=0
|
||||
so.items[1].qty = 0
|
||||
self.assertRaises(InvalidQtyError, so.save)
|
||||
|
||||
# No error with qty=1
|
||||
so.items[1].qty = 1
|
||||
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)
|
||||
|
||||
@@ -2283,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")
|
||||
@@ -2325,7 +2426,7 @@ def make_sales_order(**args):
|
||||
{
|
||||
"item_code": args.item or args.item_code or "_Test Item",
|
||||
"warehouse": args.warehouse,
|
||||
"qty": args.qty or 10,
|
||||
"qty": args.qty if args.qty is not None else 10,
|
||||
"uom": args.uom or None,
|
||||
"price_list_rate": args.price_list_rate or None,
|
||||
"discount_percentage": args.discount_percentage or None,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -148,7 +148,7 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_te
|
||||
bin_join_selection, bin_join_condition = "", ""
|
||||
if hide_unavailable_items:
|
||||
bin_join_selection = "LEFT JOIN `tabBin` bin ON bin.item_code = item.name"
|
||||
bin_join_condition = "AND item.is_stock_item = 0 OR (item.is_stock_item = 1 AND bin.warehouse = %(warehouse)s AND bin.actual_qty > 0)"
|
||||
bin_join_condition = "AND (item.is_stock_item = 0 OR (item.is_stock_item = 1 AND bin.warehouse = %(warehouse)s AND bin.actual_qty > 0))"
|
||||
|
||||
items_data = frappe.db.sql(
|
||||
"""
|
||||
|
||||
@@ -177,14 +177,17 @@ class Analytics:
|
||||
entity = "supplier as entity"
|
||||
entity_name = "supplier_name as entity_name"
|
||||
|
||||
filters = {
|
||||
"docstatus": 1,
|
||||
"company": ["in", self.filters.company],
|
||||
self.date_field: ("between", [self.filters.from_date, self.filters.to_date]),
|
||||
}
|
||||
|
||||
if self.filters.doc_type in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]:
|
||||
filters.update({"is_opening": "No"})
|
||||
|
||||
self.entries = frappe.get_all(
|
||||
self.filters.doc_type,
|
||||
fields=[entity, entity_name, value_field, self.date_field],
|
||||
filters={
|
||||
"docstatus": 1,
|
||||
"company": ["in", self.filters.company],
|
||||
self.date_field: ("between", [self.filters.from_date, self.filters.to_date]),
|
||||
},
|
||||
self.filters.doc_type, fields=[entity, entity_name, value_field, self.date_field], filters=filters
|
||||
)
|
||||
|
||||
self.entity_names = {}
|
||||
@@ -236,14 +239,19 @@ class Analytics:
|
||||
else:
|
||||
entity_field = "territory as entity"
|
||||
|
||||
filters = {
|
||||
"docstatus": 1,
|
||||
"company": ["in", self.filters.company],
|
||||
self.date_field: ("between", [self.filters.from_date, self.filters.to_date]),
|
||||
}
|
||||
|
||||
if self.filters.doc_type in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]:
|
||||
filters.update({"is_opening": "No"})
|
||||
|
||||
self.entries = frappe.get_all(
|
||||
self.filters.doc_type,
|
||||
fields=[entity_field, value_field, self.date_field],
|
||||
filters={
|
||||
"docstatus": 1,
|
||||
"company": ["in", self.filters.company],
|
||||
self.date_field: ("between", [self.filters.from_date, self.filters.to_date]),
|
||||
},
|
||||
filters=filters,
|
||||
)
|
||||
self.get_groups()
|
||||
|
||||
@@ -282,15 +290,18 @@ class Analytics:
|
||||
|
||||
entity = "project as entity"
|
||||
|
||||
filters = {
|
||||
"docstatus": 1,
|
||||
"company": ["in", self.filters.company],
|
||||
"project": ["!=", ""],
|
||||
self.date_field: ("between", [self.filters.from_date, self.filters.to_date]),
|
||||
}
|
||||
|
||||
if self.filters.doc_type in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]:
|
||||
filters.update({"is_opening": "No"})
|
||||
|
||||
self.entries = frappe.get_all(
|
||||
self.filters.doc_type,
|
||||
fields=[entity, value_field, self.date_field],
|
||||
filters={
|
||||
"docstatus": 1,
|
||||
"company": ["in", self.filters.company],
|
||||
"project": ["!=", ""],
|
||||
self.date_field: ("between", [self.filters.from_date, self.filters.to_date]),
|
||||
},
|
||||
self.filters.doc_type, fields=[entity, value_field, self.date_field], filters=filters
|
||||
)
|
||||
|
||||
def get_rows(self):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -681,16 +681,20 @@ def update_billed_amount_based_on_so(so_detail, update_modified=True):
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
# Billed against Sales Order directly
|
||||
si = frappe.qb.DocType("Sales Invoice").as_("si")
|
||||
si_item = frappe.qb.DocType("Sales Invoice Item").as_("si_item")
|
||||
sum_amount = Sum(si_item.amount).as_("amount")
|
||||
|
||||
billed_against_so = (
|
||||
frappe.qb.from_(si_item)
|
||||
.join(si)
|
||||
.on(si.name == si_item.parent)
|
||||
.select(sum_amount)
|
||||
.where(
|
||||
(si_item.so_detail == so_detail)
|
||||
& ((si_item.dn_detail.isnull()) | (si_item.dn_detail == ""))
|
||||
& (si_item.docstatus == 1)
|
||||
& (si.update_stock == 0)
|
||||
)
|
||||
.run()
|
||||
)
|
||||
@@ -1152,6 +1156,18 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
frappe.throw(_("All items have already been received"))
|
||||
|
||||
def update_details(source_doc, target_doc, source_parent):
|
||||
def _validate_address_link(address, link_doctype, link_name):
|
||||
return frappe.db.get_value(
|
||||
"Dynamic Link",
|
||||
{
|
||||
"parent": address,
|
||||
"parenttype": "Address",
|
||||
"link_doctype": link_doctype,
|
||||
"link_name": link_name,
|
||||
},
|
||||
"parent",
|
||||
)
|
||||
|
||||
target_doc.inter_company_invoice_reference = source_doc.name
|
||||
if target_doc.doctype == "Purchase Receipt":
|
||||
target_doc.company = details.get("company")
|
||||
@@ -1161,16 +1177,34 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
target_doc.inter_company_reference = source_doc.name
|
||||
|
||||
# Invert the address on target doc creation
|
||||
update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
|
||||
update_address(
|
||||
target_doc, "dispatch_address", "dispatch_address_display", source_doc.dispatch_address_name
|
||||
)
|
||||
update_address(
|
||||
target_doc, "shipping_address", "shipping_address_display", source_doc.shipping_address_name
|
||||
)
|
||||
update_address(
|
||||
target_doc, "billing_address", "billing_address_display", source_doc.customer_address
|
||||
)
|
||||
if source_doc.company_address and _validate_address_link(
|
||||
source_doc.company_address, "Supplier", details.get("party")
|
||||
):
|
||||
update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
|
||||
if source_doc.dispatch_address_name and _validate_address_link(
|
||||
source_doc.dispatch_address_name, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc,
|
||||
"dispatch_address",
|
||||
"dispatch_address_display",
|
||||
source_doc.dispatch_address_name,
|
||||
)
|
||||
if source_doc.shipping_address_name and _validate_address_link(
|
||||
source_doc.shipping_address_name, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc,
|
||||
"shipping_address",
|
||||
"shipping_address_display",
|
||||
source_doc.shipping_address_name,
|
||||
)
|
||||
if source_doc.customer_address and _validate_address_link(
|
||||
source_doc.customer_address, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc, "billing_address", "billing_address_display", source_doc.customer_address
|
||||
)
|
||||
|
||||
update_taxes(
|
||||
target_doc,
|
||||
@@ -1190,13 +1224,22 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
target_doc.inter_company_reference = source_doc.name
|
||||
|
||||
# Invert the address on target doc creation
|
||||
update_address(
|
||||
target_doc, "company_address", "company_address_display", source_doc.supplier_address
|
||||
)
|
||||
update_address(
|
||||
target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address
|
||||
)
|
||||
update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address)
|
||||
if source_doc.supplier_address and _validate_address_link(
|
||||
source_doc.supplier_address, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc, "company_address", "company_address_display", source_doc.supplier_address
|
||||
)
|
||||
if source_doc.shipping_address and _validate_address_link(
|
||||
source_doc.shipping_address, "Customer", details.get("party")
|
||||
):
|
||||
update_address(
|
||||
target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address
|
||||
)
|
||||
if source_doc.shipping_address and _validate_address_link(
|
||||
source_doc.shipping_address, "Customer", details.get("party")
|
||||
):
|
||||
update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address)
|
||||
|
||||
update_taxes(
|
||||
target_doc,
|
||||
|
||||
@@ -12,6 +12,7 @@ from frappe.utils import add_days, cstr, flt, getdate, nowdate, nowtime, today
|
||||
from erpnext.accounts.doctype.account.test_account import get_inventory_account
|
||||
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
|
||||
from erpnext.accounts.utils import get_balance_on
|
||||
from erpnext.controllers.accounts_controller import InvalidQtyError
|
||||
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import (
|
||||
automatically_fetch_payment_terms,
|
||||
@@ -44,6 +45,16 @@ from erpnext.stock.stock_ledger import get_previous_sle
|
||||
|
||||
|
||||
class TestDeliveryNote(FrappeTestCase):
|
||||
def test_delivery_note_qty(self):
|
||||
dn = create_delivery_note(qty=0, do_not_save=True)
|
||||
with self.assertRaises(InvalidQtyError):
|
||||
dn.save()
|
||||
|
||||
# No error with qty=1
|
||||
dn.items[0].qty = 1
|
||||
dn.save()
|
||||
self.assertEqual(dn.items[0].qty, 1)
|
||||
|
||||
def test_over_billing_against_dn(self):
|
||||
frappe.db.set_single_value("Stock Settings", "allow_negative_stock", 1)
|
||||
|
||||
@@ -1084,6 +1095,28 @@ class TestDeliveryNote(FrappeTestCase):
|
||||
self.assertEqual(dn.per_billed, 100)
|
||||
self.assertEqual(dn.status, "Completed")
|
||||
|
||||
def test_dn_billing_status_case5(self):
|
||||
# SO -> SI(with update stock partial invoice)
|
||||
# SO -> DN
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note, make_sales_invoice
|
||||
|
||||
so = make_sales_order(po_no="12345")
|
||||
|
||||
si = make_sales_invoice(so.name)
|
||||
si.get("items")[0].qty = 5
|
||||
si.update_stock = 1
|
||||
si.submit()
|
||||
|
||||
# Testing if Customer's Purchase Order No was rightly copied
|
||||
self.assertEqual(so.po_no, si.po_no)
|
||||
|
||||
dn = make_delivery_note(so.name)
|
||||
dn.submit()
|
||||
|
||||
self.assertEqual(dn.get("items")[0].billed_amt, 0)
|
||||
self.assertEqual(dn.per_billed, 0)
|
||||
self.assertEqual(dn.status, "To Bill")
|
||||
|
||||
def test_delivery_trip(self):
|
||||
dn = create_delivery_note()
|
||||
dt = make_delivery_trip(dn.name)
|
||||
@@ -2564,7 +2597,7 @@ def create_delivery_note(**args):
|
||||
if dn.is_return:
|
||||
type_of_transaction = "Inward"
|
||||
|
||||
qty = args.get("qty") or 1
|
||||
qty = args.qty if args.get("qty") is not None else 1
|
||||
qty *= -1 if type_of_transaction == "Outward" else 1
|
||||
batches = {}
|
||||
if args.get("batch_no"):
|
||||
@@ -2595,7 +2628,7 @@ def create_delivery_note(**args):
|
||||
{
|
||||
"item_code": args.item or args.item_code or "_Test Item",
|
||||
"warehouse": args.warehouse or "_Test Warehouse - _TC",
|
||||
"qty": args.qty or 1,
|
||||
"qty": args.qty if args.get("qty") is not None else 1,
|
||||
"rate": args.rate if args.get("rate") is not None else 100,
|
||||
"conversion_factor": 1.0,
|
||||
"serial_and_batch_bundle": bundle_id,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -643,6 +643,7 @@ def make_supplier_quotation(source_name, target_doc=None):
|
||||
postprocess,
|
||||
)
|
||||
|
||||
doclist.set_onload("load_after_mapping", False)
|
||||
return doclist
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import flt, today
|
||||
|
||||
from erpnext.controllers.accounts_controller import InvalidQtyError
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.stock.doctype.material_request.material_request import (
|
||||
make_in_transit_stock_entry,
|
||||
@@ -21,6 +22,17 @@ from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
|
||||
class TestMaterialRequest(FrappeTestCase):
|
||||
def test_material_request_qty(self):
|
||||
mr = frappe.copy_doc(test_records[0])
|
||||
mr.items[0].qty = 0
|
||||
with self.assertRaises(InvalidQtyError):
|
||||
mr.insert()
|
||||
|
||||
# No error with qty=1
|
||||
mr.items[0].qty = 1
|
||||
mr.save()
|
||||
self.assertEqual(mr.items[0].qty, 1)
|
||||
|
||||
def test_make_purchase_order(self):
|
||||
mr = frappe.copy_doc(test_records[0]).insert()
|
||||
|
||||
|
||||
@@ -496,6 +496,12 @@ class PurchaseReceipt(BuyingController):
|
||||
outgoing_amount = abs(get_stock_value_difference(self.name, item.name, item.from_warehouse))
|
||||
credit_amount = outgoing_amount
|
||||
|
||||
if item.get("rejected_qty") and frappe.db.get_single_value(
|
||||
"Buying Settings", "set_valuation_rate_for_rejected_materials"
|
||||
):
|
||||
outgoing_amount += get_stock_value_difference(self.name, item.name, item.rejected_warehouse)
|
||||
credit_amount = outgoing_amount
|
||||
|
||||
if credit_amount:
|
||||
if not account:
|
||||
validate_account("Stock or Asset Received But Not Billed")
|
||||
@@ -629,6 +635,12 @@ class PurchaseReceipt(BuyingController):
|
||||
valuation_amount_as_per_doc - flt(stock_value_diff), item.precision("base_net_amount")
|
||||
)
|
||||
|
||||
if item.get("rejected_qty") and frappe.db.get_single_value(
|
||||
"Buying Settings", "set_valuation_rate_for_rejected_materials"
|
||||
):
|
||||
rejected_item_cost = get_stock_value_difference(self.name, item.name, item.rejected_warehouse)
|
||||
divisional_loss -= rejected_item_cost
|
||||
|
||||
if divisional_loss:
|
||||
loss_account = (
|
||||
self.get_company_default("default_expense_account", ignore_validation=True)
|
||||
@@ -726,13 +738,23 @@ class PurchaseReceipt(BuyingController):
|
||||
make_sub_contracting_gl_entries(d)
|
||||
make_divisional_loss_gl_entry(d, outgoing_amount)
|
||||
elif (d.warehouse and d.warehouse not in warehouse_with_no_account) or (
|
||||
d.rejected_warehouse and d.rejected_warehouse not in warehouse_with_no_account
|
||||
not frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials")
|
||||
and d.rejected_warehouse
|
||||
and d.rejected_warehouse not in warehouse_with_no_account
|
||||
):
|
||||
warehouse_with_no_account.append(d.warehouse or d.rejected_warehouse)
|
||||
|
||||
if d.is_fixed_asset and d.landed_cost_voucher_amount:
|
||||
self.update_assets(d, d.valuation_rate)
|
||||
|
||||
if d.rejected_qty and frappe.db.get_single_value(
|
||||
"Buying Settings", "set_valuation_rate_for_rejected_materials"
|
||||
):
|
||||
stock_value_diff = get_stock_value_difference(self.name, d.name, d.rejected_warehouse)
|
||||
stock_asset_account_name = warehouse_account[d.rejected_warehouse]["account"]
|
||||
|
||||
make_item_asset_inward_gl_entry(d, stock_value_diff, stock_asset_account_name)
|
||||
|
||||
if warehouse_with_no_account:
|
||||
frappe.msgprint(
|
||||
_("No accounting entries for the following warehouses")
|
||||
@@ -1106,6 +1128,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 +1146,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(
|
||||
|
||||
@@ -11,6 +11,7 @@ import erpnext.controllers
|
||||
import erpnext.controllers.status_updater
|
||||
from erpnext.accounts.doctype.account.test_account import get_inventory_account
|
||||
from erpnext.buying.doctype.supplier.test_supplier import create_supplier
|
||||
from erpnext.controllers.accounts_controller import InvalidQtyError
|
||||
from erpnext.controllers.buying_controller import QtyMismatchError
|
||||
from erpnext.stock import get_warehouse_account_map
|
||||
from erpnext.stock.doctype.item.test_item import create_item, make_item
|
||||
@@ -32,6 +33,23 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
def setUp(self):
|
||||
frappe.db.set_single_value("Buying Settings", "allow_multiple_items", 1)
|
||||
|
||||
def test_purchase_receipt_qty(self):
|
||||
pr = make_purchase_receipt(qty=0, rejected_qty=0, do_not_save=True)
|
||||
with self.assertRaises(InvalidQtyError):
|
||||
pr.save()
|
||||
|
||||
# No error with qty=1
|
||||
pr.items[0].qty = 1
|
||||
pr.save()
|
||||
self.assertEqual(pr.items[0].qty, 1)
|
||||
|
||||
# No error with rejected_qty=1
|
||||
pr.items[0].rejected_warehouse = "_Test Rejected Warehouse - _TC"
|
||||
pr.items[0].rejected_qty = 1
|
||||
pr.items[0].qty = 0
|
||||
pr.save()
|
||||
self.assertEqual(pr.items[0].rejected_qty, 1)
|
||||
|
||||
def test_purchase_receipt_received_qty(self):
|
||||
"""
|
||||
1. Test if received qty is validated against accepted + rejected
|
||||
@@ -3363,7 +3381,6 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
self.assertEqual(pr.status, "Completed")
|
||||
|
||||
def test_internal_transfer_for_batch_items_with_cancel(self):
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
|
||||
@@ -3479,7 +3496,6 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 1)
|
||||
|
||||
def test_internal_transfer_for_batch_items_with_cancel_use_serial_batch_fields(self):
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
|
||||
@@ -4061,7 +4077,6 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
make_purchase_receipt as _make_purchase_receipt,
|
||||
)
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import (
|
||||
create_pr_against_po,
|
||||
create_purchase_order,
|
||||
)
|
||||
|
||||
@@ -4184,6 +4199,126 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
# Test 3 - OverAllowanceError should be thrown as qty is greater than qty in DN
|
||||
self.assertRaises(erpnext.controllers.status_updater.OverAllowanceError, pr.submit)
|
||||
|
||||
def test_valuation_rate_for_rejected_materials(self):
|
||||
item = make_item("Test Item with Rej Material Valuation", {"is_stock_item": 1})
|
||||
company = "_Test Company with perpetual inventory"
|
||||
|
||||
warehouse = create_warehouse(
|
||||
"_Test In-ward Warehouse",
|
||||
company="_Test Company with perpetual inventory",
|
||||
)
|
||||
|
||||
rej_warehouse = create_warehouse(
|
||||
"_Test Warehouse - Rejected Material",
|
||||
company="_Test Company with perpetual inventory",
|
||||
)
|
||||
|
||||
frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 1)
|
||||
|
||||
frappe.db.set_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials", 1)
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
company=company,
|
||||
warehouse=warehouse,
|
||||
rejected_qty=5,
|
||||
rejected_warehouse=rej_warehouse,
|
||||
)
|
||||
|
||||
stock_received_but_not_billed_account = frappe.get_value(
|
||||
"Company",
|
||||
company,
|
||||
"stock_received_but_not_billed",
|
||||
)
|
||||
|
||||
rejected_item_cost = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": pr.name,
|
||||
"warehouse": rej_warehouse,
|
||||
},
|
||||
"stock_value_difference",
|
||||
)
|
||||
|
||||
self.assertEqual(rejected_item_cost, 500)
|
||||
|
||||
srbnb_cost = frappe.db.get_value(
|
||||
"GL Entry",
|
||||
{
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": pr.name,
|
||||
"account": stock_received_but_not_billed_account,
|
||||
},
|
||||
"credit",
|
||||
)
|
||||
|
||||
self.assertEqual(srbnb_cost, 1500)
|
||||
|
||||
frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 0)
|
||||
|
||||
frappe.db.set_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials", 0)
|
||||
|
||||
def test_no_valuation_rate_for_rejected_materials(self):
|
||||
item = make_item("Test Item with Rej Material No Valuation", {"is_stock_item": 1})
|
||||
company = "_Test Company with perpetual inventory"
|
||||
|
||||
warehouse = create_warehouse(
|
||||
"_Test In-ward Warehouse",
|
||||
company="_Test Company with perpetual inventory",
|
||||
)
|
||||
|
||||
rej_warehouse = create_warehouse(
|
||||
"_Test Warehouse - Rejected Material",
|
||||
company="_Test Company with perpetual inventory",
|
||||
)
|
||||
|
||||
frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 0)
|
||||
|
||||
frappe.db.set_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials", 0)
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
company=company,
|
||||
warehouse=warehouse,
|
||||
rejected_qty=5,
|
||||
rejected_warehouse=rej_warehouse,
|
||||
)
|
||||
|
||||
stock_received_but_not_billed_account = frappe.get_value(
|
||||
"Company",
|
||||
company,
|
||||
"stock_received_but_not_billed",
|
||||
)
|
||||
|
||||
rejected_item_cost = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": pr.name,
|
||||
"warehouse": rej_warehouse,
|
||||
},
|
||||
"stock_value_difference",
|
||||
)
|
||||
|
||||
self.assertEqual(rejected_item_cost, 0.0)
|
||||
|
||||
srbnb_cost = frappe.db.get_value(
|
||||
"GL Entry",
|
||||
{
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": pr.name,
|
||||
"account": stock_received_but_not_billed_account,
|
||||
},
|
||||
"credit",
|
||||
)
|
||||
|
||||
self.assertEqual(srbnb_cost, 1000)
|
||||
|
||||
|
||||
def prepare_data_for_internal_transfer():
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier
|
||||
@@ -4353,7 +4488,8 @@ def make_purchase_receipt(**args):
|
||||
pr.is_return = args.is_return
|
||||
pr.return_against = args.return_against
|
||||
pr.apply_putaway_rule = args.apply_putaway_rule
|
||||
qty = args.qty or 5
|
||||
|
||||
qty = args.qty if args.qty is not None else 5
|
||||
rejected_qty = args.rejected_qty or 0
|
||||
received_qty = args.received_qty or flt(rejected_qty) + flt(qty)
|
||||
|
||||
|
||||
@@ -514,7 +514,6 @@ class SerialandBatchBundle(Document):
|
||||
"item_code": self.item_code,
|
||||
"warehouse": self.warehouse,
|
||||
"serial_and_batch_bundle": self.name,
|
||||
"actual_qty": self.total_qty,
|
||||
"company": self.company,
|
||||
"serial_nos": [row.serial_no for row in self.entries if row.serial_no],
|
||||
"batch_nos": {row.batch_no: row for row in self.entries if row.batch_no},
|
||||
@@ -723,19 +722,19 @@ class SerialandBatchBundle(Document):
|
||||
|
||||
def reset_qty(self, row, qty_field=None):
|
||||
qty_field = self.get_qty_field(row, qty_field=qty_field)
|
||||
qty = abs(row.get(qty_field))
|
||||
qty = abs(flt(row.get(qty_field), self.precision("total_qty")))
|
||||
|
||||
idx = None
|
||||
while qty > 0:
|
||||
for d in self.entries:
|
||||
row_qty = abs(d.qty)
|
||||
row_qty = abs(flt(d.qty, d.precision("qty")))
|
||||
if row_qty >= qty:
|
||||
d.db_set("qty", qty if self.type_of_transaction == "Inward" else qty * -1)
|
||||
qty = 0
|
||||
idx = d.idx
|
||||
break
|
||||
else:
|
||||
qty -= row_qty
|
||||
qty = flt(qty - row_qty, d.precision("qty"))
|
||||
idx = d.idx
|
||||
|
||||
if idx and len(self.entries) > idx:
|
||||
|
||||
@@ -365,9 +365,8 @@ class StockEntry(StockController):
|
||||
frappe.delete_doc("Stock Entry", d.name)
|
||||
|
||||
def set_transfer_qty(self):
|
||||
self.validate_qty_is_not_zero()
|
||||
for item in self.get("items"):
|
||||
if not flt(item.qty):
|
||||
frappe.throw(_("Row {0}: Qty is mandatory").format(item.idx), title=_("Zero quantity"))
|
||||
if not flt(item.conversion_factor):
|
||||
frappe.throw(_("Row {0}: UOM Conversion Factor is mandatory").format(item.idx))
|
||||
item.transfer_qty = flt(
|
||||
@@ -521,17 +520,19 @@ class StockEntry(StockController):
|
||||
if acc_details.account_type == "Stock":
|
||||
frappe.throw(
|
||||
_(
|
||||
"At row {0}: the Difference Account must not be a Stock type account, please change the Account Type for the account {1} or select a different account"
|
||||
"At row #{0}: the Difference Account must not be a Stock type account, please change the Account Type for the account {1} or select a different account"
|
||||
).format(d.idx, get_link_to_form("Account", d.expense_account)),
|
||||
OpeningEntryAccountError,
|
||||
title=_("Difference Account in Items Table"),
|
||||
)
|
||||
|
||||
if self.purpose != "Material Issue" and acc_details.account_type == "Cost of Goods Sold":
|
||||
frappe.msgprint(
|
||||
_(
|
||||
"At row {0}: You have selected the Difference Account {1}, which is a Cost of Goods Sold type account. Please select a different account"
|
||||
"At row #{0}: you have selected the Difference Account {1}, which is a Cost of Goods Sold type account. Please select a different account"
|
||||
).format(d.idx, bold(get_link_to_form("Account", d.expense_account))),
|
||||
title=_("Warning : Cost of Goods Sold Account"),
|
||||
title=_("Cost of Goods Sold Account in Items Table"),
|
||||
indicator="orange",
|
||||
alert=1,
|
||||
)
|
||||
|
||||
def validate_warehouse(self):
|
||||
|
||||
@@ -7,6 +7,7 @@ from frappe.tests.utils import FrappeTestCase, change_settings
|
||||
from frappe.utils import add_days, cstr, flt, get_time, getdate, nowtime, today
|
||||
|
||||
from erpnext.accounts.doctype.account.test_account import get_inventory_account
|
||||
from erpnext.controllers.accounts_controller import InvalidQtyError
|
||||
from erpnext.stock.doctype.item.test_item import (
|
||||
create_item,
|
||||
make_item,
|
||||
@@ -53,6 +54,18 @@ class TestStockEntry(FrappeTestCase):
|
||||
frappe.db.rollback()
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def test_stock_entry_qty(self):
|
||||
item_code = "_Test Item 2"
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
se = make_stock_entry(item_code=item_code, target=warehouse, qty=0, do_not_save=True)
|
||||
with self.assertRaises(InvalidQtyError):
|
||||
se.save()
|
||||
|
||||
# No error with qty=1
|
||||
se.items[0].qty = 1
|
||||
se.save()
|
||||
self.assertEqual(se.items[0].qty, 1)
|
||||
|
||||
def test_fifo(self):
|
||||
frappe.db.set_single_value("Stock Settings", "allow_negative_stock", 1)
|
||||
item_code = "_Test Item 2"
|
||||
|
||||
@@ -57,7 +57,6 @@
|
||||
"options": "Item",
|
||||
"print_width": "100px",
|
||||
"read_only": 1,
|
||||
"search_index": 1,
|
||||
"width": "100px"
|
||||
},
|
||||
{
|
||||
@@ -88,7 +87,6 @@
|
||||
"options": "Warehouse",
|
||||
"print_width": "100px",
|
||||
"read_only": 1,
|
||||
"search_index": 1,
|
||||
"width": "100px"
|
||||
},
|
||||
{
|
||||
@@ -101,7 +99,6 @@
|
||||
"oldfieldtype": "Date",
|
||||
"print_width": "100px",
|
||||
"read_only": 1,
|
||||
"search_index": 1,
|
||||
"width": "100px"
|
||||
},
|
||||
{
|
||||
@@ -357,13 +354,14 @@
|
||||
"search_index": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"hide_toolbar": 1,
|
||||
"icon": "fa fa-list",
|
||||
"idx": 1,
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-12-23 18:03:05.171023",
|
||||
"modified": "2025-04-22 12:37:41.304109",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Ledger Entry",
|
||||
|
||||
@@ -345,5 +345,4 @@ class StockLedgerEntry(Document):
|
||||
def on_doctype_update():
|
||||
frappe.db.add_index("Stock Ledger Entry", ["voucher_no", "voucher_type"])
|
||||
frappe.db.add_index("Stock Ledger Entry", ["batch_no", "item_code", "warehouse"])
|
||||
frappe.db.add_index("Stock Ledger Entry", ["warehouse", "item_code"], "item_warehouse")
|
||||
frappe.db.add_index("Stock Ledger Entry", ["posting_datetime", "creation"])
|
||||
frappe.db.add_index("Stock Ledger Entry", ["item_code", "warehouse", "posting_datetime", "creation"])
|
||||
|
||||
@@ -6,7 +6,7 @@ from collections import defaultdict
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import flt, today
|
||||
from frappe.utils import flt, get_datetime, today
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -167,7 +167,8 @@ def get_query_based_on_filters(query, batch, table, filters):
|
||||
query = query.where(batch.batch_qty > 0)
|
||||
|
||||
else:
|
||||
query = query.where(table.posting_date <= filters.to_date)
|
||||
to_date = get_datetime(str(filters.to_date) + " 23:59:59")
|
||||
query = query.where(table.posting_datetime <= to_date)
|
||||
|
||||
if filters.warehouse:
|
||||
lft, rgt = frappe.db.get_value("Warehouse", filters.warehouse, ["lft", "rgt"])
|
||||
|
||||
@@ -115,7 +115,6 @@ def get_stock_ledger_entries_for_batch_no(filters):
|
||||
& (sle.posting_datetime < posting_datetime)
|
||||
)
|
||||
.groupby(sle.voucher_no, sle.batch_no, sle.item_code, sle.warehouse)
|
||||
.orderby(sle.item_code, sle.warehouse)
|
||||
)
|
||||
|
||||
query = apply_warehouse_filter(query, sle, filters)
|
||||
@@ -160,7 +159,6 @@ def get_stock_ledger_entries_for_batch_bundle(filters):
|
||||
& (sle.posting_datetime <= to_date)
|
||||
)
|
||||
.groupby(sle.voucher_no, batch_package.batch_no, batch_package.warehouse)
|
||||
.orderby(sle.item_code, sle.warehouse)
|
||||
)
|
||||
|
||||
query = apply_warehouse_filter(query, sle, filters)
|
||||
|
||||
@@ -66,7 +66,7 @@ def get_stock_ledger_entries(filters):
|
||||
"Stock Ledger Entry",
|
||||
fields=SLE_FIELDS,
|
||||
filters=sle_filters,
|
||||
order_by="timestamp(posting_date, posting_time), creation",
|
||||
order_by="posting_datetime, creation",
|
||||
)
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user