Compare commits

..

11 Commits

171 changed files with 1903 additions and 3887 deletions

View File

@@ -7,7 +7,6 @@ frappe.ui.form.on("Bank Statement Import", {
return {
filters: {
company: doc.company,
is_company_account: 1,
},
};
});

View File

@@ -72,7 +72,7 @@ class BankStatementImport(DataImport):
self.template_warnings = ""
if self.import_file and not self.import_file.lower().endswith(".txt"):
self.get_importer()
self.validate_import_file()
self.validate_google_sheets_url()
def start_import(self):

View File

@@ -0,0 +1,8 @@
from frappe import _
def get_data():
return {
"non_standard_fieldnames": {"MapReduce Job": "document_name"},
"transactions": [{"label": _("Job"), "items": ["MapReduce Job"]}],
}

View File

@@ -151,7 +151,7 @@ function refresh_payments(payments, frm) {
mode_of_payment: p.mode_of_payment,
opening_amount: 0,
expected_amount: p.amount,
closing_amount: p.amount,
closing_amount: 0,
});
}
});

View File

@@ -438,7 +438,6 @@ def apply_pricing_rule(args: str | dict, doc: str | dict | Document | None = Non
for item in item_list:
args_copy = copy.deepcopy(args)
args_copy.update(item)
set_transaction_type(args_copy)
data = get_pricing_rule_for_item(args_copy, doc=doc)
out.append(data)

View File

@@ -34,7 +34,6 @@ from erpnext.accounts.party import get_due_date, get_party_account
from erpnext.accounts.utils import (
get_account_currency,
get_fiscal_year,
pre_submit_validation,
refresh_subscription_status,
update_voucher_outstanding,
)
@@ -312,8 +311,6 @@ class PurchaseInvoice(BuyingController):
if self.on_hold:
self.validate_invoice_hold()
pre_submit_validation(self, check_prev_docstatus=True)
def set_percentage_received(self):
total_billed_qty = 0.0
total_received_qty = 0.0

View File

@@ -2278,25 +2278,6 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
@ERPNextTestSuite.change_settings(
"Buying Settings", {"maintain_same_rate": 0, "set_landed_cost_based_on_purchase_invoice_rate": 1}
)
def test_adjust_incoming_rate_keeps_discounted_pr_value(self):
pr = make_purchase_receipt(qty=10, rate=100, do_not_submit=True)
pr.apply_discount_on = "Net Total"
pr.additional_discount_percentage = 10
pr.submit()
pi = create_purchase_invoice_from_receipt(pr.name)
pi.submit()
pr.reload()
stock_value_difference = frappe.db.get_value(
"Stock Ledger Entry", {"voucher_no": pr.name, "is_cancelled": 0}, "stock_value_difference"
)
self.assertEqual(pr.items[0].amount_difference_with_purchase_invoice, 0)
self.assertEqual(stock_value_difference, 900)
def test_item_less_defaults(self):
pi = frappe.new_doc("Purchase Invoice")
pi.supplier = "_Test Supplier"

View File

@@ -22,11 +22,7 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
)
from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import SalesTaxWithholding
from erpnext.accounts.party import get_due_date, get_party_account
from erpnext.accounts.utils import (
pre_submit_validation,
refresh_subscription_status,
update_voucher_outstanding,
)
from erpnext.accounts.utils import refresh_subscription_status, update_voucher_outstanding
from erpnext.controllers.accounts_controller import validate_account_head
from erpnext.controllers.selling_controller import SellingController
from erpnext.setup.doctype.company.company import update_company_current_month_sales
@@ -390,7 +386,6 @@ class SalesInvoice(SellingController):
self.reset_default_field_value("set_warehouse", "items", "warehouse")
self.validate_subcontracted_sales_order()
self.validate_scio_self_rm_qty()
pre_submit_validation(self, check_prev_docstatus=True, check_credit_limit=True)
def validate_update_stock_for_pick_list_reference(self):
if self.update_stock or self.is_return:

View File

@@ -0,0 +1,20 @@
{
"action": "Create Entry",
"action_label": "Create Sales Invoice",
"creation": "2026-02-20 13:42:38.439574",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 2,
"is_complete": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2026-02-23 22:16:40.931428",
"modified_by": "Administrator",
"name": "Create Sales Invoice",
"owner": "Administrator",
"reference_document": "Sales Invoice",
"show_form_tour": 0,
"show_full_form": 0,
"title": "Create Sales Invoice",
"validate_action": 1
}

View File

@@ -0,0 +1,22 @@
{
"align_labels_right": 0,
"creation": "2011-12-21 11:08:55",
"custom_format": 1,
"disabled": 0,
"doc_type": "Sales Invoice",
"docstatus": 0,
"doctype": "Print Format",
"html": "<style>\n\t.print-format table, .print-format tr, \n\t.print-format td, .print-format div, .print-format p {\n\t\tfont-family: Tahoma, sans-serif;\n\t\tline-height: 150%;\n\t\tvertical-align: middle;\n\t}\n\t@media screen {\n\t\t.print-format {\n\t\t\twidth: 4in;\n\t\t\tpadding: 0.25in;\n\t\t\tmin-height: 8in;\n\t\t}\n\t}\n</style>\n\n{% if letter_head %}\n {{ letter_head }}\n{% endif %}\n\n<p class=\"text-center\" style=\"margin-bottom: 1rem\">\n\t{{ doc.company }}<br>\n\t{{ doc.select_print_heading or _(\"Invoice\") }}<br>\n</p>\n<p>\n\t<b>{{ _(\"Receipt No\") }}:</b> {{ doc.name }}<br>\n\t<b>{{ _(\"Date\") }}:</b> {{ doc.get_formatted(\"posting_date\") }}<br>\n\t<b>{{ _(\"Customer\") }}:</b> {{ doc.customer_name }}\n</p>\n\n<hr>\n<table class=\"table table-condensed cart no-border\">\n\t<thead>\n\t\t<tr>\n\t\t\t<th width=\"50%\">{{ _(\"Item\") }}</b></th>\n\t\t\t<th width=\"25%\" class=\"text-right\">{{ _(\"Qty\") }}</th>\n\t\t\t<th width=\"25%\" class=\"text-right\">{{ _(\"Amount\") }}</th>\n\t\t</tr>\n\t</thead>\n\t<tbody>\n\t\t{%- for item in doc.items -%}\n\t\t<tr>\n\t\t\t<td>\n\t\t\t\t{{ item.item_code }}\n\t\t\t\t{%- if item.item_name != item.item_code -%}\n\t\t\t\t\t<br>{{ item.item_name }}{%- endif -%}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">{{ item.qty }}<br>@ {{ item.get_formatted(\"rate\") }}</td>\n\t\t\t<td class=\"text-right\">{{ item.get_formatted(\"amount\") }}</td>\n\t\t</tr>\n\t\t{%- endfor -%}\n\t</tbody>\n</table>\n<table class=\"table table-condensed no-border\">\n\t<tbody>\n\t\t<tr>\n\t\t\t{% if doc.flags.show_inclusive_tax_in_print %}\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ _(\"Total Excl. Tax\") }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ doc.get_formatted(\"net_total\", doc) }}\n\t\t\t\t</td>\n\t\t\t{% else %}\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ _(\"Total\") }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ doc.get_formatted(\"total\", doc) }}\n\t\t\t\t</td>\n\t\t\t{% endif %}\n\t\t</tr>\n\t\t{%- for row in doc.taxes -%}\n\t\t {%- if not row.included_in_print_rate or doc.flags.show_inclusive_tax_in_print -%}\n\t\t\t<tr>\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ row.description }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ row.get_formatted(\"tax_amount\", doc) }}\n\t\t\t\t</td>\n\t\t\t<tr>\n\t\t {%- endif -%}\n\t\t{%- endfor -%}\n\n\t\t{%- if doc.discount_amount -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t{{ _(\"Discount\") }}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"discount_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- endif -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Grand Total\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"grand_total\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- if doc.rounded_total -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Rounded Total\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"rounded_total\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- endif -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Paid Amount\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"paid_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- if doc.change_amount -%}\n\t\t\t<tr>\n\t\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t\t<b>{{ _(\"Change Amount\") }}</b>\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ doc.get_formatted(\"change_amount\") }}\n\t\t\t\t</td>\n\t\t\t</tr>\n\t\t{%- endif -%}\n\t</tbody>\n</table>\n<hr>\n<p>{{ doc.terms or \"\" }}</p>\n<p class=\"text-center\">{{ _(\"Thank you, please visit again.\") }}</p>",
"idx": 1,
"line_breaks": 0,
"modified": "2020-04-29 16:35:07.043058",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",
"owner": "Administrator",
"print_format_builder": 0,
"print_format_type": "Jinja",
"raw_printing": 0,
"show_section_headings": 0,
"standard": "Yes"
}

View File

@@ -0,0 +1,153 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.query_reports["Consolidated Accounts Payable"] = {
filters: [
{
fieldname: "companies",
label: __("Companies"),
fieldtype: "MultiSelectList",
options: "Company",
get_data: function (txt) {
return frappe.db.get_link_options("Company", txt);
},
description: __("Totals are dropped when the companies use different currencies"),
},
{
fieldname: "report_date",
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
{
fieldname: "party_type",
label: __("Party Type"),
fieldtype: "Autocomplete",
options: get_party_type_options(),
on_change: function () {
frappe.query_report.set_filter_value("party", "");
frappe.query_report.toggle_filter_display(
"supplier_group",
frappe.query_report.get_filter_value("party_type") !== "Supplier"
);
},
},
{
fieldname: "party",
label: __("Party"),
fieldtype: "MultiSelectList",
options: "party_type",
get_data: function (txt) {
if (!frappe.query_report.filters) return;
let party_type = frappe.query_report.get_filter_value("party_type");
if (!party_type) return;
return frappe.db.get_link_options(party_type, txt);
},
},
{
fieldname: "ageing_based_on",
label: __("Ageing Based On"),
fieldtype: "Select",
options: "Posting Date\nDue Date\nSupplier Invoice Date",
default: "Due Date",
},
{
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday",
default: "Report Date",
},
{
fieldname: "range",
label: __("Ageing Range"),
fieldtype: "Data",
default: "30, 60, 90, 120",
},
{
fieldname: "supplier_group",
label: __("Supplier Group"),
fieldtype: "Link",
options: "Supplier Group",
},
{
fieldname: "group_by_party",
label: __("Group By Supplier"),
fieldtype: "Check",
},
{
fieldname: "group_by_company",
label: __("Group By Company"),
fieldtype: "Check",
},
{
fieldname: "ignore_accounts",
label: __("Group by Voucher"),
fieldtype: "Check",
},
{
fieldname: "based_on_payment_terms",
label: __("Based On Payment Terms"),
fieldtype: "Check",
},
{
fieldname: "show_future_payments",
label: __("Show Future Payments"),
fieldtype: "Check",
},
{
fieldname: "show_remarks",
label: __("Show Remarks"),
fieldtype: "Check",
},
{
fieldname: "for_revaluation_journals",
label: __("Revaluation Journals"),
fieldtype: "Check",
},
{
fieldname: "in_party_currency",
label: __("In Party Currency"),
fieldtype: "Check",
},
],
collapsible_filters: true,
separate_check_filters: true,
formatter: function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
if (data && data.bold) {
value = value.bold();
}
return value;
},
onload: function (report) {
report.page.add_inner_button(__("Consolidated Accounts Payable Summary"), function () {
frappe.set_route("query-report", "Consolidated Accounts Payable Summary", report.get_values());
});
const company = frappe.defaults.get_user_default("Company");
if (company && !(report.get_filter_value("companies") || []).length) {
report.set_filter_value("companies", [company]);
}
if (frappe.boot.sysdefaults.default_ageing_range) {
report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range);
}
},
};
function get_party_type_options() {
let options = [];
frappe.db
.get_list("Party Type", { filters: { account_type: "Payable" }, fields: ["name"] })
.then((res) => {
res.forEach((party_type) => {
options.push(party_type.name);
});
});
return options;
}

View File

@@ -0,0 +1,36 @@
{
"add_total_row": 1,
"add_translate_data": 0,
"columns": [],
"creation": "2026-09-11 10:00:00.000000",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"modified": "2026-09-11 10:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Consolidated Accounts Payable",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Purchase Invoice",
"report_name": "Consolidated Accounts Payable",
"report_type": "Script Report",
"roles": [
{
"role": "Accounts User"
},
{
"role": "Purchase User"
},
{
"role": "Accounts Manager"
},
{
"role": "Auditor"
}
],
"timeout": 0
}

View File

@@ -0,0 +1,14 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from erpnext.accounts.report.consolidated_accounts_receivable.consolidated_accounts_receivable import (
ConsolidatedReceivablePayable,
)
def execute(filters=None):
args = {
"account_type": "Payable",
"naming_by": ["Buying Settings", "supp_master_name"],
}
return ConsolidatedReceivablePayable(filters).run(args)

View File

@@ -0,0 +1,133 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.query_reports["Consolidated Accounts Payable Summary"] = {
filters: [
{
fieldname: "companies",
label: __("Companies"),
fieldtype: "MultiSelectList",
options: "Company",
get_data: function (txt) {
return frappe.db.get_link_options("Company", txt);
},
description: __("Totals are dropped when the companies use different currencies"),
},
{
fieldname: "report_date",
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
{
fieldname: "ageing_based_on",
label: __("Ageing Based On"),
fieldtype: "Select",
options: "Posting Date\nDue Date\nSupplier Invoice Date",
default: "Due Date",
},
{
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday",
default: "Report Date",
},
{
fieldname: "range",
label: __("Ageing Range"),
fieldtype: "Data",
default: "30, 60, 90, 120",
},
{
fieldname: "party_type",
label: __("Party Type"),
fieldtype: "Autocomplete",
options: get_party_type_options(),
on_change: function () {
frappe.query_report.set_filter_value("party", "");
frappe.query_report.toggle_filter_display(
"supplier_group",
frappe.query_report.get_filter_value("party_type") !== "Supplier"
);
},
},
{
fieldname: "party",
label: __("Party"),
fieldtype: "MultiSelectList",
options: "party_type",
get_data: function (txt) {
if (!frappe.query_report.filters) return;
let party_type = frappe.query_report.get_filter_value("party_type");
if (!party_type) return;
return frappe.db.get_link_options(party_type, txt);
},
},
{
fieldname: "supplier_group",
label: __("Supplier Group"),
fieldtype: "Link",
options: "Supplier Group",
},
{
fieldname: "show_future_payments",
label: __("Show Future Payments"),
fieldtype: "Check",
},
{
fieldname: "show_gl_balance",
label: __("Show GL Balance"),
fieldtype: "Check",
},
{
fieldname: "based_on_payment_terms",
label: __("Based On Payment Terms"),
fieldtype: "Check",
},
{
fieldname: "for_revaluation_journals",
label: __("Revaluation Journals"),
fieldtype: "Check",
},
],
collapsible_filters: true,
separate_check_filters: true,
formatter: function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
if (data && data.bold) {
value = value.bold();
}
return value;
},
onload: function (report) {
report.page.add_inner_button(__("Consolidated Accounts Payable"), function () {
frappe.set_route("query-report", "Consolidated Accounts Payable", report.get_values());
});
const company = frappe.defaults.get_user_default("Company");
if (company && !(report.get_filter_value("companies") || []).length) {
report.set_filter_value("companies", [company]);
}
if (frappe.boot.sysdefaults.default_ageing_range) {
report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range);
}
},
};
function get_party_type_options() {
let options = [];
frappe.db
.get_list("Party Type", { filters: { account_type: "Payable" }, fields: ["name"] })
.then((res) => {
res.forEach((party_type) => {
options.push(party_type.name);
});
});
return options;
}

View File

@@ -0,0 +1,36 @@
{
"add_total_row": 0,
"add_translate_data": 0,
"columns": [],
"creation": "2026-08-07 10:00:00.000000",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"modified": "2026-08-07 10:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Consolidated Accounts Payable Summary",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Purchase Invoice",
"report_name": "Consolidated Accounts Payable Summary",
"report_type": "Script Report",
"roles": [
{
"role": "Accounts User"
},
{
"role": "Purchase User"
},
{
"role": "Accounts Manager"
},
{
"role": "Auditor"
}
],
"timeout": 0
}

View File

@@ -0,0 +1,14 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from erpnext.accounts.report.consolidated_accounts_receivable_summary.consolidated_accounts_receivable_summary import (
ConsolidatedReceivablePayableSummary,
)
def execute(filters=None):
args = {
"account_type": "Payable",
"naming_by": ["Buying Settings", "supp_master_name"],
}
return ConsolidatedReceivablePayableSummary(filters).run(args)

View File

@@ -0,0 +1,184 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.query_reports["Consolidated Accounts Receivable"] = {
filters: [
{
fieldname: "companies",
label: __("Companies"),
fieldtype: "MultiSelectList",
options: "Company",
get_data: function (txt) {
return frappe.db.get_link_options("Company", txt);
},
description: __("Totals are dropped when the companies use different currencies"),
},
{
fieldname: "report_date",
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
{
fieldname: "party_type",
label: __("Party Type"),
fieldtype: "Autocomplete",
options: get_party_type_options(),
on_change: function () {
frappe.query_report.set_filter_value("party", "");
frappe.query_report.toggle_filter_display(
"customer_group",
frappe.query_report.get_filter_value("party_type") !== "Customer"
);
},
},
{
fieldname: "party",
label: __("Party"),
fieldtype: "MultiSelectList",
options: "party_type",
get_data: function (txt) {
if (!frappe.query_report.filters) return;
let party_type = frappe.query_report.get_filter_value("party_type");
if (!party_type) return;
return frappe.db.get_link_options(party_type, txt);
},
},
{
fieldname: "ageing_based_on",
label: __("Ageing Based On"),
fieldtype: "Select",
options: "Posting Date\nDue Date",
default: "Due Date",
},
{
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday",
default: "Report Date",
},
{
fieldname: "range",
label: __("Ageing Range"),
fieldtype: "Data",
default: "30, 60, 90, 120",
},
{
fieldname: "customer_group",
label: __("Customer Group"),
fieldtype: "Link",
options: "Customer Group",
},
{
fieldname: "territory",
label: __("Territory"),
fieldtype: "MultiSelectList",
options: "Territory",
get_data: function (txt) {
return frappe.db.get_link_options("Territory", txt);
},
},
{
fieldname: "sales_partner",
label: __("Sales Partner"),
fieldtype: "Link",
options: "Sales Partner",
},
{
fieldname: "sales_person",
label: __("Sales Person"),
fieldtype: "Link",
options: "Sales Person",
},
{
fieldname: "group_by_party",
label: __("Group By Customer"),
fieldtype: "Check",
},
{
fieldname: "group_by_company",
label: __("Group By Company"),
fieldtype: "Check",
},
{
fieldname: "ignore_accounts",
label: __("Group by Voucher"),
fieldtype: "Check",
},
{
fieldname: "based_on_payment_terms",
label: __("Based On Payment Terms"),
fieldtype: "Check",
},
{
fieldname: "show_future_payments",
label: __("Show Future Payments"),
fieldtype: "Check",
},
{
fieldname: "show_remarks",
label: __("Show Remarks"),
fieldtype: "Check",
},
{
fieldname: "show_sales_person",
label: __("Show Sales Person"),
fieldtype: "Check",
},
{
fieldname: "show_delivery_notes",
label: __("Show Linked Delivery Notes"),
fieldtype: "Check",
},
{
fieldname: "for_revaluation_journals",
label: __("Revaluation Journals"),
fieldtype: "Check",
},
{
fieldname: "in_party_currency",
label: __("In Party Currency"),
fieldtype: "Check",
},
],
collapsible_filters: true,
separate_check_filters: true,
formatter: function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
if (data && data.bold) {
value = value.bold();
}
return value;
},
onload: function (report) {
report.page.add_inner_button(__("Consolidated Accounts Receivable Summary"), function () {
frappe.set_route("query-report", "Consolidated Accounts Receivable Summary", report.get_values());
});
const company = frappe.defaults.get_user_default("Company");
if (company && !(report.get_filter_value("companies") || []).length) {
report.set_filter_value("companies", [company]);
}
if (frappe.boot.sysdefaults.default_ageing_range) {
report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range);
}
},
};
function get_party_type_options() {
let options = [];
frappe.db
.get_list("Party Type", { filters: { account_type: "Receivable" }, fields: ["name"] })
.then((res) => {
res.forEach((party_type) => {
options.push(party_type.name);
});
});
return options;
}

View File

@@ -0,0 +1,30 @@
{
"add_total_row": 1,
"add_translate_data": 0,
"columns": [],
"creation": "2026-09-11 10:00:00.000000",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"modified": "2026-09-11 10:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Consolidated Accounts Receivable",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Sales Invoice",
"report_name": "Consolidated Accounts Receivable",
"report_type": "Script Report",
"roles": [
{
"role": "Accounts Manager"
},
{
"role": "Accounts User"
}
],
"timeout": 0
}

View File

@@ -0,0 +1,147 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from collections import OrderedDict
import frappe
from frappe import _
from frappe.model import numeric_fieldtypes
from frappe.utils import flt
from erpnext.accounts.report.accounts_receivable.accounts_receivable import ReceivablePayableReport
from erpnext.accounts.report.consolidated_financial_statement.consolidated_financial_statement import (
get_subsidiary_companies,
)
# Outstanding invoices of a party across companies that need not be related to each other.
# Every row carries the company it belongs to, and a party's companies sit together.
def execute(filters=None):
args = {
"account_type": "Receivable",
"naming_by": ["Selling Settings", "cust_master_name"],
}
return ConsolidatedReceivablePayable(filters).run(args)
class ConsolidatedReceivablePayable(ReceivablePayableReport):
def run(self, args):
self.companies = get_consolidated_companies(self.filters)
self.args = args # the engine's get_data() takes no arguments, so hand them over here
columns, data, _message, chart, _report_summary, skip_total_row = super().run(args)
if self.filters.get("group_by_company") or len(row_currencies(data)) > 1:
# a grand total would either double count the subtotals or add unlike currencies
skip_total_row = 1
return columns, data, None, chart, None, skip_total_row
def get_columns(self):
super().get_columns()
add_company_columns(self.columns)
def get_data(self):
# party wins when both are checked, its subtotal is the point of this report
if self.filters.get("group_by_party"):
group_by, subtotal_of = "party", self.party_subtotal
elif self.filters.get("group_by_company"):
group_by, subtotal_of = "company", self.company_subtotal
else:
group_by, subtotal_of = "party", None
self.data = []
for rows in self.get_grouped_rows(group_by).values():
self.data.extend(rows)
if subtotal_of and len(row_currencies(rows)) <= 1:
self.data.append(subtotal_of(rows))
self.data.append({}) # blank separator, like the engine's own grouping
def get_grouped_rows(self, group_by):
"""Rows of every company, regrouped so those sharing `group_by` sit together."""
grouped = OrderedDict()
for row in rows_per_company(self.companies, self.filters, self.args, ReceivablePayableReport):
grouped.setdefault(row.get(group_by), []).append(row)
return grouped
def party_subtotal(self, rows):
return self.subtotal(rows, party=rows[0].party)
def company_subtotal(self, rows):
return self.subtotal(rows, company=rows[0].company)
def subtotal(self, rows, **label):
# same shape as the engine's own group-by-party subtotal
subtotal = frappe._dict(currency=rows[0].get("currency"), bold=1, **label)
for field in self.get_currency_fields():
subtotal[field] = sum(flt(row.get(field)) for row in rows)
return subtotal
def rows_per_company(companies, filters, args, engine):
"""Run `engine` once per company, tagging every row with the company it came from."""
for company in companies:
# subtotals are appended once per group by the caller, not once per company
company_filters = frappe._dict(filters, company=company, group_by_party=0)
company_filters.pop("companies", None)
parent = frappe.get_cached_value("Company", company, "parent_company")
for row in engine(company_filters).run(args)[1]:
row.company, row.parent_company = company, parent
yield row
def get_consolidated_companies(filters):
"""Selected companies, a group company standing for the companies under it."""
companies = []
for selected in filters.get("companies") or []:
for company in get_subsidiary_companies(selected):
if company not in companies:
companies.append(company)
return companies
def row_currencies(rows):
return {row.get("currency") for row in rows if row.get("currency")}
def add_company_columns(columns):
"""Company and its parent, right after the party columns, plus header alignment."""
at = company_column_index(columns)
columns.insert(
at,
dict(
label=_("Company"),
fieldname="company",
fieldtype="Link",
options="Company",
width=180,
sticky=True,
),
)
columns.insert(
at + 1,
dict(
label=_("Parent Company"),
fieldname="parent_company",
fieldtype="Link",
options="Company",
width=160,
),
)
# datatable guesses alignment from the first row, which misreads an empty column
for column in columns:
column["align"] = "right" if column["fieldtype"] in numeric_fieldtypes else "left"
def company_column_index(columns):
fieldnames = [column["fieldname"] for column in columns]
for fieldname in ("party_name", "party"):
if fieldname in fieldnames:
return fieldnames.index(fieldname) + 1
return 0

View File

@@ -0,0 +1,133 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import today
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.consolidated_accounts_receivable.consolidated_accounts_receivable import (
execute,
)
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite
class ConsolidatedReportMixin(AccountsTestMixin):
"""Companies, invoices and filters shared by the consolidated report tests."""
def filters(self, companies=None, **kwargs):
return {
# an explicit empty list means "nothing selected", so do not fall back on it
"companies": [self.company_a, self.company_b] if companies is None else companies,
"party_type": "Customer",
"party": [self.customer],
"report_date": today(),
"range": "30, 60, 90, 120",
**kwargs,
}
def create_invoice(self, company, abbr, rate, customer=None, currency=None):
return create_sales_invoice(
item=self.item,
company=company,
customer=customer or self.customer,
currency=currency,
debit_to=f"Debtors - {abbr}",
income_account=f"Sales - {abbr}",
cost_center=f"Main - {abbr}",
parent_cost_center=f"Main - {abbr}",
warehouse=f"Stores - {abbr}",
posting_date=today(),
rate=rate,
price_list_rate=rate,
)
def create_test_company(self, company_name, abbr, currency="INR", is_group=0, parent=None):
if frappe.db.exists("Company", company_name):
return company_name
company = frappe.new_doc("Company")
company.company_name = company_name
company.abbr = abbr
company.country = "India"
company.default_currency = currency
company.create_chart_of_accounts_based_on = "Standard Template"
company.chart_of_accounts = "Standard"
company.is_group = is_group
company.parent_company = parent
company.insert()
return company.name
class TestConsolidatedAccountsReceivable(ERPNextTestSuite, ConsolidatedReportMixin):
def setUp(self):
self.maxDiff = None
# deliberately unrelated companies, there is no parent/child link between them
self.company_a = self.create_test_company("_Test Unrelated A", "_TUNA")
self.company_b = self.create_test_company("_Test Unrelated B", "_TUNB")
self.create_customer("_Test Consolidation Customer")
# the mixin would otherwise pass company=None, which the Item Default row rejects
self.create_item("_Test Consolidation Item", company=self.company_a)
def test_rows_carry_the_company_they_came_from(self):
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(self.company_b, "_TUNB", 300)
rows = execute(self.filters())[1]
self.assertEqual([r.company for r in rows], [self.company_a, self.company_b])
self.assertFalse(any(r.parent_company for r in rows))
def test_group_by_party_adds_one_subtotal_for_all_companies(self):
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(self.company_b, "_TUNB", 300)
result = execute(self.filters(group_by_party=1))
subtotals = [row for row in result[1] if row.get("bold")]
self.assertEqual(len(subtotals), 1)
self.assertEqual(subtotals[0].outstanding, 500.0)
self.assertEqual(result[5], 1) # skip_total_row, else the grand total double counts
def test_group_by_company_subtotals_each_company(self):
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(self.company_b, "_TUNB", 300)
result = execute(self.filters(group_by_company=1))
subtotals = [row for row in result[1] if row.get("bold")]
self.assertEqual([s.company for s in subtotals], [self.company_a, self.company_b])
self.assertEqual([s.outstanding for s in subtotals], [200.0, 300.0])
self.assertEqual(result[5], 1)
def test_party_grouping_wins_when_both_are_selected(self):
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(self.company_b, "_TUNB", 300)
subtotals = [
row for row in execute(self.filters(group_by_party=1, group_by_company=1))[1] if row.get("bold")
]
self.assertEqual(len(subtotals), 1)
self.assertEqual(subtotals[0].party, self.customer)
def test_no_total_when_companies_use_different_currencies(self):
usd = self.create_test_company("_Test Unrelated USD", "_TUNU", currency="USD")
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(usd, "_TUNU", 300, currency="USD")
result = execute(self.filters(companies=[self.company_a, usd]))
self.assertEqual([r.company for r in result[1]], [self.company_a, usd])
self.assertEqual(result[5], 1) # skip_total_row, the rows span currencies
def test_group_company_expands_to_its_subsidiaries(self):
group = self.create_test_company("_Test Consolidation Group", "_TCGRP", is_group=1)
child = self.create_test_company("_Test Consolidation Child", "_TCCLD", parent=group)
self.create_invoice(child, "_TCCLD", 400)
rows = execute(self.filters(companies=[group]))[1]
self.assertEqual([r.company for r in rows], [child])
self.assertEqual([r.parent_company for r in rows], [group])

View File

@@ -0,0 +1,159 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.query_reports["Consolidated Accounts Receivable Summary"] = {
filters: [
{
fieldname: "companies",
label: __("Companies"),
fieldtype: "MultiSelectList",
options: "Company",
get_data: function (txt) {
return frappe.db.get_link_options("Company", txt);
},
description: __("Totals are dropped when the companies use different currencies"),
},
{
fieldname: "report_date",
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
{
fieldname: "ageing_based_on",
label: __("Ageing Based On"),
fieldtype: "Select",
options: "Posting Date\nDue Date",
default: "Due Date",
},
{
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday",
default: "Report Date",
},
{
fieldname: "range",
label: __("Ageing Range"),
fieldtype: "Data",
default: "30, 60, 90, 120",
},
{
fieldname: "party_type",
label: __("Party Type"),
fieldtype: "Autocomplete",
options: get_party_type_options(),
on_change: function () {
frappe.query_report.set_filter_value("party", "");
frappe.query_report.toggle_filter_display(
"customer_group",
frappe.query_report.get_filter_value("party_type") !== "Customer"
);
},
},
{
fieldname: "party",
label: __("Party"),
fieldtype: "MultiSelectList",
options: "party_type",
get_data: function (txt) {
if (!frappe.query_report.filters) return;
let party_type = frappe.query_report.get_filter_value("party_type");
if (!party_type) return;
return frappe.db.get_link_options(party_type, txt);
},
},
{
fieldname: "customer_group",
label: __("Customer Group"),
fieldtype: "Link",
options: "Customer Group",
},
{
fieldname: "territory",
label: __("Territory"),
fieldtype: "MultiSelectList",
options: "Territory",
get_data: function (txt) {
return frappe.db.get_link_options("Territory", txt);
},
},
{
fieldname: "show_future_payments",
label: __("Show Future Payments"),
fieldtype: "Check",
},
{
fieldname: "sales_partner",
label: __("Sales Partner"),
fieldtype: "Link",
options: "Sales Partner",
},
{
fieldname: "sales_person",
label: __("Sales Person"),
fieldtype: "Link",
options: "Sales Person",
},
{
fieldname: "show_gl_balance",
label: __("Show GL Balance"),
fieldtype: "Check",
},
{
fieldname: "show_sales_person",
label: __("Show Sales Person"),
fieldtype: "Check",
},
{
fieldname: "based_on_payment_terms",
label: __("Based On Payment Terms"),
fieldtype: "Check",
},
{
fieldname: "for_revaluation_journals",
label: __("Revaluation Journals"),
fieldtype: "Check",
},
],
collapsible_filters: true,
separate_check_filters: true,
formatter: function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
if (data && data.bold) {
value = value.bold();
}
return value;
},
onload: function (report) {
report.page.add_inner_button(__("Consolidated Accounts Receivable"), function () {
frappe.set_route("query-report", "Consolidated Accounts Receivable", report.get_values());
});
const company = frappe.defaults.get_user_default("Company");
if (company && !(report.get_filter_value("companies") || []).length) {
report.set_filter_value("companies", [company]);
}
if (frappe.boot.sysdefaults.default_ageing_range) {
report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range);
}
},
};
function get_party_type_options() {
let options = [];
frappe.db
.get_list("Party Type", { filters: { account_type: "Receivable" }, fields: ["name"] })
.then((res) => {
res.forEach((party_type) => {
options.push(party_type.name);
});
});
return options;
}

View File

@@ -0,0 +1,30 @@
{
"add_total_row": 0,
"add_translate_data": 0,
"columns": [],
"creation": "2026-08-07 10:00:00.000000",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"modified": "2026-08-07 10:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Consolidated Accounts Receivable Summary",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Sales Invoice",
"report_name": "Consolidated Accounts Receivable Summary",
"report_type": "Script Report",
"roles": [
{
"role": "Accounts Manager"
},
{
"role": "Accounts User"
}
],
"timeout": 0
}

View File

@@ -0,0 +1,73 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from collections import OrderedDict
import frappe
from frappe import _
from frappe.utils import flt
from erpnext.accounts.report.accounts_receivable_summary.accounts_receivable_summary import (
AccountsReceivableSummary,
)
from erpnext.accounts.report.consolidated_accounts_receivable.consolidated_accounts_receivable import (
add_company_columns,
get_consolidated_companies,
row_currencies,
rows_per_company,
)
# What a party owes (or is owed) across companies that need not be related to each other.
# Each party gets one row per company, followed by a total row for that party.
def execute(filters=None):
args = {
"account_type": "Receivable",
"naming_by": ["Selling Settings", "cust_master_name"],
}
return ConsolidatedReceivablePayableSummary(filters).run(args)
class ConsolidatedReceivablePayableSummary(AccountsReceivableSummary):
def run(self, args):
self.companies = get_consolidated_companies(self.filters)
return super().run(args)
def get_columns(self):
super().get_columns()
add_company_columns(self.columns)
def get_data(self, args):
self.data = []
for rows in self.get_rows_by_party(args).values():
self.data.extend(rows)
# a total over companies of differing currencies would add unlike units
if len(row_currencies(rows)) <= 1:
self.data.append(self.total_row(rows))
def get_rows_by_party(self, args):
"""Rows of every company, regrouped so a party's companies sit together."""
by_party = OrderedDict()
for row in rows_per_company(self.companies, self.filters, args, AccountsReceivableSummary):
by_party.setdefault(row.party, []).append(row)
return by_party
def total_row(self, rows):
# label sits in the first column, like the total row of the plain summary reports;
# `bold` is picked up by the formatter in the report's js
total = frappe._dict(
party_type=_("Total"),
party="",
company="",
currency=rows[0].get("currency"),
bold=1,
)
for row in rows:
for field, value in row.items():
# `advance` arrives as an int when there is none, so don't filter on float alone
if isinstance(value, int | float) and not isinstance(value, bool):
total[field] = flt(total.get(field)) + value
return total

View File

@@ -0,0 +1,98 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from frappe.utils import today
from erpnext.accounts.report.accounts_receivable_summary.accounts_receivable_summary import (
execute as execute_summary,
)
from erpnext.accounts.report.consolidated_accounts_receivable.test_consolidated_accounts_receivable import (
ConsolidatedReportMixin,
)
from erpnext.accounts.report.consolidated_accounts_receivable_summary.consolidated_accounts_receivable_summary import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestConsolidatedAccountsReceivableSummary(ERPNextTestSuite, ConsolidatedReportMixin):
def setUp(self):
self.maxDiff = None
# deliberately unrelated companies, there is no parent/child link between them
self.company_a = self.create_test_company("_Test Unrelated A", "_TUNA")
self.company_b = self.create_test_company("_Test Unrelated B", "_TUNB")
self.create_customer("_Test Consolidation Customer")
# the mixin would otherwise pass company=None, which the Item Default row rejects
self.create_item("_Test Consolidation Item", company=self.company_a)
def test_party_gets_a_row_per_company_plus_a_total(self):
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(self.company_b, "_TUNB", 300)
rows = execute(self.filters())[1]
self.assertEqual([r.company for r in rows], [self.company_a, self.company_b, ""])
self.assertEqual([r.party for r in rows], [self.customer, self.customer, ""])
self.assertEqual([r.outstanding for r in rows], [200.0, 300.0, 500.0])
self.assertEqual(rows[-1].party_type, "Total")
self.assertTrue(rows[-1].bold)
def test_total_matches_individual_company_summaries(self):
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(self.company_b, "_TUNB", 300)
total = execute(self.filters())[1][-1].outstanding
individual = sum(self.company_outstanding(company) for company in (self.company_a, self.company_b))
self.assertEqual(total, individual)
def test_company_without_transactions_is_omitted(self):
self.create_invoice(self.company_a, "_TUNA", 200)
rows = execute(self.filters())[1]
self.assertEqual([r.company for r in rows], [self.company_a, ""])
self.assertEqual(rows[-1].outstanding, 200.0)
def test_no_companies_selected_returns_nothing(self):
self.create_invoice(self.company_a, "_TUNA", 200)
self.assertEqual(execute(self.filters(companies=[]))[1], [])
def test_all_parties_are_shown_when_no_party_is_selected(self):
self.create_customer("_Test Second Consolidation Customer")
other = self.customer
self.create_customer("_Test Consolidation Customer")
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(self.company_a, "_TUNA", 300, customer=other)
filters = self.filters()
filters.pop("party")
parties = {r.party for r in execute(filters)[1] if r.party}
self.assertEqual(parties, {self.customer, other})
def test_group_by_party_carried_over_from_the_detail_report_is_ignored(self):
# the detail report's button hands its own filters over, and the engine would
# otherwise answer with separator rows the summary cannot total
self.create_invoice(self.company_a, "_TUNA", 200)
rows = execute(self.filters(group_by_party=1))[1]
self.assertEqual([r.outstanding for r in rows], [200.0, 200.0])
def test_no_total_when_companies_use_different_currencies(self):
usd = self.create_test_company("_Test Unrelated USD", "_TUNU", currency="USD")
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(usd, "_TUNU", 300, currency="USD")
rows = execute(self.filters(companies=[self.company_a, usd]))[1]
self.assertEqual([r.company for r in rows], [self.company_a, usd])
self.assertFalse(any(row.get("bold") for row in rows))
# ---------- helpers ----------
def company_outstanding(self, company):
filters = {"company": company, "report_date": today(), "range": "30, 60, 90, 120"}
return sum(r.outstanding for r in execute_summary(filters)[1] if r.party == self.customer)

View File

@@ -7,11 +7,6 @@ from frappe import _
from frappe.query_builder.functions import CurDate, DateDiff
from frappe.utils import cint
from erpnext.stock.doctype.company_restriction.company_restriction import (
get_allowed_companies_condition,
get_allowed_masters_condition,
)
def execute(filters=None):
columns = get_columns()
@@ -113,7 +108,7 @@ def get_sales_details(filters):
# renders the bare CURRENT_DATE keyword. Yields the integer number of days.
days_since_last_order = DateDiff(CurDate(), date_col)
query = (
sales_data = (
frappe.qb.from_(parent)
.inner_join(child)
.on(parent.name == child.parent)
@@ -128,12 +123,7 @@ def get_sales_details(filters):
)
.where(parent.docstatus == 1)
.orderby(days_since_last_order)
)
if condition := get_allowed_companies_condition(parent.company, filters["based_on"]):
query = query.where(condition)
sales_data = query.run(as_dict=True)
).run(as_dict=True)
for d in sales_data:
item_details_map.setdefault((d.territory, d.item_code), d)
@@ -160,14 +150,10 @@ def get_items(filters):
if filters.get("item"):
filters_dict.update({"name": filters["item"]})
item_filters = [filters_dict]
if condition := get_allowed_masters_condition(frappe.qb.DocType("Item").name, "Item"):
item_filters.append(condition)
items = frappe.get_all(
"Item",
fields=["name", "item_group", "item_name", "item_code"],
filters=item_filters,
filters=filters_dict,
order_by="name",
)

View File

@@ -440,15 +440,7 @@ def get_invoices(filters, additional_query_columns):
if filters.get("supplier"):
query = query.where(pi.supplier == filters.supplier)
if filters.get("supplier_group"):
# read the group from the supplier master, to match the Supplier Group column
supplier = frappe.qb.DocType("Supplier")
query = query.where(
pi.supplier.isin(
frappe.qb.from_(supplier)
.select(supplier.name)
.where(supplier.supplier_group == filters.supplier_group)
)
)
query = query.where(pi.supplier_group == filters.supplier_group)
query = get_conditions(filters, query, "Purchase Invoice")

View File

@@ -175,23 +175,6 @@ class TestPurchaseRegister(ERPNextTestSuite):
self.assertEqual(first_row.credit, 600)
self.assertEqual(first_row.balance, 500)
def test_supplier_group_filter_uses_supplier_master(self):
# invoices created before the supplier_group field existed have it blank
pi = make_purchase_invoice()
pi.db_set("supplier_group", None, update_modified=False)
supplier_group = frappe.db.get_value("Supplier", pi.supplier, "supplier_group")
filters = frappe._dict(
company="_Test Company 6",
from_date=add_months(today(), -1),
to_date=today(),
supplier_group=supplier_group,
)
rows = [frappe._dict(row) for row in execute(filters)[1] if row.get("voucher_no") == pi.name]
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].supplier_group, supplier_group)
def make_purchase_invoice():
from erpnext.accounts.doctype.account.test_account import create_account

View File

@@ -6,7 +6,6 @@ from frappe import _
from frappe.query_builder.functions import IfNull
from erpnext.accounts.report.utils import validate_mandatory_date_range
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies_condition
class TaxWithholdingDetailsReport:
@@ -89,8 +88,6 @@ class TaxWithholdingDetailsReport:
query = query.where(twe.party_type == self.filters.party_type)
if self.filters.party:
query = query.where(twe.party == self.filters.party)
if condition := get_allowed_companies_condition(twe.company, "Tax Withholding Entry"):
query = query.where(condition)
return query

View File

@@ -10,7 +10,6 @@ from frappe.utils import cint, flt
from erpnext.accounts.report.general_ledger.general_ledger import get_accounts_with_children
from erpnext.accounts.report.trial_balance.trial_balance import validate_filters
from erpnext.accounts.utils import get_currency_precision
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_masters_condition
def execute(filters=None):
@@ -32,11 +31,7 @@ def get_data(filters, show_party_name):
else:
party_name_field = "name"
party_filters = [{"name": filters.get("party")}] if filters.get("party") else []
party_type = frappe.qb.DocType(filters.get("party_type"))
if condition := get_allowed_masters_condition(party_type.name, filters.get("party_type")):
party_filters.append(condition)
party_filters = {"name": filters.get("party")} if filters.get("party") else {}
parties = frappe.get_all(
filters.get("party_type"),
fields=["name", party_name_field],

View File

@@ -499,27 +499,6 @@ def update_bin_on_delete(row, doctype: str) -> None:
update_bin_qty(row.item_code, row.warehouse, qty_dict)
def validate_no_issued_proforma(rows) -> None:
"""Raise if a Sales Order row being deleted has an issued Proforma Invoice."""
if not rows:
return
proformed = set(
frappe.get_all(
"Proforma Invoice Item",
filters={"so_detail": ["in", [row.name for row in rows]], "docstatus": 1},
pluck="so_detail",
)
)
for row in rows:
if row.name in proformed:
frappe.throw(
_("Row #{0}: Cannot delete item {1} which has an issued Proforma Invoice.").format(
row.idx, row.item_code
)
)
def validate_and_delete_children(parent, data, ordered_item=None) -> bool:
"""Delete child rows not present in data; return True if any were removed."""
updated_item_names = [d.get("docname") for d in data]
@@ -529,9 +508,6 @@ def validate_and_delete_children(parent, data, ordered_item=None) -> bool:
item for item in parent.items if item.name not in updated_item_names and not item.get("closed")
]
if parent.doctype == "Sales Order":
validate_no_issued_proforma(deleted_children)
for d in deleted_children:
validate_child_on_delete(d, parent, ordered_item)
d.flags.ignore_permissions = True

View File

@@ -2781,21 +2781,48 @@ def is_immutable_ledger_enabled():
return frappe.get_single_value("Accounts Settings", "enable_immutable_ledger")
def pre_submit_validation(doc, check_prev_docstatus=False, check_credit_limit=False, check_packed_qty=False):
PRE_SUBMIT_DOCTYPE_CONFIG = {
"Sales Invoice": {
"check_prev_docstatus": True,
"check_credit_limit": True,
},
"Purchase Invoice": {
"check_prev_docstatus": True,
},
"Delivery Note": {
"check_prev_docstatus": True,
"check_credit_limit": True,
"check_packed_qty": True,
},
"Purchase Receipt": {
"check_prev_docstatus": True,
},
"Sales Order": {
"check_credit_limit": True,
},
}
def pre_submit_validation(doc, method=None):
cfg = PRE_SUBMIT_DOCTYPE_CONFIG.get(doc.doctype)
if (
doc.docstatus != 0
or not frappe.get_cached_value("Accounts Settings", None, "preview_mode")
or not cfg
or not doc.company
):
return
_run_pre_submit_checks(doc, cfg)
if check_prev_docstatus:
def _run_pre_submit_checks(doc, cfg):
if cfg.get("check_prev_docstatus"):
_check_prev_docstatus(doc)
if check_credit_limit:
if cfg.get("check_credit_limit"):
_check_credit_limit_warn(doc)
if check_packed_qty:
if cfg.get("check_packed_qty"):
_check_packed_qty_warn(doc)

View File

@@ -37,7 +37,7 @@
},
{
"fieldname": "subject",
"fieldtype": "Text Editor",
"fieldtype": "Small Text",
"in_list_view": 1,
"label": "Subject",
"print_width": "518",
@@ -76,7 +76,7 @@
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-09-25 12:00:00.000000",
"modified": "2024-03-27 13:06:32.933603",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Activity",

View File

@@ -17,7 +17,7 @@ class AssetActivity(Document):
asset: DF.Link
date: DF.Datetime
subject: DF.TextEditor
subject: DF.SmallText
user: DF.Link
# end: auto-generated types

View File

@@ -121,7 +121,7 @@
},
{
"fieldname": "notes",
"fieldtype": "Text Editor",
"fieldtype": "Small Text",
"label": "Notes",
"read_only": 1
},
@@ -217,7 +217,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-09-25 12:00:00.000000",
"modified": "2025-05-23 01:17:16.708004",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Depreciation Schedule",

View File

@@ -40,7 +40,7 @@ class AssetDepreciationSchedule(DepreciationScheduleController):
frequency_of_depreciation: DF.Int
naming_series: DF.Literal["ACC-ADS-.YYYY.-"]
net_purchase_amount: DF.Currency
notes: DF.TextEditor | None
notes: DF.SmallText | None
opening_accumulated_depreciation: DF.Currency
opening_number_of_booked_depreciations: DF.Int
rate_of_depreciation: DF.Percent

View File

@@ -163,7 +163,6 @@
"mps",
"is_internal_supplier",
"inter_company_order_reference",
"remarks",
"connections_tab"
],
"fields": [
@@ -932,13 +931,6 @@
"options": "Sales Order",
"read_only": 1
},
{
"fieldname": "remarks",
"fieldtype": "Small Text",
"label": "Remarks",
"no_copy": 1,
"print_hide": 1
},
{
"fieldname": "column_break_74",
"fieldtype": "Column Break"
@@ -1308,7 +1300,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2026-09-24 12:00:00.000000",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order",

View File

@@ -116,7 +116,6 @@ class PurchaseOrder(BuyingController):
pricing_rules: DF.Table[PricingRuleDetail]
project: DF.Link | None
ref_sq: DF.Link | None
remarks: DF.SmallText | None
represents_company: DF.Link | None
rounded_total: DF.Currency
rounding_adjustment: DF.Currency

View File

@@ -1,7 +1,6 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import importlib
import frappe
from frappe import _, throw
@@ -12,9 +11,6 @@ import erpnext.buying.doctype.supplier_scorecard_variable.supplier_scorecard_var
from erpnext.buying.doctype.supplier_scorecard_criteria.supplier_scorecard_criteria import (
get_variables,
)
from erpnext.buying.doctype.supplier_scorecard_variable.supplier_scorecard_variable import (
VariablePathNotFound,
)
class SupplierScorecardPeriod(Document):
@@ -123,30 +119,11 @@ class SupplierScorecardPeriod(Document):
def import_string_path(path):
app_name = path.split(".", 1)[0]
if app_name not in frappe.get_installed_apps():
throw(_("App {0} is not installed").format(app_name), frappe.AppNotInstalledError)
target, attributes = import_longest_module(path)
for attribute in attributes:
if not hasattr(target, attribute):
throw(_("Could not find path for {0}").format(path), VariablePathNotFound)
target = getattr(target, attribute)
return target
def import_longest_module(path):
parts = path.split(".")
module = importlib.import_module(parts[0])
for index in range(1, len(parts)):
module_name = ".".join(parts[: index + 1])
try:
module = importlib.import_module(module_name)
except ModuleNotFoundError as error:
if error.name != module_name:
raise
return module, parts[index:]
return module, []
components = path.split(".")
mod = __import__(components[0])
for comp in components[1:]:
mod = getattr(mod, comp)
return mod
def make_supplier_scorecard(source_name, target_doc=None):

View File

@@ -1,31 +1,10 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import sys
import tempfile
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import patch
import frappe
from erpnext.buying.doctype.supplier_scorecard_variable.supplier_scorecard_variable import (
VariablePathNotFound,
)
from erpnext.tests.utils import ERPNextTestSuite
CUSTOM_APP = "custom_scorecard_app"
CUSTOM_VARIABLES_SOURCE = """
def get_value(scorecard):
return 7
class Metrics:
@staticmethod
def get_value(scorecard):
return 7
"""
class TestSupplierScorecardPeriod(ERPNextTestSuite):
def test_criteria_score_is_clamped_to_bounds(self):
@@ -76,60 +55,6 @@ class TestSupplierScorecardPeriod(ERPNextTestSuite):
)
self.assertRaises(frappe.ValidationError, period.validate_criteria_weights)
def test_custom_variable_path_in_unimported_module(self):
for attribute in ("get_value", "Metrics.get_value"):
with self.subTest(attribute=attribute):
path = f"{CUSTOM_APP}.variables.{attribute}"
variable = make_variable(path)
period = make_period(
variables=[{"variable_label": "Custom", "param_name": "custom", "path": path}]
)
with unimported_custom_app():
variable.validate_path_exists()
with unimported_custom_app():
period.calculate_variables()
self.assertEqual(period.variables[0].value, 7)
def test_variable_path_outside_installed_apps_is_rejected(self):
period = make_period(variables=[{"variable_label": "OS", "param_name": "os", "path": "os.getcwd"}])
self.assertRaises(frappe.AppNotInstalledError, period.calculate_variables)
def test_missing_variable_path_is_rejected(self):
for path in ("erpnext.no_such_module.get_value", f"{CUSTOM_APP}.variables.missing"):
with self.subTest(path=path):
variable = make_variable(path)
with unimported_custom_app():
self.assertRaises(VariablePathNotFound, variable.validate_path_exists)
def test_variable_module_import_error_is_not_hidden(self):
variable = make_variable(f"{CUSTOM_APP}.broken.get_value")
with unimported_custom_app():
self.assertRaises(ModuleNotFoundError, variable.validate_path_exists)
@contextmanager
def unimported_custom_app():
with tempfile.TemporaryDirectory() as directory:
package = Path(directory, CUSTOM_APP)
package.mkdir()
(package / "__init__.py").touch()
(package / "variables.py").write_text(CUSTOM_VARIABLES_SOURCE)
(package / "broken.py").write_text("import scorecard_missing_dependency\n")
installed_apps = [*frappe.get_installed_apps(), CUSTOM_APP]
with (
patch.object(sys, "path", [directory, *sys.path]),
patch.dict(sys.modules),
patch.object(frappe, "get_installed_apps", return_value=installed_apps),
):
yield
def make_variable(path):
return frappe.get_doc({"doctype": "Supplier Scorecard Variable", "path": path})
def make_period(variables=None, criteria=None):
period = frappe.new_doc("Supplier Scorecard Period")

View File

@@ -36,11 +36,14 @@ class SupplierScorecardVariable(Document):
def validate_path_exists(self):
if "." in self.path:
from erpnext.buying.doctype.supplier_scorecard_period.supplier_scorecard_period import (
import_string_path,
)
try:
from erpnext.buying.doctype.supplier_scorecard_period.supplier_scorecard_period import (
import_string_path,
)
import_string_path(self.path)
import_string_path(self.path)
except AttributeError:
frappe.throw(_("Could not find path for {0}").format(self.path), VariablePathNotFound)
else:
if not hasattr(sys.modules[__name__], self.path):

View File

@@ -7,8 +7,6 @@ from frappe import _
from frappe.query_builder.functions import Min
from frappe.utils import flt
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies_condition
def execute(filters=None):
columns = get_columns(filters)
@@ -214,8 +212,6 @@ def get_mapped_mr_details(filters):
.where((parent.per_ordered >= 0) & (parent.name == child.parent) & (parent.docstatus == 1))
)
query = apply_filters_on_query(filters, parent, child, query)
if condition := get_allowed_companies_condition(parent.company, "Material Request"):
query = query.where(condition)
mr_details = query.run(as_dict=True)
@@ -297,8 +293,6 @@ def get_po_entries(filters):
.groupby(child.parent, child.material_request_item)
)
representative_lines = apply_filters_on_query(filters, parent, child, representative_lines)
if condition := get_allowed_companies_condition(parent.company, "Purchase Order"):
representative_lines = representative_lines.where(condition)
query = (
frappe.qb.from_(parent)

View File

@@ -5,8 +5,6 @@
import frappe
from frappe import _
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies_condition
def execute(filters=None):
if filters.from_date >= filters.to_date:
@@ -85,10 +83,6 @@ def get_subcontract_orders(filters):
["docstatus", "=", 1],
]
order = frappe.qb.DocType("Subcontracting Order")
if condition := get_allowed_companies_condition(order.company, "Subcontracting Order"):
record_filters.append(condition)
return frappe.get_all(
"Subcontracting Order", filters=record_filters, fields=["name", "transaction_date", "supplier"]
)

View File

@@ -5,8 +5,6 @@
import frappe
from frappe import _
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies_condition
def execute(filters=None):
if filters.from_date >= filters.to_date:
@@ -72,10 +70,6 @@ def get_order_items_to_supply(filters):
["Subcontracting Order", "docstatus", "=", 1],
]
order = frappe.qb.DocType("Subcontracting Order")
if condition := get_allowed_companies_condition(order.company, "Subcontracting Order"):
record_filters.append(condition)
return frappe.db.get_all(
"Subcontracting Order",
fields=[

View File

@@ -1153,7 +1153,7 @@ def set_default_income_account_for_item(obj):
obj: Transaction document containing items table with income_account field
"""
company_default = frappe.get_cached_value("Company", obj.company, "default_income_account")
for d in sorted(obj.get("items", default=[]), key=lambda row: row.item_code or ""):
for d in obj.get("items", default=[]):
income_account = getattr(d, "income_account", None)
if d.item_code and income_account and income_account != company_default:
set_item_default(d.item_code, obj.company, "income_account", income_account)

View File

@@ -228,7 +228,7 @@ class Lead(SellingController, CRMNote):
def has_quotation(self):
return frappe.db.get_value(
"Quotation", {"party_name": self.name, "docstatus": 1, "is_active": 1, "status": ["!=", "Lost"]}
"Quotation", {"party_name": self.name, "docstatus": 1, "status": ["!=", "Lost"]}
)
def has_lost_quotation(self):

View File

@@ -302,7 +302,6 @@ class Opportunity(TransactionBase, CRMNote):
"opportunity": self.name,
"status": ("not in", ["Lost", "Cancelled", "Expired"]),
"docstatus": 1,
"is_active": 1,
},
"name",
)
@@ -316,7 +315,6 @@ class Opportunity(TransactionBase, CRMNote):
.select(q.name)
.where(
(q.docstatus == 1)
& (q.is_active == 1)
& (qi.prevdoc_docname == self.name)
& q.status.notin(["Lost", "Cancelled", "Expired"])
)

View File

@@ -336,14 +336,14 @@ permission_query_conditions = {
"Item": "erpnext.stock.doctype.company_restriction.company_restriction.get_permission_query_conditions",
"Customer": "erpnext.stock.doctype.company_restriction.company_restriction.get_permission_query_conditions",
"Supplier": "erpnext.stock.doctype.company_restriction.company_restriction.get_permission_query_conditions",
"*": "erpnext.stock.doctype.company_restriction.company_restriction.get_inherited_permission_query_conditions",
"Item Price": "erpnext.stock.doctype.company_restriction.company_restriction.get_inherited_permission_query_conditions",
}
has_permission = {
"Item": "erpnext.stock.doctype.company_restriction.company_restriction.has_permission",
"Customer": "erpnext.stock.doctype.company_restriction.company_restriction.has_permission",
"Supplier": "erpnext.stock.doctype.company_restriction.company_restriction.has_permission",
"*": "erpnext.stock.doctype.company_restriction.company_restriction.has_inherited_permission",
"Item Price": "erpnext.stock.doctype.company_restriction.company_restriction.has_inherited_permission",
}
has_website_permission = {
@@ -382,6 +382,14 @@ period_closing_doctypes = [
"Subcontracting Receipt",
]
pre_submit_validation_doctypes = [
"Sales Invoice",
"Purchase Invoice",
"Delivery Note",
"Purchase Receipt",
"Sales Order",
]
sqlite_search = ["erpnext.stock.doctype.item.item_search.ItemSearch"]
doc_events = {
@@ -395,9 +403,20 @@ doc_events = {
tuple(period_closing_doctypes): {
"validate": "erpnext.accounts.doctype.accounting_period.accounting_period.validate_accounting_period_on_doc_save",
},
tuple(pre_submit_validation_doctypes): {
"validate": "erpnext.accounts.utils.pre_submit_validation",
},
("Item", "Customer", "Supplier"): {
"validate": "erpnext.stock.doctype.company_restriction.company_restriction.validate_allowed_companies",
},
"Item": {
"on_update": "erpnext.stock.doctype.item.item_search.reindex_item",
"after_rename": "erpnext.stock.doctype.item.item_search.reindex_renamed_item",
},
"Stock Entry": {
"on_submit": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty",
"on_cancel": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty",
},
"User": {
"after_insert": "frappe.contacts.doctype.contact.contact.update_contact",
"validate": "erpnext.setup.doctype.employee.employee.validate_employee_role",

View File

@@ -104,9 +104,7 @@ frappe.ui.form.on("Blanket Order", {
if (frm.doc.blanket_order_type === "Selling") {
frm.set_df_property("customer", "reqd", 1);
frm.set_df_property("supplier", "reqd", 0);
if (frm.doc.supplier) {
frm.set_value("supplier", "");
}
frm.set_value("supplier", "");
frm.set_query("tc_name", function () {
return { filters: { selling: 1 } };
@@ -115,9 +113,7 @@ frappe.ui.form.on("Blanket Order", {
if (frm.doc.blanket_order_type === "Purchasing") {
frm.set_df_property("supplier", "reqd", 1);
frm.set_df_property("customer", "reqd", 0);
if (frm.doc.customer) {
frm.set_value("customer", "");
}
frm.set_value("customer", "");
frm.set_query("tc_name", function () {
return { filters: { buying: 1 } };

View File

@@ -1490,7 +1490,6 @@ def _build_base_bom_items_query(bom, company, qty, t):
Min(t.bom_item.idx).as_("idx"),
Max(t.item_doc.item_name).as_("item_name"),
(Sum(t.qty_field_col / IfNull(t.bom_doc.quantity, 1)) * qty).as_("qty"),
(Sum(t.bom_item.stock_qty / IfNull(t.bom_doc.quantity, 1)) * qty).as_("stock_qty"),
Max(t.item_doc.image).as_("image"),
Max(t.bom_doc.project).as_("project"),
Max(t.item_doc.stock_uom).as_("stock_uom"),
@@ -1608,20 +1607,19 @@ def _add_bom_item_to_dict(item_dict, item, company, opts):
if item.operation:
key = (item.item_code, item.operation)
stock_qty = item.pop("stock_qty")
if item.get("is_phantom_item"):
_merge_phantom_bom_items(item_dict, item, stock_qty, company, opts)
_merge_phantom_bom_items(item_dict, item, company, opts)
elif key in item_dict:
item_dict[key]["qty"] += flt(item.qty)
else:
item_dict[key] = item
def _merge_phantom_bom_items(item_dict, item, stock_qty, company, opts):
def _merge_phantom_bom_items(item_dict, item, company, opts):
data = get_bom_items_as_dict(
item.get("bom_no"),
company,
qty=stock_qty,
qty=item.get("qty"),
fetch_exploded=opts.fetch_exploded,
fetch_secondary_items=opts.fetch_secondary_items,
include_non_stock_items=opts.include_non_stock_items,

View File

@@ -141,36 +141,6 @@ class TestBOM(ERPNextTestSuite):
self.assertEqual(flt(items_dict[component].qty), 1.0)
self.assertNotIn(rm_normal, items_dict)
@timeout
def test_get_items_explodes_phantom_row_by_stock_qty(self):
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
rm = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
kit = make_item(
properties={"is_stock_item": 0, "uoms": [{"uom": "Box", "conversion_factor": 5}]}
).name
phantom_bom = make_bom(item=kit, raw_materials=[rm], do_not_save=True)
phantom_bom.is_phantom_bom = 1
phantom_bom.save()
phantom_bom.submit()
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
bom = make_bom(item=fg_item, raw_materials=[kit], do_not_save=True)
bom.items[0].update({"qty": 2, "uom": "Box", "bom_no": phantom_bom.name})
bom.save()
bom.submit()
for fetch_qty_in_stock_uom in (True, False):
items_dict = get_bom_items_as_dict(
bom.name,
"_Test Company",
qty=1,
fetch_exploded=0,
fetch_qty_in_stock_uom=fetch_qty_in_stock_uom,
)
self.assertEqual(flt(items_dict[rm].qty), 10.0)
@timeout
def test_get_items_amount_uses_each_lines_own_rate(self):
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict

View File

@@ -1789,100 +1789,6 @@ class TestJobCard(ERPNextTestSuite):
8,
)
def test_semi_fg_secondary_items_across_split_job_cards(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.manufacturing.doctype.work_order.mapper import make_job_card
from erpnext.stock.doctype.item.test_item import make_item
warehouse = "Stores - _TC"
rm = make_item("Split JC Scrap RM", {"is_stock_item": 1, "valuation_rate": 100}).name
fg = make_item("Split JC Scrap FG", {"is_stock_item": 1}).name
scrap = make_item("Split JC Scrap", {"is_stock_item": 1, "valuation_rate": 5}).name
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
fg_bom.append("items", {"item_code": rm, "qty": 1, "operation_row_id": 1})
fg_bom.append("secondary_items", {"item_code": scrap, "qty": 1, "secondary_item_type": "Scrap"})
operation = {
"operation": "Split JC Scrap Op",
"workstation": "_Test Workstation A",
"finished_good": fg,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation)
make_operation(operation)
fg_bom.append("operations", operation)
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg,
qty=10,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
work_order.operations[0].time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
job_card = frappe.get_doc(
"Job Card", frappe.db.get_value("Job Card", {"work_order": work_order.name}, "name")
)
job_card.for_quantity = 5
job_card.secondary_items[0].stock_qty = 5
job_card.append(
"time_logs",
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 5},
)
job_card.save()
job_card.submit()
frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit()
make_job_card(
work_order.name,
[
{
"name": work_order.operations[0].name,
"operation": "Split JC Scrap Op",
"qty": 5,
"pending_qty": 5,
"skip_material_transfer": 1,
}
],
)
job_card = frappe.get_doc(
"Job Card", frappe.db.get_value("Job Card", {"work_order": work_order.name, "docstatus": 0})
)
job_card.append(
"time_logs",
{"from_time": "2024-02-02 08:00:00", "to_time": "2024-02-02 09:00:00", "completed_qty": 5},
)
job_card.save()
job_card.submit()
stock_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item())
scrap_qty = sum(row.qty for row in stock_entry.items if row.item_code == scrap)
self.assertEqual(scrap_qty, 5)
def test_batch_split_operation_creates_child_batches(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item

View File

@@ -57,7 +57,7 @@
},
{
"fieldname": "description",
"fieldtype": "Text Editor",
"fieldtype": "Text",
"label": "Description",
"read_only": 1
},
@@ -116,7 +116,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-09-25 12:00:00.000000",
"modified": "2026-06-23 16:52:37.669110",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card Item",

View File

@@ -16,7 +16,7 @@ class JobCardItem(Document):
allow_alternative_item: DF.Check
consumed_qty: DF.Float
description: DF.TextEditor | None
description: DF.Text | None
item_code: DF.Link
item_group: DF.Link | None
item_name: DF.Data | None

View File

@@ -32,6 +32,7 @@ from erpnext.manufacturing.doctype.production_plan.services.material_request imp
)
from erpnext.manufacturing.doctype.production_plan.services.reservation import (
cancel_stock_reservation_entries,
get_non_completed_production_plans,
get_reserved_qty_for_production_plan,
get_reserved_qty_for_sub_assembly,
make_stock_reservation_entries,
@@ -266,14 +267,8 @@ class ProductionPlan(Document):
data.db_update()
self.calculate_total_produced_qty()
self.update_status_and_bin_qty()
def update_status_and_bin_qty(self):
previous_status = self.status
self.set_status()
self.db_set("status", self.status)
if previous_status != self.status and "Completed" in (previous_status, self.status):
self.update_bin_qty()
def on_submit(self):
self.update_bin_qty()
@@ -367,7 +362,11 @@ class ProductionPlan(Document):
return so_wise_planned_qty
def update_bin_qty(self):
self.update_raw_material_bin_qty()
for d in self.mr_items:
if d.warehouse:
bin_name = get_or_make_bin(d.item_code, d.warehouse)
bin = frappe.get_doc("Bin", bin_name, for_update=True)
bin.update_reserved_qty_for_production_plan()
for d in self.sub_assembly_items:
if d.fg_warehouse and d.type_of_manufacturing == "In House":
@@ -375,13 +374,6 @@ class ProductionPlan(Document):
bin = frappe.get_doc("Bin", bin_name, for_update=True)
bin.update_reserved_qty_for_for_sub_assembly()
def update_raw_material_bin_qty(self, item_codes: set[str] | None = None):
for d in self.mr_items:
if d.warehouse and (item_codes is None or d.item_code in item_codes):
bin_name = get_or_make_bin(d.item_code, d.warehouse)
bin = frappe.get_doc("Bin", bin_name, for_update=True)
bin.update_reserved_qty_for_production_plan()
def delete_draft_work_order(self):
for d in frappe.get_all(
"Work Order", fields=["name"], filters={"docstatus": 0, "production_plan": ("=", self.name)}
@@ -392,9 +384,6 @@ class ProductionPlan(Document):
def set_status(self, close: bool | None = None, update_bin: bool = False):
self.check_permission("write")
if close is None and self.status == "Closed":
return
self.status = {0: "Draft", 1: "Submitted", 2: "Cancelled"}.get(self.docstatus)
if close:

View File

@@ -27,85 +27,58 @@ _RESERVATION_TABLES = {
def get_reserved_qty_for_production_plan(item_code, warehouse):
plan_reservations = _get_plan_reservations(item_code)
if not plan_reservations:
from erpnext.manufacturing.doctype.work_order.work_order import get_reserved_qty_for_production
non_completed_production_plans = get_non_completed_production_plans()
reserved = _production_plan_reserved_qty(item_code, warehouse, non_completed_production_plans)
if reserved is None:
return None
work_order_reservations = _get_work_order_reservations(item_code, list(plan_reservations))
reserved_qty = 0.0
for plan, plan_qty_by_warehouse in plan_reservations.items():
reserved_qty += _get_remaining_reserved_qty(
plan_qty_by_warehouse, work_order_reservations.get(plan, {}), warehouse
for_production = flt(
get_reserved_qty_for_production(
item_code, warehouse, non_completed_production_plans, check_production_plan=True
)
return reserved_qty
def _get_remaining_reserved_qty(plan_qty_by_warehouse, work_order_qty_by_warehouse, warehouse):
remaining_qty_by_warehouse = {
plan_warehouse: max(qty - work_order_qty_by_warehouse.get(plan_warehouse, 0.0), 0.0)
for plan_warehouse, qty in plan_qty_by_warehouse.items()
}
total_remaining_qty = sum(remaining_qty_by_warehouse.values())
if not total_remaining_qty:
)
if for_production > reserved:
return 0.0
matched_qty = sum(plan_qty_by_warehouse.values()) - total_remaining_qty
unmatched_qty = min(sum(work_order_qty_by_warehouse.values()) - matched_qty, total_remaining_qty)
remaining_qty = remaining_qty_by_warehouse.get(warehouse, 0.0)
return remaining_qty - remaining_qty * unmatched_qty / total_remaining_qty
return reserved - for_production
def _get_plan_reservations(item_code):
def _production_plan_reserved_qty(item_code, warehouse, non_completed_production_plans):
table = frappe.qb.DocType("Production Plan")
child = frappe.qb.DocType("Material Request Plan Item")
query = (
frappe.qb.from_(table)
.inner_join(child)
.on(table.name == child.parent)
.select(
table.name.as_("production_plan"),
child.warehouse,
Sum(child.required_bom_qty).as_("reserved_qty"),
)
.where(
(table.docstatus == 1)
& (child.item_code == item_code)
& (table.status.notin(["Completed", "Closed"]))
)
.groupby(table.name, child.warehouse)
.select(Sum(child.required_bom_qty))
.where(_plan_reserved_filter(table, child, item_code, warehouse))
)
return _group_by_plan_and_warehouse(query)
if non_completed_production_plans:
query = query.where(table.name.isin(non_completed_production_plans))
result = query.run()
return flt(result[0][0]) if result and result[0][0] is not None else None
def _get_work_order_reservations(item_code, plan_names):
work_order = frappe.qb.DocType("Work Order")
work_order_item = frappe.qb.DocType("Work Order Item")
query = (
frappe.qb.from_(work_order)
.from_(work_order_item)
.select(
work_order.production_plan,
work_order_item.source_warehouse.as_("warehouse"),
Sum(work_order_item.required_qty).as_("reserved_qty"),
)
.where(
(work_order_item.item_code == item_code)
& (work_order_item.parent == work_order.name)
& (work_order.docstatus == 1)
& (IfNull(work_order_item.source_warehouse, "") != "")
& work_order.production_plan.isin(plan_names)
)
.groupby(work_order.production_plan, work_order_item.source_warehouse)
def _plan_reserved_filter(table, child, item_code, warehouse):
return (
(table.docstatus == 1)
& (child.item_code == item_code)
& (child.warehouse == warehouse)
& (table.status.notin(["Completed", "Closed"]))
)
return _group_by_plan_and_warehouse(query)
def _group_by_plan_and_warehouse(query):
reservations = {}
for row in query.run(as_dict=True):
reservations.setdefault(row.production_plan, {})[row.warehouse] = flt(row.reserved_qty)
return reservations
def get_non_completed_production_plans():
table = frappe.qb.DocType("Production Plan")
return (
frappe.qb.from_(table)
.select(table.name)
.distinct()
.where((table.docstatus == 1) & (table.status.notin(["Completed", "Closed"])))
).run(pluck="name")
def get_reserved_qty_for_sub_assembly(item_code, warehouse):

View File

@@ -6,6 +6,7 @@ from frappe.utils import add_to_date, flt, getdate, now_datetime, nowdate
from erpnext.controllers.item_variant import create_variant
from erpnext.manufacturing.doctype.production_plan.production_plan import (
get_items_for_material_requests,
get_non_completed_production_plans,
get_sales_orders,
get_warehouse_list,
)
@@ -1596,183 +1597,6 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertEqual(after_qty, before_qty)
def test_plan_reservation_offsets_work_order_in_another_warehouse(self):
from erpnext.manufacturing.doctype.production_plan.services.reservation import (
get_reserved_qty_for_production_plan,
)
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
plan_warehouse = "_Test Warehouse - _TC"
work_order_warehouse = "_Test Warehouse 1 - _TC"
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse=plan_warehouse)
plan = create_production_plan(
item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1, do_not_submit=1
)
plan.submit()
self.assertEqual(get_reserved_qty_for_production_plan(rm_item, plan_warehouse), 10)
bin_name = frappe.db.get_value("Bin", {"item_code": rm_item, "warehouse": plan_warehouse}, "name")
bin = frappe.get_doc("Bin", bin_name)
self.assertEqual(bin.reserved_qty_for_production_plan, 10)
projected_qty = bin.projected_qty
work_order = submit_work_order_from_plan(plan, 5, work_order_warehouse)
self.assertEqual(get_reserved_qty_for_production_plan(rm_item, plan_warehouse), 5)
bin.reload()
self.assertEqual(bin.reserved_qty_for_production_plan, 5)
self.assertEqual(bin.projected_qty, projected_qty + 5)
work_order.cancel()
bin.reload()
self.assertEqual(bin.reserved_qty_for_production_plan, 10)
self.assertEqual(bin.projected_qty, projected_qty)
def test_plan_reservation_ignores_work_orders_of_other_plans(self):
from erpnext.manufacturing.doctype.production_plan.services.reservation import (
get_reserved_qty_for_production_plan,
)
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
first_warehouse = "_Test Warehouse - _TC"
second_warehouse = "_Test Warehouse 1 - _TC"
plans = []
for warehouse in (first_warehouse, second_warehouse):
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse=warehouse)
plans.append(
create_production_plan(item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1)
)
submit_work_order_from_plan(plans[1], 10, first_warehouse)
self.assertEqual(get_reserved_qty_for_production_plan(rm_item, first_warehouse), 10)
self.assertEqual(get_reserved_qty_for_production_plan(rm_item, second_warehouse), 0)
def test_plan_reservation_kept_for_work_order_without_source_warehouse(self):
from erpnext.manufacturing.doctype.production_plan.services.reservation import (
get_reserved_qty_for_production_plan,
)
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
plan_warehouse = "_Test Warehouse - _TC"
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse=plan_warehouse)
plan = create_production_plan(item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1)
submit_work_order_from_plan(plan, 5, None)
self.assertEqual(get_reserved_qty_for_production_plan(rm_item, plan_warehouse), 10)
self.assertEqual(
frappe.db.get_value(
"Bin", {"item_code": rm_item, "warehouse": plan_warehouse}, "reserved_qty_for_production_plan"
),
10,
)
def test_plan_reservation_released_when_plan_completes(self):
plan, work_order = make_plan_with_sub_assembly()
work_order.submit()
make_stock_entry(
item_code=plan.sub_assembly_items[0].production_item,
qty=5,
rate=10,
target=work_order.source_warehouse,
)
frappe.get_doc(make_se_from_wo(work_order.name, "Material Transfer for Manufacture", 5)).submit()
raw_material = plan.mr_items[0]
bin = frappe.get_doc(
"Bin", {"item_code": raw_material.item_code, "warehouse": raw_material.warehouse}
)
self.assertEqual(bin.reserved_qty_for_production_plan, 5)
manufacture = frappe.get_doc(make_se_from_wo(work_order.name, "Manufacture", 5))
manufacture.submit()
self.assertEqual(frappe.db.get_value("Production Plan", plan.name, "status"), "Completed")
bin.reload()
self.assertEqual(bin.reserved_qty_for_production_plan, 0)
manufacture.cancel()
bin.reload()
self.assertEqual(bin.reserved_qty_for_production_plan, 5)
def test_plan_reservation_released_when_last_work_order_is_closed(self):
from erpnext.manufacturing.doctype.work_order.work_order import close_work_order
plan, work_order = make_plan_with_sub_assembly()
work_order.submit()
plan.make_work_order()
sub_assembly = plan.sub_assembly_items[0]
sub_assembly_work_order = frappe.get_doc(
"Work Order", {"production_plan": plan.name, "production_item": sub_assembly.production_item}
)
sub_assembly_work_order.wip_warehouse = "_Test Warehouse 2 - _TC"
sub_assembly_work_order.submit()
make_stock_entry(
item_code=sub_assembly.production_item, qty=5, rate=10, target=work_order.source_warehouse
)
frappe.get_doc(make_se_from_wo(work_order.name, "Material Transfer for Manufacture", 5)).submit()
frappe.get_doc(make_se_from_wo(work_order.name, "Manufacture", 5)).submit()
bin = frappe.get_doc(
"Bin", {"item_code": sub_assembly.production_item, "warehouse": sub_assembly.fg_warehouse}
)
self.assertEqual(bin.reserved_qty_for_production_plan, 5)
close_work_order(sub_assembly_work_order.name, "Closed")
self.assertEqual(frappe.db.get_value("Production Plan", plan.name, "status"), "Completed")
bin.reload()
self.assertEqual(bin.reserved_qty_for_production_plan, 0)
def test_closed_plan_stays_closed_on_production(self):
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
warehouse = "_Test Warehouse - _TC"
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse=warehouse)
plan = create_production_plan(item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1)
work_order = submit_work_order_from_plan(plan, 5, warehouse)
plan.set_status(close=True)
make_stock_entry(item_code=rm_item, qty=5, rate=10, target=warehouse)
frappe.get_doc(make_se_from_wo(work_order.name, "Material Transfer for Manufacture", 5)).submit()
frappe.get_doc(make_se_from_wo(work_order.name, "Manufacture", 5)).submit()
self.assertEqual(frappe.db.get_value("Production Plan", plan.name, "status"), "Closed")
self.assertEqual(
frappe.db.get_value(
"Bin", {"item_code": rm_item, "warehouse": warehouse}, "reserved_qty_for_production_plan"
),
0,
)
def test_plan_reservation_offsets_are_distributed_across_warehouses(self):
from erpnext.manufacturing.doctype.production_plan.services.reservation import (
_get_remaining_reserved_qty,
)
reservations = {"Warehouse A": 6, "Warehouse B": 4}
cases = [
({"Warehouse A": 5}, 1, 4),
({"Warehouse C": 5}, 3, 2),
({"Warehouse A": 8}, 0, 2),
({"Warehouse C": 20}, 0, 0),
]
for work_order_reservations, warehouse_a_qty, warehouse_b_qty in cases:
with self.subTest(work_order_reservations=work_order_reservations):
self.assertEqual(
_get_remaining_reserved_qty(reservations, work_order_reservations, "Warehouse A"),
warehouse_a_qty,
)
self.assertEqual(
_get_remaining_reserved_qty(reservations, work_order_reservations, "Warehouse B"),
warehouse_b_qty,
)
self.assertEqual(
_get_remaining_reserved_qty({"Warehouse A": 5}, {"Warehouse B": 4}, "Warehouse A"), 1
)
def test_reserved_qty_for_production_plan_for_less_rm_qty(self):
from erpnext.stock.utils import get_or_make_bin
@@ -1788,6 +1612,7 @@ class TestProductionPlan(ERPNextTestSuite):
pln.make_work_order()
plans = []
for row in frappe.get_all("Work Order", filters={"production_plan": pln.name}, fields=["name"]):
wo_doc = frappe.get_doc("Work Order", row.name)
wo_doc.source_warehouse = "_Test Warehouse - _TC"
@@ -1804,12 +1629,16 @@ class TestProductionPlan(ERPNextTestSuite):
)
wo_doc.submit()
plans.append(pln.name)
bin_name = get_or_make_bin("Raw Material Item 1", "_Test Warehouse - _TC")
after_qty = flt(frappe.db.get_value("Bin", bin_name, "reserved_qty_for_production_plan"))
self.assertEqual(after_qty, before_qty)
self.assertNotIn(frappe.db.get_value("Production Plan", pln.name, "status"), ["Completed", "Closed"])
non_completed_plans = get_non_completed_production_plans()
for plan in plans:
self.assertIn(plan, non_completed_plans)
def test_reserved_qty_for_production_plan_for_material_requests_with_multi_UOM(self):
from erpnext.stock.utils import get_or_make_bin
@@ -4218,51 +4047,6 @@ def create_production_plan(**args):
return pln
def submit_work_order_from_plan(plan, qty, source_warehouse):
production_item = next(iter(plan.get_production_items().values()))
production_item["qty"] = qty
work_order = frappe.get_doc("Work Order", plan.create_work_order(production_item))
work_order.source_warehouse = source_warehouse
work_order.wip_warehouse = "_Test Warehouse 2 - _TC"
work_order.fg_warehouse = "_Test Warehouse - _TC"
for item in work_order.required_items:
item.source_warehouse = source_warehouse
work_order.submit()
return work_order
def make_plan_with_sub_assembly():
warehouse = "_Test Warehouse - _TC"
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
sub_assembly_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
make_bom(item=sub_assembly_item, raw_materials=[rm_item], source_warehouse=warehouse)
make_bom(item=fg_item, raw_materials=[sub_assembly_item], source_warehouse=warehouse)
plan = create_production_plan(
item_code=fg_item,
planned_qty=5,
ignore_existing_ordered_qty=1,
sub_assembly_warehouse="_Test Warehouse 1 - _TC",
skip_getting_mr_items=1,
do_not_submit=1,
)
plan.get_sub_assembly_items()
for row in get_items_for_material_requests(plan.as_dict()):
plan.append("mr_items", row)
plan.submit()
production_item = next(iter(plan.get_production_items().values()))
production_item["use_multi_level_bom"] = 0
work_order = frappe.get_doc("Work Order", plan.create_work_order(production_item))
work_order.source_warehouse = warehouse
work_order.wip_warehouse = "_Test Warehouse 2 - _TC"
work_order.fg_warehouse = warehouse
for item in work_order.required_items:
item.source_warehouse = warehouse
return plan, work_order
def make_bom(**args):
args = frappe._dict(args)

View File

@@ -140,7 +140,7 @@
},
{
"fieldname": "description",
"fieldtype": "Text Editor",
"fieldtype": "Small Text",
"label": "description",
"read_only": 1
},
@@ -278,7 +278,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-09-25 12:00:00.000000",
"modified": "2026-08-12 00:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Production Plan Sub Assembly Item",

View File

@@ -18,7 +18,7 @@ class ProductionPlanSubAssemblyItem(Document):
actual_qty: DF.Float
bom_level: DF.Int
bom_no: DF.Link | None
description: DF.TextEditor | None
description: DF.SmallText | None
fg_warehouse: DF.Link | None
indent: DF.Int
item_name: DF.Data | None

View File

@@ -35,36 +35,11 @@ class SalesForecast(Document):
self.db_set("status", "Cancelled")
def generate_manual_demand(self):
if not self.selected_items:
return
item_details_by_code = {
item.name: item
for item in frappe.get_all(
"Item",
filters={"name": ["in", [row.item_code for row in self.selected_items]]},
fields=["name", "item_name", "stock_uom as uom"],
)
}
ascii_items_by_lowercase_code = {}
if frappe.db.db_type == "mariadb":
# Unicode casefold does not always match MariaDB's collation.
ascii_items_by_lowercase_code = {
code.lower(): item for code, item in item_details_by_code.items() if code.isascii()
}
forecast_demand = []
for row in self.selected_items:
if row.item_code not in item_details_by_code:
matching_item = None
if row.item_code.isascii():
matching_item = ascii_items_by_lowercase_code.get(row.item_code.lower())
# Fall back to the database for other collation-equivalent Item codes.
item_details_by_code[row.item_code] = matching_item or frappe.db.get_value(
"Item", row.item_code, ["item_name", "stock_uom as uom"], as_dict=True
)
item_details = item_details_by_code[row.item_code]
item_details = frappe.db.get_value(
"Item", row.item_code, ["item_name", "stock_uom as uom"], as_dict=True
)
for index in range(self.demand_number):
if self.frequency == "Monthly":

View File

@@ -1,35 +1,4 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from types import SimpleNamespace
from unittest.mock import Mock, patch
import frappe
from frappe.tests import UnitTestCase
from erpnext.manufacturing.doctype.sales_forecast.sales_forecast import SalesForecast
class TestSalesForecast(UnitTestCase):
def test_generate_demand_with_casefold_collision(self):
forecast = SimpleNamespace(
selected_items=[SimpleNamespace(item_code="SF-ss"), SimpleNamespace(item_code="SF-ẞ")],
from_date="2026-01-01",
frequency="Monthly",
demand_number=1,
items=[],
)
forecast.append = lambda field, demand: forecast.items.append(demand)
items = [
frappe._dict(name="SF-SS", item_name="ASCII Item", uom="Nos"),
frappe._dict(name="SF-ẞ", item_name="Unicode Item", uom="Kg"),
]
db = SimpleNamespace(db_type="mariadb", get_value=Mock(side_effect=AssertionError))
with patch.object(frappe, "get_all", return_value=items), patch.object(frappe, "db", db):
SalesForecast.generate_manual_demand(forecast)
self.assertEqual(
[(row["item_name"], row["uom"]) for row in forecast.items],
[("ASCII Item", "Nos"), ("Unicode Item", "Kg")],
)
# import frappe

View File

@@ -49,7 +49,6 @@ _SERIAL_BATCH_FIELDS = [
"`tabSerial and Batch Bundle`.`item_code`",
"`tabSerial and Batch Bundle`.`voucher_detail_no`",
]
CONSUMPTION_PURPOSES = ("Manufacture", "Material Consumption for Manufacture")
class WorkOrderStockReservation:
@@ -136,17 +135,15 @@ class WorkOrderStockReservation:
@staticmethod
def _apply_reservation_transfer(doc, qty_to_update, row_wise_serial_batch):
doc.db_set("transferred_qty", flt(qty_to_update), update_modified=False)
if (doc.has_batch_no or doc.has_serial_no) and doc.reservation_based_on == "Serial and Batch":
doc.consume_serial_batch_for_material_transfer(row_wise_serial_batch)
qty_to_update = doc.matched_serial_batch_qty
doc.db_set("transferred_qty", flt(qty_to_update), update_modified=False)
if doc.transferred_qty >= doc.reserved_qty:
doc.db_set("status", "Closed", update_modified=False)
doc.update_status()
doc.update_reserved_stock_in_bin()
doc.update_reserved_qty_in_voucher()
def update_consumed_qty_in_stock_reservation(self, item, consumed_qty, wip_warehouse):
filters = {
@@ -159,7 +156,7 @@ class WorkOrderStockReservation:
if not self.doc.skip_transfer:
filters["from_voucher_no"] = ("is", "set")
row_wise_serial_batch = get_row_wise_serial_batch(self.doc.name, CONSUMPTION_PURPOSES)
row_wise_serial_batch = get_row_wise_serial_batch(self.doc.name, "Manufacture")
names = frappe.get_all("Stock Reservation Entry", filters=filters, pluck="name", order_by="creation")
for name in names:
consumed_qty = self._apply_consumed_qty(name, consumed_qty, row_wise_serial_batch)
@@ -175,11 +172,9 @@ class WorkOrderStockReservation:
if (doc.has_batch_no or doc.has_serial_no) and doc.reservation_based_on == "Serial and Batch":
doc.consume_serial_batch_for_material_transfer(row_wise_serial_batch)
doc.db_set("consumed_qty", doc.matched_serial_batch_qty, update_modified=False)
doc.update_status()
doc.update_reserved_stock_in_bin()
doc.update_reserved_qty_in_voucher()
return consumed_qty
def validate_reserved_qty(self):
@@ -668,7 +663,7 @@ def get_consumed_qty(work_order, item_code):
def _consumed_qty_filter(stock_entry, stock_entry_detail, work_order, item_code):
return (
(stock_entry.work_order == work_order)
& (stock_entry.purpose.isin(CONSUMPTION_PURPOSES))
& (stock_entry.purpose.isin(["Manufacture", "Material Consumption for Manufacture"]))
& (stock_entry.docstatus == 1)
& (stock_entry_detail.s_warehouse.isnotnull())
# An attributed row belongs to its original requirement, not both item codes.
@@ -676,27 +671,31 @@ def _consumed_qty_filter(stock_entry, stock_entry_detail, work_order, item_code)
)
def get_reserved_qty_for_production(item_code: str, warehouse: str) -> float:
def get_reserved_qty_for_production(
item_code: str,
warehouse: str,
non_completed_production_plans: list | None = None,
check_production_plan: bool = False,
) -> float:
"""Get total reserved quantity for any item in specified warehouse"""
wo = frappe.qb.DocType("Work Order")
wo_item = frappe.qb.DocType("Work Order Item")
qty_field = wo_item.required_qty if check_production_plan else _production_reserved_qty_field(wo, wo_item)
query = (
frappe.qb.from_(wo)
.from_(wo_item)
.select(Sum(_production_reserved_qty_field(wo, wo_item)))
.select(Sum(qty_field))
.where(
(wo_item.item_code == item_code)
& (wo_item.parent == wo.name)
& (wo.docstatus == 1)
& (wo_item.source_warehouse == warehouse)
& (wo.status.notin(["Stopped", "Completed", "Closed"]))
& (
(wo_item.required_qty > wo_item.transferred_qty)
| (wo_item.required_qty > wo_item.consumed_qty)
)
)
)
query = _apply_production_plan_filter(
query, wo, wo_item, check_production_plan, non_completed_production_plans
)
return query.run()[0][0] or 0.0
@@ -709,12 +708,28 @@ def _production_reserved_qty_field(wo, wo_item):
return qty_field.else_(wo_item.required_qty - wo_item.consumed_qty)
def _apply_production_plan_filter(query, wo, wo_item, check_production_plan, non_completed_production_plans):
if check_production_plan:
query = query.where(wo.production_plan.isnotnull())
else:
query = query.where(
(wo.status.notin(["Stopped", "Completed", "Closed"]))
& (
(wo_item.required_qty > wo_item.transferred_qty)
| (wo_item.required_qty > wo_item.consumed_qty)
)
)
if non_completed_production_plans:
query = query.where(wo.production_plan.isin(non_completed_production_plans))
return query
def get_row_wise_serial_batch(work_order, purpose=None):
purpose = purpose or "Material Transfer for Manufacture"
purposes = [purpose] if isinstance(purpose, str) else purpose
stock_entries = frappe.get_all(
"Stock Entry",
filters={"work_order": work_order, "purpose": ("in", purposes), "docstatus": 1},
filters={"work_order": work_order, "purpose": purpose, "docstatus": 1},
pluck="name",
)

View File

@@ -423,8 +423,8 @@ class StatusService:
doc = frappe.get_doc("Production Plan", self.doc.production_plan)
doc.flags.ignore_permissions = True
doc.update_status_and_bin_qty()
doc.update_raw_material_bin_qty({d.item_code for d in self.doc.required_items})
doc.set_status()
doc.db_set("status", doc.status)
def _production_plan_ordered_qty(self):
table = frappe.qb.DocType("Work Order")

View File

@@ -1072,20 +1072,6 @@ class TestWorkOrder(ERPNextTestSuite):
wo.load_from_db()
self.assertEqual(wo.status, "Completed")
from erpnext.stock.stock_balance import get_planned_qty
completed_planned_qty = get_bin(wo.production_item, wo.fg_warehouse).planned_qty
expected_completed_qty = get_planned_qty(wo.production_item, wo.fg_warehouse)
se.cancel()
wo.reload()
self.assertEqual(wo.status, "In Process")
cancelled_planned_qty = get_bin(wo.production_item, wo.fg_warehouse).planned_qty
expected_cancelled_qty = get_planned_qty(wo.production_item, wo.fg_warehouse)
self.assertEqual(completed_planned_qty, expected_completed_qty)
self.assertEqual(cancelled_planned_qty, expected_cancelled_qty)
@timeout(seconds=60)
def test_job_card_secondary_item(self):
items = [
@@ -4566,166 +4552,6 @@ class TestWorkOrder(ERPNextTestSuite):
self.assertRaises(frappe.ValidationError, transfer_entry.submit)
@ERPNextTestSuite.change_settings(
"Stock Settings",
{"enable_stock_reservation": 1, "auto_reserve_serial_and_batch": 1},
)
def test_transfer_frees_reserved_batch_in_source_warehouse(self):
production_item = "Test Transfer Batch Release FG"
rm_item = "Test Transfer Batch Release RM"
source_warehouse = "Stores - _TC"
make_item(production_item, {"is_stock_item": 1})
make_item(
rm_item,
{
"is_stock_item": 1,
"has_batch_no": 1,
"batch_number_series": "TST-BATCH-REL-.###",
"create_new_batch": 1,
},
)
make_bom(item=production_item, source_warehouse=source_warehouse, raw_materials=[rm_item])
receipt = test_stock_entry.make_stock_entry(
item_code=rm_item, target=source_warehouse, qty=30, basic_rate=100
)
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
wo = make_wo_order_test_record(
item=production_item, qty=20, reserve_stock=1, source_warehouse=source_warehouse
)
frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 20)).submit()
issue = test_stock_entry.make_stock_entry(
item_code=rm_item,
source=source_warehouse,
qty=5,
batch_no=batch_no,
use_serial_batch_fields=1,
)
self.assertEqual(issue.docstatus, 1)
@ERPNextTestSuite.change_settings(
"Stock Settings",
{"enable_stock_reservation": 1, "auto_reserve_serial_and_batch": 1},
)
def test_transfer_of_other_batch_keeps_reservation_open(self):
production_item = "Test Other Batch Release FG"
rm_item = "Test Other Batch Release RM"
source_warehouse = "Stores - _TC"
make_item(production_item, {"is_stock_item": 1})
make_item(
rm_item,
{
"is_stock_item": 1,
"has_batch_no": 1,
"batch_number_series": "TST-BATCH-OTH-.###",
"create_new_batch": 1,
},
)
make_bom(item=production_item, source_warehouse=source_warehouse, raw_materials=[rm_item])
batches = []
for _ in range(2):
receipt = test_stock_entry.make_stock_entry(
item_code=rm_item, target=source_warehouse, qty=50, basic_rate=100
)
batches.append(get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle))
wo = make_wo_order_test_record(
item=production_item, qty=50, reserve_stock=1, source_warehouse=source_warehouse
)
sre = frappe.get_doc(
"Stock Reservation Entry",
{"voucher_no": wo.name, "warehouse": source_warehouse, "docstatus": 1},
)
reserved_batch = sre.sb_entries[0].batch_no
other_batch = batches[1] if batches[0] == reserved_batch else batches[0]
transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 50))
for row in transfer.items:
row.update(
{"batch_no": other_batch, "use_serial_batch_fields": 1, "serial_and_batch_bundle": None}
)
transfer.insert()
transfer.submit()
sre.reload()
self.assertEqual(sre.status, "Reserved")
self.assertEqual(sre.transferred_qty, 0)
self.assertEqual([(row.batch_no, row.delivered_qty) for row in sre.sb_entries], [(reserved_batch, 0)])
frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 50)).submit()
wo.reload()
self.assertEqual(wo.required_items[0].stock_reserved_qty, 50)
@ERPNextTestSuite.change_settings(
"Stock Settings",
{"enable_stock_reservation": 1, "auto_reserve_serial_and_batch": 1},
)
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"material_consumption": 1})
def test_material_consumption_uses_batch_reservation(self):
production_item = "Test Consumption Reservation FG"
rm_item = "Test Consumption Reservation RM"
source_warehouse = "Stores - _TC"
make_item(production_item, {"is_stock_item": 1})
make_item(
rm_item,
{
"is_stock_item": 1,
"has_batch_no": 1,
"batch_number_series": "TST-BATCH-MCM-.###",
"create_new_batch": 1,
},
)
make_bom(item=production_item, source_warehouse=source_warehouse, raw_materials=[rm_item])
test_stock_entry.make_stock_entry(item_code=rm_item, target=source_warehouse, qty=50, basic_rate=100)
wo = make_wo_order_test_record(
item=production_item, qty=50, reserve_stock=1, source_warehouse=source_warehouse
)
frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 50)).submit()
frappe.get_doc(make_stock_entry(wo.name, "Material Consumption for Manufacture", 50)).submit()
wip_reservation = frappe.db.get_value(
"Stock Reservation Entry",
{"voucher_no": wo.name, "warehouse": wo.wip_warehouse, "docstatus": 1},
["consumed_qty", "status"],
as_dict=True,
)
self.assertEqual(wip_reservation.consumed_qty, 50)
self.assertEqual(wip_reservation.status, "Delivered")
@ERPNextTestSuite.change_settings(
"Stock Settings",
{"enable_stock_reservation": 1, "allow_negative_stock": 0},
)
def test_ledger_preview_ignores_own_work_order_reservation(self):
from erpnext.controllers.ledger_preview import get_stock_ledger_preview
production_item = "Test Preview Reservation FG"
rm_item = "Test Preview Reservation RM"
source_warehouse = "Stores - _TC"
make_item(production_item, {"is_stock_item": 1})
make_item(rm_item, {"is_stock_item": 1})
make_bom(item=production_item, source_warehouse=source_warehouse, raw_materials=[rm_item])
test_stock_entry.make_stock_entry(item_code=rm_item, target=source_warehouse, qty=20, basic_rate=100)
wo = make_wo_order_test_record(
item=production_item, qty=20, reserve_stock=1, source_warehouse=source_warehouse
)
transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 20))
transfer.insert()
transfer.run_method("before_sl_preview")
_, sl_data = get_stock_ledger_preview(transfer, frappe._dict(company=transfer.company))
self.assertEqual(len(sl_data), 2)
@ERPNextTestSuite.change_settings(
"Stock Settings",
{"enable_stock_reservation": 1, "allow_partial_reservation": 1},

View File

@@ -255,11 +255,6 @@ frappe.ui.form.on("Work Order", {
frm.trigger("toggle_items_editable");
frm.trigger("set_fg_warehouse_mandatory");
frm.trigger("toggle_hide_fields");
erpnext.work_order.render_linked_lists(frm);
},
on_tab_change(frm) {
frm.wo_linked_lists && frm.wo_linked_lists.load_active_tab();
},
toggle_hide_fields(frm) {
@@ -904,6 +899,8 @@ erpnext.work_order = {
);
}
erpnext.work_order.setup_stock_reservation(frm);
if (!frm.doc.track_semi_finished_goods) {
const show_start_btn =
frm.doc.skip_transfer || frm.doc.transfer_material_against == "Job Card" ? 0 : 1;
@@ -1053,8 +1050,6 @@ erpnext.work_order = {
}
}
}
erpnext.work_order.setup_stock_reservation(frm);
},
add_start_button(frm) {
@@ -1071,7 +1066,6 @@ erpnext.work_order = {
setup_stock_reservation(frm) {
if (frm.doc.docstatus === 1 && frm.doc.reserve_stock) {
if (
!["Closed", "Completed"].includes(frm.doc.status) &&
frm.events.has_unreserved_stock(frm) &&
(frm.doc.skip_transfer || frm.doc.material_transferred_for_manufacturing < frm.doc.qty)
) {
@@ -1083,11 +1077,13 @@ erpnext.work_order = {
}
if (frm.events.has_reserved_stock(frm)) {
frm.add_custom_button(
__("Unreserve"),
() => erpnext.stock_reservation.unreserve_stock(frm),
__("Stock Reservation")
);
if (frm.doc.skip_transfer || frm.doc.material_transferred_for_manufacturing < frm.doc.qty) {
frm.add_custom_button(
__("Unreserve"),
() => erpnext.stock_reservation.unreserve_stock(frm),
__("Stock Reservation")
);
}
frm.add_custom_button(
__("Reserved Stock"),
@@ -1505,215 +1501,3 @@ frappe.tour["Work Order"] = [
),
},
];
erpnext.work_order.render_linked_lists = function (frm) {
if (!frm.wo_linked_lists) {
frm.wo_linked_lists = new erpnext.work_order.LinkedLists(frm);
}
frm.wo_linked_lists.render();
};
// Same conditions as the "Material Request" toolbar button in set_custom_buttons().
erpnext.work_order.can_create_material_request = function (frm) {
const doc = frm.doc;
if (doc.docstatus !== 1) return false;
if (["Closed", "Completed", "Stopped"].includes(doc.status)) return false;
if (doc.track_semi_finished_goods) return false;
if (doc.skip_transfer || doc.transfer_material_against === "Job Card") return false;
return (doc.required_items || []).some((item) => flt(item.transferred_qty) < flt(item.required_qty));
};
// EmbeddedList with an action in the empty state. Lazy: the class only exists once
// embedded_list.bundle.js has loaded.
erpnext.work_order.get_embedded_list_class = function () {
if (erpnext.work_order._EmbeddedListWithEmptyAction) {
return erpnext.work_order._EmbeddedListWithEmptyAction;
}
erpnext.work_order._EmbeddedListWithEmptyAction = class extends frappe.ui.EmbeddedList {
toggle_result_area() {
super.toggle_result_area();
const has_rows = this.data.length > 0;
const searched = this._all_data && this._all_data.length > 0;
if (has_rows || searched || !this.empty_state_action) return;
const $empty = frappe.ui.empty_state({
icon: this.empty_icon,
title: this.empty_message,
description: this.empty_description,
actions: [this.empty_state_action],
});
this.$no_result.replaceWith($empty);
this.$no_result = $empty;
this.$no_result.toggle(true);
}
};
return erpnext.work_order._EmbeddedListWithEmptyAction;
};
erpnext.work_order.LinkedLists = class WorkOrderLinkedLists {
constructor(frm) {
this.frm = frm;
this.lists = {};
this.tabs = {
job_card_tab: {
html_field: "job_card_list_html",
doctype: "Job Card",
fields: ["name", "status", "docstatus", "operation", "workstation", "for_quantity"],
columns: [
{
label: __("Job Card"),
fieldname: "name",
type: "link",
route: (row) => ["Form", "Job Card", row.name],
},
{ label: __("Operation"), fieldname: "operation" },
{ label: __("Workstation"), fieldname: "workstation" },
{ label: __("For Qty"), fieldname: "for_quantity", align: "right" },
{
label: __("Status"),
render: (row) => {
const [label, color] = frappe.get_indicator(row, "Job Card") || [
row.status,
"gray",
];
return frappe.ui.badge.html({ label, theme: color });
},
},
],
},
material_request_tab: {
html_field: "material_request_list_html",
doctype: "Material Request",
fields: ["name", "status", "material_request_type", "transaction_date"],
empty_message: __("No Material Request created"),
empty_description: __("Create your first Material Request to get started."),
can_add: (frm) => erpnext.work_order.can_create_material_request(frm),
empty_state_action: {
label: __("Create Material Request"),
icon: "plus",
onclick: () => erpnext.work_order.make_material_request(this.frm),
},
columns: [
{
label: __("Material Request"),
fieldname: "name",
type: "link",
route: (row) => ["Form", "Material Request", row.name],
},
{ label: __("Type"), fieldname: "material_request_type" },
{
label: __("Date"),
render: (row) => frappe.format(row.transaction_date, { fieldtype: "Date" }),
},
{ label: __("Status"), fieldname: "status", type: "badge" },
],
},
stock_entry_tab: {
html_field: "stock_entry_list_html",
doctype: "Stock Entry",
fields: ["name", "stock_entry_type", "posting_date", "docstatus"],
columns: [
{
label: __("Stock Entry"),
fieldname: "name",
type: "link",
route: (row) => ["Form", "Stock Entry", row.name],
},
{ label: __("Purpose"), fieldname: "stock_entry_type" },
{
label: __("Date"),
render: (row) => frappe.format(row.posting_date, { fieldtype: "Date" }),
},
{
label: __("Status"),
render: (row) =>
frappe.ui.badge.html({
label: { 0: __("Draft"), 1: __("Submitted"), 2: __("Cancelled") }[
row.docstatus
],
theme: { 0: "gray", 1: "green", 2: "red" }[row.docstatus],
}),
},
],
},
};
}
render() {
if (this.frm.is_new()) {
Object.values(this.tabs).forEach((cfg) => {
const wrapper = this.frm.fields_dict[cfg.html_field]?.$wrapper;
wrapper &&
wrapper
.empty()
.append(
$('<div class="text-muted">').text(
__("Save the Work Order to view linked documents.")
)
);
});
return;
}
frappe
.require("embedded_list.bundle.js")
.then(() => {
this._loaded = true;
this.lists = {};
this.load_active_tab();
})
.catch((e) => {
console.error("Work Order: failed to load embedded_list.bundle.js", e);
});
}
build(tab_fieldname) {
const cfg = this.tabs[tab_fieldname];
if (!cfg) return;
if (this.lists[tab_fieldname]) return;
const wrapper = this.frm.fields_dict[cfg.html_field]?.$wrapper;
if (!wrapper) return;
wrapper.empty();
const can_add = !cfg.can_add || cfg.can_add(this.frm);
const opts = {
wrapper,
doctype: cfg.doctype,
filters: { work_order: this.frm.doc.name },
fields: cfg.fields,
columns: cfg.columns,
order_by: "creation desc",
add_button: can_add ? cfg.add_button : undefined,
empty_state_action: can_add ? cfg.empty_state_action : undefined,
empty_description: cfg.empty_description,
empty_message: cfg.empty_message || __("No {0} linked to this Work Order.", [__(cfg.doctype)]),
};
const ListClass = erpnext.work_order.get_embedded_list_class();
const list = new ListClass(opts);
this.lists[tab_fieldname] = list;
if (tab_fieldname === "job_card_tab") {
// Load Job Card's list settings first so the status badge can reuse its
// indicator colors on the very first render.
frappe.model.with_doctype("Job Card", () => list.refresh());
return;
}
list.refresh();
}
load_active_tab() {
if (!this._loaded || this.frm.is_new()) return;
const active = this.frm.get_active_tab && this.frm.get_active_tab();
const fieldname = active?.df?.fieldname;
if (fieldname && this.tabs[fieldname]) {
this.build(fieldname);
}
}
};

View File

@@ -18,30 +18,33 @@
"qty",
"sales_order",
"reserve_stock",
"allow_alternative_item",
"section_break_vrpa",
"max_producible_qty",
"material_transferred_for_manufacturing",
"additional_transferred_qty",
"column_break_ezmq",
"produced_qty",
"process_loss_qty",
"disassembled_qty",
"column_break_ezmq",
"material_transferred_for_manufacturing",
"additional_transferred_qty",
"max_producible_qty",
"warehouses",
"source_warehouse",
"wip_warehouse",
"column_break_12",
"fg_warehouse",
"scrap_warehouse",
"column_break_dqlq",
"skip_transfer",
"from_wip_warehouse",
"operations_section",
"transfer_material_against",
"operations",
"section_break_ndpq",
"use_multi_level_bom",
"source_warehouse",
"required_items",
"work_order_configuration",
"settings_section",
"allow_alternative_item",
"use_multi_level_bom",
"column_break_17",
"skip_transfer",
"from_wip_warehouse",
"update_consumed_material_cost_in_project",
"time",
"planned_start_date",
"planned_end_date",
@@ -57,12 +60,6 @@
"column_break_24",
"corrective_operation_cost",
"total_operating_cost",
"job_card_tab",
"job_card_list_html",
"material_request_tab",
"material_request_list_html",
"stock_entry_tab",
"stock_entry_list_html",
"more_info",
"production_item_info_section",
"image",
@@ -80,7 +77,6 @@
"secondary_items_section",
"secondary_items",
"reference_section",
"update_consumed_material_cost_in_project",
"project",
"subcontracting_inward_order",
"production_plan",
@@ -289,6 +285,10 @@
"options": "Warehouse",
"read_only_depends_on": "subcontracting_inward_order"
},
{
"fieldname": "column_break_12",
"fieldtype": "Column Break"
},
{
"description": "This is a location where scraped materials are stored.",
"fieldname": "scrap_warehouse",
@@ -422,7 +422,7 @@
},
{
"fieldname": "description",
"fieldtype": "Text Editor",
"fieldtype": "Small Text",
"label": "Item Description",
"read_only": 1
},
@@ -503,6 +503,10 @@
"options": "Work Order",
"read_only": 1
},
{
"fieldname": "settings_section",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_18",
"fieldtype": "Column Break"
@@ -517,7 +521,7 @@
"description": "This is a location where raw materials are available.",
"fieldname": "source_warehouse",
"fieldtype": "Link",
"label": "Default source Warehouse",
"label": "Source Warehouse",
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
"options": "Warehouse",
"read_only_depends_on": "eval:doc.subcontracting_inward_order"
@@ -536,6 +540,10 @@
"fieldtype": "Section Break",
"label": "Finished Good Serial / Batch"
},
{
"fieldname": "column_break_17",
"fieldtype": "Column Break"
},
{
"default": "0",
"fetch_from": "production_item.has_serial_no",
@@ -596,7 +604,7 @@
{
"fieldname": "work_order_configuration",
"fieldtype": "Tab Break",
"label": "Scheduling & Costing"
"label": "Configuration"
},
{
"collapsible": 1,
@@ -724,40 +732,6 @@
"label": "Secondary Items (as per BOM)",
"options": "Work Order Additional Item",
"read_only": 1
},
{
"fieldname": "column_break_dqlq",
"fieldtype": "Column Break"
},
{
"fieldname": "job_card_tab",
"fieldtype": "Tab Break",
"label": "Job Cards"
},
{
"fieldname": "job_card_list_html",
"fieldtype": "HTML",
"label": "Job Cards"
},
{
"fieldname": "material_request_tab",
"fieldtype": "Tab Break",
"label": "Material Requests"
},
{
"fieldname": "material_request_list_html",
"fieldtype": "HTML",
"label": "Material Requests"
},
{
"fieldname": "stock_entry_tab",
"fieldtype": "Tab Break",
"label": "Stock Entries"
},
{
"fieldname": "stock_entry_list_html",
"fieldtype": "HTML",
"label": "Stock Entries"
}
],
"grid_page_length": 50,
@@ -766,7 +740,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
"modified": "2026-09-25 12:00:00.000000",
"modified": "2026-08-08 12:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order",

View File

@@ -121,7 +121,7 @@ class WorkOrder(Document):
bom_no: DF.Link
company: DF.Link
corrective_operation_cost: DF.Currency
description: DF.TextEditor | None
description: DF.SmallText | None
disassembled_qty: DF.Float
expected_delivery_date: DF.Date | None
fg_warehouse: DF.Link | None

View File

@@ -72,7 +72,7 @@
},
{
"fieldname": "description",
"fieldtype": "Text Editor",
"fieldtype": "Text",
"label": "Description",
"read_only": 1
},
@@ -227,7 +227,7 @@
"grid_page_length": 50,
"istable": 1,
"links": [],
"modified": "2026-09-25 12:00:00.000000",
"modified": "2026-08-07 10:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order Item",

View File

@@ -20,7 +20,7 @@ class WorkOrderItem(Document):
available_qty_at_source_warehouse: DF.Float
available_qty_at_wip_warehouse: DF.Float
consumed_qty: DF.Float
description: DF.TextEditor | None
description: DF.Text | None
include_item_in_manufacturing: DF.Check
is_additional_item: DF.Check
is_customer_provided_item: DF.Check

View File

@@ -5,8 +5,6 @@
import frappe
from frappe import _
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies_condition
def execute(filters=None):
data = get_data(filters)
@@ -74,9 +72,6 @@ def get_filtered_data(filters):
if filters.get("workstation"):
bom_ops_query = bom_ops_query.where(bom_ops.workstation == filters.get("workstation"))
if condition := get_allowed_companies_condition(bom.company, "BOM"):
bom_ops_query = bom_ops_query.where(condition)
bom_operation_data = bom_ops_query.run(as_dict=True)
return bom_operation_data

View File

@@ -5,8 +5,6 @@
import frappe
from frappe import _
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies_condition
def execute(filters=None):
columns, data = [], []
@@ -79,9 +77,6 @@ def get_data(filters):
if filters.get("work_order"):
query = query.where(wo.name == filters.get("work_order"))
if condition := get_allowed_companies_condition(wo.company, "Work Order"):
query = query.where(condition)
results = []
for d in query.run(as_dict=True):
results.append(d)

View File

@@ -5,8 +5,6 @@
import frappe
from frappe import _
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies_condition
def execute(filters=None):
columns, data = [], []
@@ -38,13 +36,8 @@ def get_data(filters):
query_filters["report_date"] = ["between", [filters.get("from_date"), filters.get("to_date")]]
inspection_filters = [query_filters]
company = frappe.qb.DocType("Quality Inspection").company
if condition := get_allowed_companies_condition(company, "Quality Inspection"):
inspection_filters.append(condition)
return frappe.get_all(
"Quality Inspection", fields=fields, filters=inspection_filters, order_by="report_date asc"
"Quality Inspection", fields=fields, filters=query_filters, order_by="report_date asc"
)

View File

@@ -7,8 +7,6 @@ from frappe import _
from frappe.query_builder.functions import IfNull, Max, Sum
from frappe.utils import cint
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies_condition
def execute(filters=None):
wo_list = get_work_orders()
@@ -98,13 +96,9 @@ def get_item_list(wo_list, filters):
def get_work_orders():
work_order_filters = [{"docstatus": 1, "status": ("!=", "Completed")}]
if condition := get_allowed_companies_condition(frappe.qb.DocType("Work Order").company, "Work Order"):
work_order_filters.append(condition)
out = frappe.get_all(
"Work Order",
filters=work_order_filters,
filters={"docstatus": 1, "status": ("!=", "Completed")},
fields=["name", "status", "bom_no", "qty", "produced_qty"],
order_by="name",
)

View File

@@ -529,4 +529,3 @@ erpnext.patches.v16_0.set_blanket_order_status
erpnext.patches.v16_0.set_blanket_order_item_stock_uom
erpnext.patches.v16_0.recalculate_subcontracting_order_service_cost
erpnext.patches.v16_0.move_sub_assembly_rate_setting_to_bom_item
erpnext.patches.v16_0.convert_plain_text_line_breaks_to_html

View File

@@ -1,19 +0,0 @@
import frappe
from frappe.utils import escape_html
FIELDS_NOW_TEXT_EDITOR = {
"Delivery Stop": "customer_address",
"Project User": "project_status",
}
def execute():
for doctype, fieldname in FIELDS_NOW_TEXT_EDITOR.items():
rows = frappe.get_all(
doctype,
filters=[[fieldname, "like", "%\n%"], [fieldname, "not like", "%<%"]],
fields=["name", fieldname],
)
for row in rows:
html = escape_html(row[fieldname]).replace("\n", "<br>")
frappe.db.set_value(doctype, row.name, fieldname, html, update_modified=False)

View File

@@ -79,13 +79,13 @@
{
"depends_on": "eval:parent.doctype == 'Project Update'",
"fieldname": "project_status",
"fieldtype": "Text Editor",
"fieldtype": "Text",
"label": "Project Status"
}
],
"istable": 1,
"links": [],
"modified": "2026-09-25 12:00:00.000000",
"modified": "2024-03-27 13:10:21.859474",
"modified_by": "Administrator",
"module": "Projects",
"name": "Project User",

View File

@@ -20,7 +20,7 @@ class ProjectUser(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
project_status: DF.TextEditor | None
project_status: DF.Text | None
user: DF.Link
view_attachments: DF.Check
welcome_email_sent: DF.Check

View File

@@ -179,11 +179,7 @@ erpnext.buying = {
this.frm.set_value("billing_address", r.message.primary_address || "");
const is_drop_ship = this.frm.doc.items.some((item) => item.delivered_by_supplier);
if (
frappe.meta.has_field(this.frm.doc.doctype, "shipping_address") &&
!is_drop_ship
) {
if (frappe.meta.has_field(this.frm.doc.doctype, "shipping_address")) {
this.frm.set_value("shipping_address", r.message.shipping_address || "");
}
},

View File

@@ -43,6 +43,5 @@ import "./financial_statements.js";
import "./sales_trends_filters.js";
import "./purchase_trends_filters.js";
import "./stock_balance_report.js";
import "./subcontracting_inward_report_filters.js";
// import { sum } from 'frappe/public/utils/util.js'

View File

@@ -4,21 +4,19 @@
frappe.ui.form.on("Sales Order", {
refresh(frm) {
erpnext.proforma.toggle_tab(frm, false);
if (frm.doc.docstatus === 0) return;
if (frm.doc.docstatus !== 1) return;
frappe.db.get_single_value("Selling Settings", "enable_proforma_invoice").then((enabled) => {
if (!enabled) return;
if (frm.doc.docstatus === 1) {
// Defer so the button lands after the standard Create options, not before them.
setTimeout(() => {
frm.add_custom_button(
__("Proforma Invoice"),
() => erpnext.proforma.open_dialog(frm),
__("Create")
);
}, 0);
}
// Defer so the button lands after the standard Create options, not before them.
setTimeout(() => {
frm.add_custom_button(
__("Proforma Invoice"),
() => erpnext.proforma.open_dialog(frm),
__("Create")
);
}, 0);
erpnext.proforma.render_list(frm);
});
},
@@ -119,12 +117,6 @@ Object.assign(erpnext.proforma, {
read_only: 1,
in_list_view: 1,
},
{
fieldname: "description",
fieldtype: "Text Editor",
label: __("Description"),
in_list_view: 1,
},
{
fieldname: "qty",
fieldtype: "Float",
@@ -213,12 +205,11 @@ Object.assign(erpnext.proforma, {
const by_amount = values.based_on === "Amount";
const items = (values.items || [])
.filter((row) => flt(by_amount ? row.amount : row.qty) > 0)
.map((row) => ({
so_detail: row.so_detail,
description: row.description,
qty: row.qty,
amount: row.amount,
}));
.map((row) =>
by_amount
? { so_detail: row.so_detail, qty: row.qty, amount: row.amount }
: { so_detail: row.so_detail, qty: row.qty }
);
if (!items.length) {
frappe.msgprint(__("Please enter a quantity or amount for at least one item."));
@@ -323,7 +314,6 @@ Object.assign(erpnext.proforma, {
],
});
list.refresh();
if (frm.doc.docstatus !== 1) return;
frappe.ui
.button({

View File

@@ -216,11 +216,8 @@ $.extend(erpnext.stock_reservation, {
unreserve_stock(frm) {
erpnext.stock_reservation.get_stock_reservation_entries(frm.doctype, frm.docname).then((r) => {
if (!r.exc && r.message) {
const sre_entries = r.message.filter(
(sre) => erpnext.stock_reservation.get_held_qty(sre) > 0
);
if (sre_entries.length > 0) {
erpnext.stock_reservation.prepare_for_cancel_sre_entries(frm, sre_entries);
if (r.message.length > 0) {
erpnext.stock_reservation.prepare_for_cancel_sre_entries(frm, r.message);
} else {
frappe.msgprint(__("No reserved stock to unreserve."));
}
@@ -256,7 +253,7 @@ $.extend(erpnext.stock_reservation, {
sre: sre.name,
item_code: sre.item_code,
warehouse: sre.warehouse,
qty: erpnext.stock_reservation.get_held_qty(sre),
qty: flt(sre.reserved_qty) - flt(sre.delivered_qty),
});
});
@@ -264,12 +261,6 @@ $.extend(erpnext.stock_reservation, {
dialog.show();
},
get_held_qty(sre) {
return (
flt(sre.reserved_qty) - flt(sre.delivered_qty) - flt(sre.transferred_qty) - flt(sre.consumed_qty)
);
},
cancel_stock_reservation(dialog, frm) {
let data = { sr_entries: dialog.fields_dict.sr_entries.grid.get_selected_children() };
let method = "erpnext.manufacturing.doctype.work_order.work_order.cancel_stock_reservation_entries";

View File

@@ -1,32 +0,0 @@
erpnext.get_subcontracting_inward_report_filters = function () {
return [
{
fieldname: "company",
label: __("Company"),
fieldtype: "Link",
options: "Company",
default: frappe.defaults.get_user_default("Company"),
reqd: 1,
},
{
fieldname: "from_date",
label: __("From Date"),
fieldtype: "Date",
default: frappe.datetime.add_months(frappe.datetime.get_today(), -1),
reqd: 1,
},
{
fieldname: "to_date",
label: __("To Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
reqd: 1,
},
{
fieldname: "customer",
label: __("Customer"),
fieldtype: "Link",
options: "Customer",
},
];
};

View File

@@ -1,8 +1,8 @@
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on("Proforma Invoice", {
refresh(frm) {
frm.page.btn_primary.toggle(frm.doc.docstatus !== 2);
},
});
// frappe.ui.form.on("Proforma Invoice", {
// refresh(frm) {
// },
// });

View File

@@ -45,59 +45,8 @@ class ProformaInvoice(Document):
def validate(self) -> None:
validate_feature_enabled()
self.validate_amended_doc()
self.validate_sales_order()
self.set_item_values()
self.set_total_qty()
def validate_sales_order(self) -> None:
if frappe.db.get_value("Sales Order", self.sales_order, "docstatus") != 1:
frappe.throw(_("A Proforma Invoice can only be created against a submitted Sales Order."))
def set_item_values(self) -> None:
"""Copy each line's item details from its Sales Order line, then set the rate and amount."""
so_items = {
row.name: row
for row in frappe.get_all(
"Sales Order Item",
filters={"parent": self.sales_order, "parenttype": "Sales Order"},
fields=["name", "item_code", "item_name", "description", "uom", "rate"],
)
}
for item in self.items:
so_item = so_items.get(item.so_detail)
if not so_item:
frappe.throw(
_("Row #{0}: The line does not belong to Sales Order {1}").format(
item.idx, frappe.bold(self.sales_order)
)
)
item.item_code = so_item.item_code
item.item_name = so_item.item_name
item.uom = so_item.uom
item.description = item.description or so_item.description
self.set_rate_and_amount(item, so_item.rate)
def set_rate_and_amount(self, item, sales_order_rate: float) -> None:
"""Quantity basis bills at the Sales Order rate; Amount basis derives the rate from the amount."""
if flt(item.qty) <= 0:
frappe.throw(_("Row #{0}: Qty must be a positive number").format(item.idx))
if self.based_on == "Amount":
if flt(item.amount) <= 0:
frappe.throw(_("Row #{0}: Amount must be a positive number").format(item.idx))
item.rate = flt(item.amount) / flt(item.qty)
else:
item.rate = sales_order_rate
item.amount = flt(item.qty) * flt(sales_order_rate)
def validate_amended_doc(self) -> None:
if self.amended_from:
frappe.throw(
_("Cannot amend {0} {1}, please create a new one instead.").format(
self.doctype, frappe.bold(self.amended_from)
)
)
def before_submit(self) -> None:
self.status = "Issued"
@@ -131,7 +80,6 @@ class ProformaInvoice(Document):
for item in sales_order.items:
item.qty = lines[item.name].qty
item.rate = lines[item.name].rate
item.description = lines[item.name].description
item.discount_amount = 0
item.discount_percentage = 0
sales_order.run_method("calculate_taxes_and_totals")
@@ -168,7 +116,6 @@ def get_sales_order_items(sales_order: str) -> list[dict]:
{
"item_code": item.item_code,
"item_name": item.item_name,
"description": item.description,
"uom": item.uom,
"so_detail": item.name,
"qty": flt(item.qty),
@@ -208,36 +155,37 @@ def make_proforma_invoice(
print_format: str | None = None,
letter_head: str | None = None,
) -> str:
"""Create and submit a Proforma Invoice from the Sales Order dialog.
"""The sole creation path for a Proforma Invoice (the doctype is `in_create`).
`based_on` decides what the user edited per line: "Quantity" (rate fixed, amount = qty x rate)
or "Amount" (both qty and amount entered, rate derived). `hide_item_qty` (Amount basis only)
hides the qty and rate on the printed proforma for a clean value-based document.
"""
validate_feature_enabled()
selected = frappe.parse_json(items)
sales_order_doc = frappe.get_doc("Sales Order", sales_order)
if sales_order_doc.docstatus != 1:
frappe.throw(_("A Proforma Invoice can only be created against a submitted Sales Order."))
so_items = {item.name: item for item in sales_order_doc.items}
proforma = frappe.new_doc("Proforma Invoice")
proforma.sales_order = sales_order
proforma.based_on = based_on
proforma.hide_item_qty = 1 if (based_on == "Amount" and int(hide_item_qty or 0)) else 0
if naming_series:
proforma.naming_series = naming_series
proforma.print_format = (
print_format
or frappe.db.get_single_value("Selling Settings", "default_proforma_print_format")
or "Proforma Invoice"
proforma.print_format = print_format or frappe.db.get_single_value(
"Selling Settings", "default_proforma_print_format"
)
proforma.letter_head = letter_head
for row in frappe.parse_json(items):
proforma.append(
"items",
{
"so_detail": row.get("so_detail"),
"qty": row.get("qty"),
"amount": row.get("amount"),
"description": row.get("description"),
},
)
for row in selected:
so_item = so_items.get(row.get("so_detail"))
if not so_item:
continue
line = _proforma_line(so_item, based_on, row)
if line:
proforma.append("items", line)
if not proforma.items:
frappe.throw(_("Please enter a quantity or amount for at least one item."))
@@ -247,6 +195,32 @@ def make_proforma_invoice(
return proforma.name
def _proforma_line(so_item, based_on: str, row: dict) -> dict | None:
if based_on == "Amount":
# Amount basis: both qty and amount are user-entered; the rate is derived.
qty = flt(row.get("qty"))
amount = flt(row.get("amount"))
if amount <= 0 or qty <= 0:
return None
rate = amount / qty
else:
qty = flt(row.get("qty"))
if qty <= 0:
return None
rate = flt(so_item.rate)
amount = qty * rate
return {
"item_code": so_item.item_code,
"item_name": so_item.item_name,
"uom": so_item.uom,
"qty": qty,
"rate": rate,
"amount": amount,
"so_detail": so_item.name,
}
@frappe.whitelist()
def send_proforma_email(proforma_name: str, recipients: str) -> None:
proforma = frappe.get_doc("Proforma Invoice", proforma_name)

View File

@@ -6,7 +6,6 @@ import json
import frappe
from frappe.utils import flt
from erpnext.accounts.services.child_item_update import update_child_qty_rate
from erpnext.selling.doctype.proforma_invoice.proforma_invoice import (
get_sales_order_items,
make_proforma_invoice,
@@ -25,9 +24,6 @@ class TestProformaInvoice(ERPNextTestSuite):
name = make_proforma_invoice(sales_order.name, json.dumps(items), **kwargs)
return frappe.get_doc("Proforma Invoice", name)
def make_draft_proforma(self, sales_order, **item):
return frappe.new_doc("Proforma Invoice", sales_order=sales_order.name, items=[item]).insert()
def test_partial_proforma_is_non_blocking(self):
"""A proforma must not touch delivery/billing or the source Sales Order."""
sales_order = make_sales_order(qty=10)
@@ -176,74 +172,6 @@ class TestProformaInvoice(ERPNextTestSuite):
("Proforma Invoice PRO-TEST-0001", "Please find attached the proforma invoice PRO-TEST-0001."),
)
def test_line_description_is_editable(self):
sales_order = make_sales_order(qty=10, do_not_submit=True)
sales_order.items[0].description = "Ordered description"
sales_order.submit()
so_detail = sales_order.items[0].name
edited = make_proforma_invoice(
sales_order.name, json.dumps([{"so_detail": so_detail, "qty": 4, "description": "Edited"}])
)
unedited = self.create_proforma(sales_order, [(so_detail, 4)])
self.assertEqual(get_sales_order_items(sales_order.name)[0]["description"], "Ordered description")
self.assertEqual(frappe.get_doc("Proforma Invoice", edited).items[0].description, "Edited")
self.assertEqual(unedited.items[0].description, "Ordered description")
def test_update_items_cannot_delete_a_proformed_row(self):
sales_order = make_sales_order(
item_list=[
{"item_code": "_Test Item", "qty": 5, "rate": 100},
{"item_code": "_Test Item 2", "qty": 2, "rate": 50},
]
)
proformed, other = sales_order.items
proforma = self.create_proforma(sales_order, [(proformed.name, 2)])
keep_other = json.dumps(
[{"item_code": other.item_code, "qty": other.qty, "rate": other.rate, "docname": other.name}]
)
self.assertRaises(
frappe.ValidationError, update_child_qty_rate, "Sales Order", keep_other, sales_order.name
)
proforma.cancel()
update_child_qty_rate("Sales Order", keep_other, sales_order.name)
sales_order.reload()
self.assertEqual([item.name for item in sales_order.items], [other.name])
def test_line_from_another_sales_order_is_rejected(self):
sales_order = make_sales_order(qty=10)
other_item = make_sales_order(qty=10).items[0]
self.assertRaises(
frappe.ValidationError,
self.make_draft_proforma,
sales_order,
so_detail=other_item.name,
item_code=other_item.item_code,
qty=4,
)
def test_quantity_basis_bills_at_sales_order_rate(self):
sales_order = make_sales_order(qty=10)
so_item = sales_order.items[0]
proforma = self.make_draft_proforma(
sales_order, so_detail=so_item.name, item_code=so_item.item_code, qty=4, rate=1, amount=1
)
item = proforma.items[0]
self.assertEqual(item.item_code, so_item.item_code)
self.assertEqual(flt(item.rate), flt(so_item.rate))
self.assertEqual(flt(item.amount), 4 * flt(so_item.rate))
def test_amended_proforma_is_rejected(self):
proforma = frappe.get_doc({"doctype": "Proforma Invoice", "amended_from": "PRO-TEST-0001"})
self.assertRaises(frappe.ValidationError, proforma.validate_amended_doc)
def test_requires_submitted_sales_order(self):
"""The server rejects a proforma against a draft Sales Order (the button is JS-gated only)."""
sales_order = make_sales_order(qty=10, do_not_submit=True)

View File

@@ -7,7 +7,6 @@
"field_order": [
"item_code",
"item_name",
"description",
"column_break_qty",
"qty",
"uom",
@@ -33,11 +32,6 @@
"label": "Item Name",
"read_only": 1
},
{
"fieldname": "description",
"fieldtype": "Text Editor",
"label": "Description"
},
{
"fieldname": "column_break_qty",
"fieldtype": "Column Break"
@@ -85,7 +79,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-09-24 12:00:00.000000",
"modified": "2026-07-16 00:00:00.000000",
"modified_by": "Administrator",
"module": "Selling",
"name": "Proforma Invoice Item",

View File

@@ -14,7 +14,6 @@ class ProformaInvoiceItem(Document):
from frappe.types import DF
amount: DF.Currency
description: DF.TextEditor | None
item_code: DF.Link
item_name: DF.Data | None
parent: DF.Data

View File

@@ -123,7 +123,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar
{
"Quotation": {
"doctype": "Sales Order",
"validation": {"docstatus": ["=", 1], "is_active": ["=", 1]},
"validation": {"docstatus": ["=", 1]},
"field_no_map": ["payment_terms_template"],
},
"Quotation Item": {
@@ -184,10 +184,7 @@ def _make_sales_invoice(source_name, target_doc=None, ignore_permissions=False,
"Quotation",
source_name,
{
"Quotation": {
"doctype": "Sales Invoice",
"validation": {"docstatus": ["=", 1], "is_active": ["=", 1]},
},
"Quotation": {"doctype": "Sales Invoice", "validation": {"docstatus": ["=", 1]}},
"Quotation Item": {
"doctype": "Sales Invoice Item",
"postprocess": update_item,
@@ -204,32 +201,6 @@ def _make_sales_invoice(source_name, target_doc=None, ignore_permissions=False,
return doclist
@frappe.whitelist()
def make_revision(source_name: str, target_doc: str | dict | Document | None = None):
frappe.get_doc("Quotation", source_name).validate_can_be_revised()
def set_revision_of(source, target):
target.revision_of = source.revision_of or source.name
return get_mapped_doc(
"Quotation",
source_name,
{
"Quotation": {
"doctype": "Quotation",
"validation": {"docstatus": ["=", 1]},
"field_no_map": ["valid_till"],
},
"Quotation Item": {
"doctype": "Quotation Item",
"field_map": {"prevdoc_doctype": "prevdoc_doctype", "prevdoc_docname": "prevdoc_docname"},
},
},
target_doc,
set_revision_of,
)
def _make_customer(source_name, ignore_permissions=False):
quotation = frappe.db.get_value(
"Quotation",

View File

@@ -12,7 +12,6 @@ frappe.ui.form.on("Quotation", {
(frm.custom_make_buttons = {
"Sales Order": "Sales Order",
Quotation: "New Version",
}),
frm.set_query("quotation_to", function () {
return {
@@ -127,7 +126,6 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
if (doc.docstatus == 1 && !["Lost", "Ordered"].includes(doc.status)) {
if (
doc.is_active &&
frappe.model.can_create("Sales Order") &&
(frappe.boot.sysdefaults.allow_sales_order_creation_for_expired_quotation ||
!doc.valid_till ||
@@ -146,38 +144,9 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
if (doc.status !== "Ordered" && this.frm.has_perm("write")) {
this.frm.add_custom_button(__("Set as Lost"), () => {
if (!doc.__onload?.has_versions_to_set_as_lost) {
this.frm.trigger("set_as_lost_dialog");
return;
}
frappe.confirm(
__("The other versions of this Quotation will also be set as Lost. Continue?"),
() => this.frm.trigger("set_as_lost_dialog")
);
this.frm.trigger("set_as_lost_dialog");
});
}
if (frappe.model.can_create("Quotation")) {
this.frm.add_custom_button(
__("New Version"),
() => {
if (doc.__onload?.is_latest_version) {
this.make_revision();
return;
}
frappe.confirm(
__(
"Newer versions of this Quotation already exist. Create a new version anyway?"
),
() => this.make_revision()
);
},
__("Create")
);
this.frm.page.set_inner_btn_group_as_primary(__("Create"));
}
}
if (this.frm.doc.docstatus === 0 && frappe.model.can_read("Opportunity")) {
@@ -232,13 +201,6 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
}
}
make_revision() {
frappe.model.open_mapped_doc({
method: "erpnext.selling.doctype.quotation.mapper.make_revision",
frm: this.frm,
});
}
set_dynamic_field_label() {
if (this.frm.doc.quotation_to == "Customer") {
this.frm.set_df_property("party_name", "label", "Customer");

View File

@@ -18,14 +18,11 @@
"column_break_7",
"transaction_date",
"valid_till",
"is_active",
"column_break1",
"order_type",
"company",
"has_unit_price_items",
"amended_from",
"revision_of",
"is_latest_revision",
"currency_and_price_list",
"currency",
"conversion_rate",
@@ -217,27 +214,6 @@
"read_only": 1,
"width": "150px"
},
{
"fieldname": "revision_of",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Revision Of",
"no_copy": 1,
"options": "Quotation",
"print_hide": 1,
"read_only": 1,
"search_index": 1
},
{
"default": "0",
"fieldname": "is_latest_revision",
"fieldtype": "Check",
"hidden": 1,
"label": "Is Latest Revision",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "company",
"fieldtype": "Link",
@@ -270,16 +246,6 @@
"fieldtype": "Date",
"label": "Valid Till"
},
{
"allow_on_submit": 1,
"default": "1",
"depends_on": "eval:doc.docstatus==1",
"fieldname": "is_active",
"fieldtype": "Check",
"label": "Is Active",
"no_copy": 1,
"read_only_depends_on": "eval:doc.status==\"Lost\""
},
{
"default": "Sales",
"fieldname": "order_type",
@@ -1180,7 +1146,7 @@
"idx": 82,
"is_submittable": 1,
"links": [],
"modified": "2026-09-26 12:00:00.000000",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Selling",
"name": "Quotation",

View File

@@ -4,9 +4,8 @@
import frappe
from frappe import _
from frappe.desk.notifications import get_open_count as get_linked_document_counts
from frappe.model.document import Document
from frappe.utils import cint, formatdate, get_datetime, getdate, nowdate
from frappe.utils import getdate, nowdate
from pypika.terms import ExistsCriterion
from erpnext.controllers.selling_controller import SellingController
@@ -16,7 +15,6 @@ from .mapper import (
)
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
VERSIONS_TO_SET_AS_LOST = {"status": ["not in", ["Partially Ordered", "Ordered", "Lost"]]}
class Quotation(SellingController):
@@ -78,8 +76,6 @@ class Quotation(SellingController):
ignore_pricing_rule: DF.Check
in_words: DF.Data | None
incoterm: DF.Link | None
is_active: DF.Check
is_latest_revision: DF.Check
item_wise_tax_details: DF.Table[ItemWiseTaxDetail]
items: DF.Table[QuotationItem]
language: DF.Link | None
@@ -101,7 +97,6 @@ class Quotation(SellingController):
pricing_rules: DF.Table[PricingRuleDetail]
quotation_to: DF.Link
referral_sales_partner: DF.Link | None
revision_of: DF.Link | None
rounded_total: DF.Currency
rounding_adjustment: DF.Currency
scan_barcode: DF.Data | None
@@ -133,25 +128,6 @@ class Quotation(SellingController):
valid_till: DF.Date | None
# end: auto-generated types
def autoname(self):
if self.revision_of:
self.name = f"{self.revision_of}-R{self.get_next_revision_index()}"
def get_next_revision_index(self):
frappe.db.get_value("Quotation", self.revision_of, "name", for_update=True)
revisions = frappe.get_all(
"Quotation",
filters={"revision_of": self.revision_of, "amended_from": ["is", "not set"]},
pluck="name",
)
return max((cint(name.rsplit("-R", 1)[-1]) for name in revisions), default=0) + 1
def onload(self):
super().onload()
if self.docstatus == 1:
self.set_onload("is_latest_version", self.is_latest_version)
self.set_onload("has_versions_to_set_as_lost", self.has_versions_to_set_as_lost)
def set_indicator(self):
if self.docstatus == 1:
self.indicator_color = "blue"
@@ -170,7 +146,6 @@ class Quotation(SellingController):
self.validate_uom_is_integer("stock_uom", "stock_qty")
self.validate_uom_is_integer("uom", "qty")
self.validate_valid_till()
self.validate_revision()
self.set_customer_name()
if self.items:
self.with_items = 1
@@ -189,49 +164,6 @@ class Quotation(SellingController):
if self.valid_till and getdate(self.valid_till) < getdate(self.transaction_date):
frappe.throw(_("Valid till date cannot be before transaction date"))
def validate_revision(self):
if not self.revision_of:
return
self.validate_revision_matches_original()
if self.get_other_versions({"status": "Lost"}):
frappe.throw(_("Quotation {0} is Lost and cannot be revised.").format(self.revision_of))
later_dates = [version.transaction_date for version in self.get_newer_versions()]
if later_dates:
frappe.throw(
_(
"Transaction Date must be after {0}, the date of the latest version of this Quotation."
).format(formatdate(max(later_dates)))
)
def validate_revision_matches_original(self):
original = frappe.db.get_value(
"Quotation", self.revision_of, ["company", "quotation_to", "party_name"], as_dict=True
)
if self.company != original.company:
frappe.throw(
_("A revision must have the same company as Quotation {0}.").format(self.revision_of)
)
if not self.has_party_of(original):
frappe.throw(
_("A revision must be for the same {0} as Quotation {1}.").format(
_(original.quotation_to), self.revision_of
)
)
def has_party_of(self, original: frappe._dict) -> bool:
if self.quotation_to == original.quotation_to and self.party_name == original.party_name:
return True
return (
original.quotation_to == "Lead"
and self.quotation_to == "Customer"
and frappe.db.get_value("Customer", self.party_name, "lead_name") == original.party_name
)
def set_has_alternative_item(self):
"""Mark 'Has Alternative Item' for rows."""
if not any(row.is_alternative for row in self.get("items")):
@@ -337,10 +269,10 @@ class Quotation(SellingController):
):
self.check_permission("write")
if not (self.is_fully_ordered() or self.is_partially_ordered() or self.has_ordered_versions):
if not (self.is_fully_ordered() or self.is_partially_ordered()):
get_lost_reasons = frappe.get_list("Quotation Lost Reason", fields=["name"])
lost_reasons_lst = [reason.get("name") for reason in get_lost_reasons]
self.db_set({"status": "Lost", "is_active": 1})
self.db_set("status", "Lost")
if detailed_reason:
self.db_set("order_lost_reason", detailed_reason)
@@ -358,7 +290,6 @@ class Quotation(SellingController):
for competitor in competitors:
self.append("competitors", competitor)
self.set_other_versions_as_lost()
self.update_opportunity("Lost")
self.update_lead()
self.save()
@@ -366,15 +297,6 @@ class Quotation(SellingController):
else:
frappe.throw(_("Cannot set as Lost as Sales Order is made."))
def before_update_after_submit(self):
if self.status == "Lost" and self.has_value_changed("is_active"):
frappe.throw(_("Is Active cannot be changed on a Lost Quotation."))
def on_update_after_submit(self):
if self.has_value_changed("is_active"):
self.update_opportunity("Quotation" if self.is_active else "Open")
self.update_lead()
def on_submit(self):
# Check for Approving Authority
frappe.get_cached_doc("Authorization Control").validate_approving_authority(
@@ -384,75 +306,6 @@ class Quotation(SellingController):
# update enquiry status
self.update_opportunity("Quotation")
self.update_lead()
self.deactivate_other_versions()
self.update_latest_revision()
def deactivate_other_versions(self):
if not (self.revision_of and self.is_active):
return
self.update_other_versions({"is_active": 1}, {"is_active": 0})
def set_other_versions_as_lost(self):
self.update_other_versions(VERSIONS_TO_SET_AS_LOST, {"status": "Lost", "is_active": 0})
@property
def has_ordered_versions(self) -> bool:
return bool(self.get_other_versions({"status": ["in", ["Partially Ordered", "Ordered"]]}))
@property
def has_versions_to_set_as_lost(self) -> bool:
return bool(self.get_other_versions(VERSIONS_TO_SET_AS_LOST))
def update_other_versions(self, filters: dict, values: dict):
self.update_versions({version.name: values for version in self.get_other_versions(filters)})
def update_latest_revision(self):
versions = self.get_other_versions({})
if not (versions or self.is_latest_revision):
return
if self.docstatus == 1:
versions.append(self)
latest = max(versions, key=get_version_order).name if len(versions) > 1 else None
self.update_versions(
{
version.name: {"is_latest_revision": int(version.name == latest)}
for version in versions
if version.name != self.name
},
update_modified=False,
)
self.db_set("is_latest_revision", int(self.name == latest), update_modified=False)
@staticmethod
def update_versions(updates: dict[str, dict], update_modified: bool = True):
frappe.db.bulk_update("Quotation", updates, update_modified=update_modified)
for name in updates:
frappe.clear_document_cache("Quotation", name)
@property
def is_latest_version(self) -> bool:
return not self.get_newer_versions()
def get_newer_versions(self) -> list[frappe._dict]:
own_order = get_version_order(self)
return [version for version in self.get_other_versions({}) if get_version_order(version) > own_order]
def validate_can_be_revised(self):
if self.status in ("Lost", "Ordered"):
frappe.throw(_("Cannot revise a Quotation with status {0}.").format(_(self.status)))
def get_other_versions(self, filters: dict, ignore_permissions: bool = True) -> list[frappe._dict]:
original = self.revision_of or self.name
return frappe.get_list(
"Quotation",
filters={"docstatus": 1, "name": ["!=", self.name], **filters},
or_filters={"name": original, "revision_of": original},
fields=["name", "transaction_date", "creation"],
ignore_permissions=ignore_permissions,
)
def on_cancel(self):
if self.lost_reasons:
@@ -463,7 +316,6 @@ class Quotation(SellingController):
self.set_status(update=True)
self.update_opportunity("Open")
self.update_lead()
self.update_latest_revision()
def carry_forward_communication(self):
from erpnext.crm.utils import copy_comments, link_communications
@@ -506,10 +358,6 @@ class Quotation(SellingController):
return rows_with_alternatives
def get_version_order(version) -> tuple:
return (getdate(version.transaction_date), get_datetime(version.creation))
def get_list_context(context=None):
from erpnext.controllers.website_list_for_contact import get_list_context
@@ -527,26 +375,6 @@ def get_list_context(context=None):
return list_context
@frappe.whitelist()
def get_open_count(doctype: str, name: str, items: str | list[str]) -> dict:
items = frappe.parse_json(items)
if not (isinstance(items, list) and all(isinstance(item, str) for item in items)):
frappe.throw(_("Items must be a list of DocType names."))
counts = get_linked_document_counts(doctype, name, [item for item in items if item != "Quotation"])
versions = [
version.name
for version in frappe.get_doc("Quotation", name).get_other_versions(
{"docstatus": ["!=", 2]}, ignore_permissions=False
)
]
if versions and counts["count"]:
counts["count"]["internal_links_found"].append(
{"doctype": "Quotation", "names": versions, "count": len(versions), "open_count": 0}
)
return counts
def set_expired_status():
quotation = frappe.qb.DocType("Quotation")
so = frappe.qb.DocType("Sales Order")

View File

@@ -4,14 +4,11 @@ from frappe import _
def get_data():
return {
"fieldname": "prevdoc_docname",
"method": "erpnext.selling.doctype.quotation.quotation.get_open_count",
"non_standard_fieldnames": {
"Auto Repeat": "reference_document",
"Quotation": "revision_of",
},
"transactions": [
{"label": _("Sales Order"), "items": ["Sales Order"]},
{"label": _("Subscription"), "items": ["Auto Repeat"]},
{"label": _("Versions"), "items": ["Quotation"]},
],
}

View File

@@ -1,14 +1,5 @@
frappe.listview_settings["Quotation"] = {
add_fields: [
"customer_name",
"base_grand_total",
"status",
"company",
"currency",
"valid_till",
"is_active",
"is_latest_revision",
],
add_fields: ["customer_name", "base_grand_total", "status", "company", "currency", "valid_till"],
onload: function (listview) {
if (listview.page.fields_dict.quotation_to) {
@@ -35,18 +26,14 @@ frappe.listview_settings["Quotation"] = {
},
get_indicator: function (doc) {
if (doc.status === "Lost") {
return [__("Lost"), "gray", "status,=,Lost"];
} else if (doc.docstatus === 1 && !doc.is_active) {
return [__("Inactive"), "red", "is_active,=,0"];
} else if (doc.status === "Open" && doc.is_latest_revision) {
return [__("Latest"), "orange", "is_latest_revision,=,1"];
} else if (doc.status === "Open") {
if (doc.status === "Open") {
return [__("Open"), "orange", "status,=,Open"];
} else if (doc.status === "Partially Ordered") {
return [__("Partially Ordered"), "yellow", "status,=,Partially Ordered"];
} else if (doc.status === "Ordered") {
return [__("Ordered"), "green", "status,=,Ordered"];
} else if (doc.status === "Lost") {
return [__("Lost"), "gray", "status,=,Lost"];
} else if (doc.status === "Expired") {
return [__("Expired"), "gray", "status,=,Expired"];
}

View File

@@ -8,9 +8,7 @@ from frappe.tests import change_settings
from frappe.utils import add_days, add_months, flt, getdate, nowdate
from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate
from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity
from erpnext.selling.doctype.quotation.mapper import make_revision, make_sales_invoice, make_sales_order
from erpnext.selling.doctype.quotation.quotation import get_open_count
from erpnext.selling.doctype.quotation.mapper import make_sales_order
from erpnext.tests.utils import ERPNextTestSuite
@@ -498,297 +496,6 @@ class TestQuotation(ERPNextTestSuite):
make_sales_order(quotation.name)
def test_revision_names_follow_the_original(self):
quotation = make_quotation()
first_revision = make_revision(quotation.name)
first_revision.insert()
first_revision.submit()
second_revision = make_revision(first_revision.name).insert()
self.assertEqual(first_revision.name, f"{quotation.name}-R1")
self.assertEqual(second_revision.name, f"{quotation.name}-R2")
self.assertEqual(second_revision.revision_of, quotation.name)
def test_every_version_lists_the_other_versions(self):
quotation = make_quotation()
first_revision = make_revision(quotation.name)
first_revision.insert()
first_revision.submit()
second_revision = make_revision(first_revision.name).insert()
counts = get_open_count("Quotation", first_revision.name, ["Quotation", "Sales Order"])
versions = next(
link for link in counts["count"]["internal_links_found"] if link["doctype"] == "Quotation"
)
self.assertCountEqual(versions["names"], [quotation.name, second_revision.name])
def test_revision_copies_items_and_clears_validity(self):
opportunity = make_opportunity(with_items=1)
quotation = make_quotation(rate=250, do_not_save=1)
quotation.valid_till = add_days(nowdate(), 10)
quotation.items[0].prevdoc_doctype = "Opportunity"
quotation.items[0].prevdoc_docname = opportunity.name
quotation.insert()
quotation.submit()
revision = make_revision(quotation.name).insert()
self.assertIsNone(revision.valid_till)
self.assertEqual(revision.items[0].rate, 250)
self.assertEqual(revision.items[0].prevdoc_docname, opportunity.name)
def test_latest_revision_is_flagged(self):
quotation = make_quotation()
self.assertEqual(quotation.is_latest_revision, 0)
first_revision = make_revision(quotation.name)
first_revision.insert()
first_revision.submit()
second_revision = make_revision(first_revision.name)
second_revision.insert()
second_revision.submit()
self.assertEqual(self.get_latest_revision_flags(quotation), [0, 0, 1])
second_revision.cancel()
self.assertEqual(self.get_latest_revision_flags(quotation), [0, 1, 0])
def get_latest_revision_flags(self, quotation):
return [
frappe.db.get_value("Quotation", name, "is_latest_revision")
for name in (quotation.name, f"{quotation.name}-R1", f"{quotation.name}-R2")
]
def test_submitting_a_revision_deactivates_other_versions(self):
quotation = make_quotation()
first_revision = make_revision(quotation.name)
first_revision.insert()
first_revision.submit()
self.assertEqual(frappe.db.get_value("Quotation", quotation.name, "is_active"), 0)
second_revision = make_revision(first_revision.name)
second_revision.insert()
second_revision.submit()
self.assertEqual(frappe.db.get_value("Quotation", first_revision.name, "is_active"), 0)
self.assertEqual(frappe.db.get_value("Quotation", second_revision.name, "is_active"), 1)
def test_setting_a_revision_as_lost_sets_other_versions_as_lost(self):
quotation = make_quotation()
revision = make_revision(quotation.name)
revision.insert()
revision.submit()
revision.declare_enquiry_lost([], [])
self.assertEqual(frappe.db.get_value("Quotation", quotation.name, "status"), "Lost")
def test_version_cannot_be_set_as_lost_when_another_version_is_ordered(self):
quotation = make_quotation()
revision = make_revision(quotation.name)
revision.insert()
revision.submit()
sales_order = make_sales_order(revision.name)
sales_order.delivery_date = nowdate()
sales_order.insert()
sales_order.submit()
quotation.reload()
self.assertRaises(frappe.ValidationError, quotation.declare_enquiry_lost, [], [])
def test_opportunity_is_lost_when_an_older_version_is_set_as_lost(self):
opportunity = make_opportunity(with_items=1)
quotation = make_quotation(do_not_save=1)
quotation.items[0].prevdoc_doctype = "Opportunity"
quotation.items[0].prevdoc_docname = opportunity.name
quotation.insert()
quotation.submit()
revision = make_revision(quotation.name)
revision.insert()
revision.submit()
quotation.reload()
quotation.declare_enquiry_lost([], [])
self.assertEqual(frappe.db.get_value("Opportunity", opportunity.name, "status"), "Lost")
def test_draft_revision_cannot_be_submitted_after_the_quotation_is_lost(self):
quotation = make_quotation()
revision = make_revision(quotation.name).insert()
quotation.declare_enquiry_lost([], [])
self.assertRaises(frappe.ValidationError, revision.submit)
def test_revision_keeps_the_company_of_the_original(self):
quotation = make_quotation()
revision = make_revision(quotation.name)
revision.company = "_Test Company 1"
self.assertRaisesRegex(frappe.ValidationError, "same company", revision.insert)
def test_revision_keeps_the_customer_of_the_original(self):
quotation = make_quotation()
revision = make_revision(quotation.name)
revision.update(
{"party_name": "_Test Customer 1", "customer_address": None, "shipping_address_name": None}
)
self.assertRaisesRegex(frappe.ValidationError, "same Customer", revision.insert)
def test_revision_of_a_lead_quotation_can_move_to_its_customer(self):
from erpnext.crm.doctype.lead.mapper import make_customer
from erpnext.crm.doctype.lead.test_lead import make_lead
lead = make_lead()
quotation = make_quotation(do_not_save=1)
quotation.quotation_to = "Lead"
quotation.party_name = lead.name
quotation.insert()
quotation.submit()
customer = make_customer(lead.name).insert(ignore_permissions=True)
unrelated_revision = make_revision(quotation.name)
unrelated_revision.update({"quotation_to": "Customer", "party_name": "_Test Customer"})
self.assertRaisesRegex(frappe.ValidationError, "same Lead", unrelated_revision.insert)
revision = make_revision(quotation.name)
revision.update({"quotation_to": "Customer", "party_name": customer.name})
revision.insert()
def test_an_older_version_can_be_set_as_lost(self):
quotation = make_quotation()
revision = make_revision(quotation.name)
revision.insert()
revision.submit()
quotation.reload()
self.assertTrue(quotation.has_versions_to_set_as_lost)
quotation.declare_enquiry_lost([], [])
self.assertEqual(frappe.db.get_value("Quotation", quotation.name, "is_active"), 1)
self.assertEqual(
frappe.db.get_value("Quotation", revision.name, ["status", "is_active"]), ("Lost", 0)
)
self.assertFalse(quotation.has_versions_to_set_as_lost)
def test_latest_version_is_ordered_by_transaction_date(self):
quotation = make_quotation()
first_revision = make_revision(quotation.name)
first_revision.transaction_date = add_days(quotation.transaction_date, 2)
first_revision.insert()
second_revision = make_revision(quotation.name)
second_revision.transaction_date = add_days(quotation.transaction_date, 1)
second_revision.insert()
second_revision.submit()
first_revision.submit()
self.assertTrue(first_revision.is_latest_version)
self.assertFalse(second_revision.is_latest_version)
def test_older_revision_cannot_be_submitted_after_a_newer_one(self):
quotation = make_quotation()
first_revision = make_revision(quotation.name).insert()
second_revision = make_revision(quotation.name).insert()
second_revision.submit()
self.assertRaises(frappe.ValidationError, first_revision.submit)
def test_revision_cannot_be_dated_before_the_latest_version(self):
quotation = make_quotation()
revision = make_revision(quotation.name)
revision.transaction_date = add_days(quotation.transaction_date, -1)
self.assertRaises(frappe.ValidationError, revision.insert)
def test_an_older_version_can_be_revised(self):
quotation = make_quotation()
first_revision = make_revision(quotation.name)
first_revision.insert()
first_revision.submit()
second_revision = make_revision(quotation.name).insert()
self.assertEqual(second_revision.name, f"{quotation.name}-R2")
def test_is_active_is_locked_on_a_lost_quotation(self):
quotation = make_quotation()
quotation.declare_enquiry_lost([], [])
quotation.reload()
quotation.is_active = 0
self.assertRaises(frappe.ValidationError, quotation.save)
def test_lost_quotation_cannot_be_revised(self):
quotation = make_quotation()
quotation.declare_enquiry_lost([], [])
self.assertRaises(frappe.ValidationError, make_revision, quotation.name)
def test_draft_quotation_cannot_be_revised(self):
quotation = make_quotation(do_not_submit=1)
self.assertRaises(frappe.ValidationError, make_revision, quotation.name)
def test_inactive_quotation_cannot_be_ordered_or_invoiced(self):
quotation = make_quotation()
quotation.is_active = 0
quotation.save()
self.assertRaises(frappe.ValidationError, make_sales_order, quotation.name)
self.assertRaises(frappe.ValidationError, make_sales_invoice, quotation.name)
def test_sales_order_cannot_be_submitted_against_a_lost_quotation(self):
quotation = make_quotation()
sales_order = make_sales_order(quotation.name)
sales_order.delivery_date = nowdate()
sales_order.insert()
quotation.declare_enquiry_lost([], [])
self.assertRaisesRegex(frappe.ValidationError, "is Lost", sales_order.submit)
def test_sales_order_cannot_be_submitted_against_an_inactive_quotation(self):
quotation = make_quotation()
sales_order = make_sales_order(quotation.name)
sales_order.delivery_date = nowdate()
sales_order.insert()
quotation.is_active = 0
quotation.save()
self.assertRaises(frappe.ValidationError, sales_order.submit)
def test_deactivating_a_quotation_reopens_its_opportunity(self):
opportunity = make_opportunity(with_items=0)
quotation = make_quotation(do_not_save=1)
quotation.opportunity = opportunity.name
quotation.insert()
quotation.submit()
quotation.is_active = 0
quotation.save()
self.assertEqual(frappe.db.get_value("Opportunity", opportunity.name, "status"), "Open")
def test_inactive_quotation_is_not_an_active_offer(self):
opportunity = make_opportunity(with_items=1)
quotation = make_quotation(do_not_save=1)
quotation.items[0].prevdoc_doctype = "Opportunity"
quotation.items[0].prevdoc_docname = opportunity.name
quotation.insert()
quotation.submit()
opportunity.reload()
self.assertTrue(opportunity.has_active_quotation())
quotation.is_active = 0
quotation.save()
self.assertFalse(opportunity.has_active_quotation())
def test_create_quotation_with_margin(self):
from erpnext.selling.doctype.quotation.mapper import make_sales_order
from erpnext.selling.doctype.sales_order.mapper import (
@@ -798,14 +505,15 @@ class TestQuotation(ERPNextTestSuite):
rate_with_margin = flt((1500 * 18.75) / 100 + 1500)
quotation = frappe.copy_doc(self.globalTestRecords["Quotation"][0])
test_record = frappe.copy_doc(self.globalTestRecords["Quotation"][0])
quotation.items[0].price_list_rate = 1500
quotation.items[0].margin_type = "Percentage"
quotation.items[0].margin_rate_or_amount = 18.75
test_record.items[0].price_list_rate = 1500
test_record.items[0].margin_type = "Percentage"
test_record.items[0].margin_rate_or_amount = 18.75
# set rate to zero, so that it is recalculated on save
quotation.items[0].rate = 0
test_record.items[0].rate = 0
quotation = frappe.copy_doc(test_record)
quotation.transaction_date = nowdate()
quotation.valid_till = add_months(quotation.transaction_date, 1)
quotation.insert()

View File

@@ -1257,7 +1257,6 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
get_query_filters: {
company: me.frm.doc.company,
docstatus: 1,
is_active: 1,
status: ["not in", ["Lost", "Ordered"]],
},
allow_child_item_selection: true,

View File

@@ -19,7 +19,6 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
update_linked_doc,
validate_inter_company_party,
)
from erpnext.accounts.utils import pre_submit_validation
from erpnext.controllers.selling_controller import SellingController
from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
validate_against_blanket_order,
@@ -275,7 +274,6 @@ class SalesOrder(SellingController):
StatusService(self).set_default_statuses()
self.reset_default_field_value("set_warehouse", "items", "warehouse")
pre_submit_validation(self, check_credit_limit=True)
def set_has_unit_price_items(self):
"""
@@ -504,12 +502,6 @@ class SalesOrder(SellingController):
if doc.docstatus.is_cancelled():
frappe.throw(_("Quotation {0} is cancelled").format(quotation))
if flag == "submit" and doc.status == "Lost":
frappe.throw(_("Quotation {0} is Lost").format(quotation))
if flag == "submit" and not doc.is_active:
frappe.throw(_("Quotation {0} is inactive").format(quotation))
doc.set_status(update=True)
doc.update_opportunity("Converted" if flag == "submit" else "Quotation")

Some files were not shown because too many files have changed in this diff Show More