mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-26 14:57:06 +00:00
Compare commits
47 Commits
consolidat
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a2c60e098 | ||
|
|
1fe6c2e92b | ||
|
|
8a200aa92c | ||
|
|
6b30cef7ac | ||
|
|
c62e06bd4c | ||
|
|
d125b84968 | ||
|
|
db292cd61c | ||
|
|
1eae08bd22 | ||
|
|
e78b1e4c2a | ||
|
|
65b8a78aa3 | ||
|
|
9a22514c93 | ||
|
|
8e378d923d | ||
|
|
213284cfa2 | ||
|
|
c8511e01eb | ||
|
|
fd8e6230f3 | ||
|
|
8cdff3bfba | ||
|
|
48720781ce | ||
|
|
a710111db9 | ||
|
|
1d1562a68e | ||
|
|
12d342cc62 | ||
|
|
75efa2d1cf | ||
|
|
f6bd6733c8 | ||
|
|
dbada3f461 | ||
|
|
f45b22b79c | ||
|
|
4dd9f3bf67 | ||
|
|
63ae699e41 | ||
|
|
3e5da36b59 | ||
|
|
4f1f676b24 | ||
|
|
ec775eb782 | ||
|
|
d687024b88 | ||
|
|
e10129ef5e | ||
|
|
387c6aad48 | ||
|
|
a8d452983a | ||
|
|
b0c4e3432f | ||
|
|
56e9e6f8ed | ||
|
|
e4caad8729 | ||
|
|
53b92732c5 | ||
|
|
b5be0aa534 | ||
|
|
da6dadcdd4 | ||
|
|
8e757abe57 | ||
|
|
9cc2c2272a | ||
|
|
712cb5d5b3 | ||
|
|
60db07a27d | ||
|
|
af9e5c61e0 | ||
|
|
66ca3af4c8 | ||
|
|
b78c2bc509 | ||
|
|
8d085e50a4 |
@@ -7,6 +7,7 @@ frappe.ui.form.on("Bank Statement Import", {
|
||||
return {
|
||||
filters: {
|
||||
company: doc.company,
|
||||
is_company_account: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -72,7 +72,7 @@ class BankStatementImport(DataImport):
|
||||
self.template_warnings = ""
|
||||
|
||||
if self.import_file and not self.import_file.lower().endswith(".txt"):
|
||||
self.validate_import_file()
|
||||
self.get_importer()
|
||||
self.validate_google_sheets_url()
|
||||
|
||||
def start_import(self):
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
from frappe import _
|
||||
|
||||
|
||||
def get_data():
|
||||
return {
|
||||
"non_standard_fieldnames": {"MapReduce Job": "document_name"},
|
||||
"transactions": [{"label": _("Job"), "items": ["MapReduce Job"]}],
|
||||
}
|
||||
@@ -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: 0,
|
||||
closing_amount: p.amount,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -438,6 +438,7 @@ 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)
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ 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,
|
||||
)
|
||||
@@ -311,6 +312,8 @@ 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
|
||||
|
||||
@@ -2278,6 +2278,25 @@ 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"
|
||||
|
||||
@@ -22,7 +22,11 @@ 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 refresh_subscription_status, update_voucher_outstanding
|
||||
from erpnext.accounts.utils import (
|
||||
pre_submit_validation,
|
||||
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
|
||||
@@ -386,6 +390,7 @@ 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:
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -7,6 +7,11 @@ 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()
|
||||
@@ -108,7 +113,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)
|
||||
|
||||
sales_data = (
|
||||
query = (
|
||||
frappe.qb.from_(parent)
|
||||
.inner_join(child)
|
||||
.on(parent.name == child.parent)
|
||||
@@ -123,7 +128,12 @@ def get_sales_details(filters):
|
||||
)
|
||||
.where(parent.docstatus == 1)
|
||||
.orderby(days_since_last_order)
|
||||
).run(as_dict=True)
|
||||
)
|
||||
|
||||
if condition := get_allowed_companies_condition(parent.company, filters["based_on"]):
|
||||
query = query.where(condition)
|
||||
|
||||
sales_data = query.run(as_dict=True)
|
||||
|
||||
for d in sales_data:
|
||||
item_details_map.setdefault((d.territory, d.item_code), d)
|
||||
@@ -150,10 +160,14 @@ 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=filters_dict,
|
||||
filters=item_filters,
|
||||
order_by="name",
|
||||
)
|
||||
|
||||
|
||||
@@ -440,7 +440,15 @@ def get_invoices(filters, additional_query_columns):
|
||||
if filters.get("supplier"):
|
||||
query = query.where(pi.supplier == filters.supplier)
|
||||
if filters.get("supplier_group"):
|
||||
query = query.where(pi.supplier_group == filters.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 = get_conditions(filters, query, "Purchase Invoice")
|
||||
|
||||
|
||||
@@ -175,6 +175,23 @@ 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
|
||||
|
||||
@@ -6,6 +6,7 @@ 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:
|
||||
@@ -88,6 +89,8 @@ 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
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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):
|
||||
@@ -31,7 +32,11 @@ def get_data(filters, show_party_name):
|
||||
else:
|
||||
party_name_field = "name"
|
||||
|
||||
party_filters = {"name": filters.get("party")} if filters.get("party") else {}
|
||||
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)
|
||||
|
||||
parties = frappe.get_all(
|
||||
filters.get("party_type"),
|
||||
fields=["name", party_name_field],
|
||||
|
||||
@@ -499,6 +499,27 @@ 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]
|
||||
@@ -508,6 +529,9 @@ 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
|
||||
|
||||
@@ -2781,48 +2781,21 @@ def is_immutable_ledger_enabled():
|
||||
return frappe.get_single_value("Accounts Settings", "enable_immutable_ledger")
|
||||
|
||||
|
||||
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)
|
||||
def pre_submit_validation(doc, check_prev_docstatus=False, check_credit_limit=False, check_packed_qty=False):
|
||||
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)
|
||||
|
||||
|
||||
def _run_pre_submit_checks(doc, cfg):
|
||||
if cfg.get("check_prev_docstatus"):
|
||||
if check_prev_docstatus:
|
||||
_check_prev_docstatus(doc)
|
||||
|
||||
if cfg.get("check_credit_limit"):
|
||||
if check_credit_limit:
|
||||
_check_credit_limit_warn(doc)
|
||||
|
||||
if cfg.get("check_packed_qty"):
|
||||
if check_packed_qty:
|
||||
_check_packed_qty_warn(doc)
|
||||
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "subject",
|
||||
"fieldtype": "Small Text",
|
||||
"fieldtype": "Text Editor",
|
||||
"in_list_view": 1,
|
||||
"label": "Subject",
|
||||
"print_width": "518",
|
||||
@@ -76,7 +76,7 @@
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:06:32.933603",
|
||||
"modified": "2026-09-25 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset Activity",
|
||||
|
||||
@@ -17,7 +17,7 @@ class AssetActivity(Document):
|
||||
|
||||
asset: DF.Link
|
||||
date: DF.Datetime
|
||||
subject: DF.SmallText
|
||||
subject: DF.TextEditor
|
||||
user: DF.Link
|
||||
# end: auto-generated types
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "notes",
|
||||
"fieldtype": "Small Text",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Notes",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -217,7 +217,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-05-23 01:17:16.708004",
|
||||
"modified": "2026-09-25 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset Depreciation Schedule",
|
||||
|
||||
@@ -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.SmallText | None
|
||||
notes: DF.TextEditor | None
|
||||
opening_accumulated_depreciation: DF.Currency
|
||||
opening_number_of_booked_depreciations: DF.Int
|
||||
rate_of_depreciation: DF.Percent
|
||||
|
||||
@@ -163,6 +163,7 @@
|
||||
"mps",
|
||||
"is_internal_supplier",
|
||||
"inter_company_order_reference",
|
||||
"remarks",
|
||||
"connections_tab"
|
||||
],
|
||||
"fields": [
|
||||
@@ -931,6 +932,13 @@
|
||||
"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"
|
||||
@@ -1300,7 +1308,7 @@
|
||||
"idx": 105,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified": "2026-09-24 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order",
|
||||
|
||||
@@ -116,6 +116,7 @@ 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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import importlib
|
||||
|
||||
import frappe
|
||||
from frappe import _, throw
|
||||
@@ -11,6 +12,9 @@ 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):
|
||||
@@ -119,11 +123,30 @@ class SupplierScorecardPeriod(Document):
|
||||
|
||||
|
||||
def import_string_path(path):
|
||||
components = path.split(".")
|
||||
mod = __import__(components[0])
|
||||
for comp in components[1:]:
|
||||
mod = getattr(mod, comp)
|
||||
return mod
|
||||
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, []
|
||||
|
||||
|
||||
def make_supplier_scorecard(source_name, target_doc=None):
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
# 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):
|
||||
@@ -55,6 +76,60 @@ 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")
|
||||
|
||||
@@ -36,14 +36,11 @@ class SupplierScorecardVariable(Document):
|
||||
|
||||
def validate_path_exists(self):
|
||||
if "." in self.path:
|
||||
try:
|
||||
from erpnext.buying.doctype.supplier_scorecard_period.supplier_scorecard_period import (
|
||||
import_string_path,
|
||||
)
|
||||
from erpnext.buying.doctype.supplier_scorecard_period.supplier_scorecard_period import (
|
||||
import_string_path,
|
||||
)
|
||||
|
||||
import_string_path(self.path)
|
||||
except AttributeError:
|
||||
frappe.throw(_("Could not find path for {0}").format(self.path), VariablePathNotFound)
|
||||
import_string_path(self.path)
|
||||
|
||||
else:
|
||||
if not hasattr(sys.modules[__name__], self.path):
|
||||
|
||||
@@ -7,6 +7,8 @@ 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)
|
||||
@@ -212,6 +214,8 @@ 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)
|
||||
|
||||
@@ -293,6 +297,8 @@ 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)
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
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:
|
||||
@@ -83,6 +85,10 @@ 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"]
|
||||
)
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
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:
|
||||
@@ -70,6 +72,10 @@ 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=[
|
||||
|
||||
@@ -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 obj.get("items", default=[]):
|
||||
for d in sorted(obj.get("items", default=[]), key=lambda row: row.item_code or ""):
|
||||
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)
|
||||
|
||||
@@ -228,7 +228,7 @@ class Lead(SellingController, CRMNote):
|
||||
|
||||
def has_quotation(self):
|
||||
return frappe.db.get_value(
|
||||
"Quotation", {"party_name": self.name, "docstatus": 1, "status": ["!=", "Lost"]}
|
||||
"Quotation", {"party_name": self.name, "docstatus": 1, "is_active": 1, "status": ["!=", "Lost"]}
|
||||
)
|
||||
|
||||
def has_lost_quotation(self):
|
||||
|
||||
@@ -302,6 +302,7 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
"opportunity": self.name,
|
||||
"status": ("not in", ["Lost", "Cancelled", "Expired"]),
|
||||
"docstatus": 1,
|
||||
"is_active": 1,
|
||||
},
|
||||
"name",
|
||||
)
|
||||
@@ -315,6 +316,7 @@ 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"])
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
"Item Price": "erpnext.stock.doctype.company_restriction.company_restriction.get_inherited_permission_query_conditions",
|
||||
"*": "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",
|
||||
"Item Price": "erpnext.stock.doctype.company_restriction.company_restriction.has_inherited_permission",
|
||||
"*": "erpnext.stock.doctype.company_restriction.company_restriction.has_inherited_permission",
|
||||
}
|
||||
|
||||
has_website_permission = {
|
||||
@@ -382,14 +382,6 @@ 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 = {
|
||||
@@ -403,20 +395,9 @@ 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",
|
||||
|
||||
@@ -104,7 +104,9 @@ 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);
|
||||
frm.set_value("supplier", "");
|
||||
if (frm.doc.supplier) {
|
||||
frm.set_value("supplier", "");
|
||||
}
|
||||
|
||||
frm.set_query("tc_name", function () {
|
||||
return { filters: { selling: 1 } };
|
||||
@@ -113,7 +115,9 @@ 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);
|
||||
frm.set_value("customer", "");
|
||||
if (frm.doc.customer) {
|
||||
frm.set_value("customer", "");
|
||||
}
|
||||
|
||||
frm.set_query("tc_name", function () {
|
||||
return { filters: { buying: 1 } };
|
||||
|
||||
@@ -1490,6 +1490,7 @@ 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"),
|
||||
@@ -1607,19 +1608,20 @@ 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, company, opts)
|
||||
_merge_phantom_bom_items(item_dict, item, stock_qty, 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, company, opts):
|
||||
def _merge_phantom_bom_items(item_dict, item, stock_qty, company, opts):
|
||||
data = get_bom_items_as_dict(
|
||||
item.get("bom_no"),
|
||||
company,
|
||||
qty=item.get("qty"),
|
||||
qty=stock_qty,
|
||||
fetch_exploded=opts.fetch_exploded,
|
||||
fetch_secondary_items=opts.fetch_secondary_items,
|
||||
include_non_stock_items=opts.include_non_stock_items,
|
||||
|
||||
@@ -141,6 +141,36 @@ 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
|
||||
|
||||
@@ -1789,6 +1789,100 @@ 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
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Text",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Description",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -116,7 +116,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-23 16:52:37.669110",
|
||||
"modified": "2026-09-25 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Job Card Item",
|
||||
|
||||
@@ -16,7 +16,7 @@ class JobCardItem(Document):
|
||||
|
||||
allow_alternative_item: DF.Check
|
||||
consumed_qty: DF.Float
|
||||
description: DF.Text | None
|
||||
description: DF.TextEditor | None
|
||||
item_code: DF.Link
|
||||
item_group: DF.Link | None
|
||||
item_name: DF.Data | None
|
||||
|
||||
@@ -32,7 +32,6 @@ 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,
|
||||
@@ -267,8 +266,14 @@ 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()
|
||||
@@ -362,11 +367,7 @@ class ProductionPlan(Document):
|
||||
return so_wise_planned_qty
|
||||
|
||||
def update_bin_qty(self):
|
||||
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()
|
||||
self.update_raw_material_bin_qty()
|
||||
|
||||
for d in self.sub_assembly_items:
|
||||
if d.fg_warehouse and d.type_of_manufacturing == "In House":
|
||||
@@ -374,6 +375,13 @@ 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)}
|
||||
@@ -384,6 +392,9 @@ 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:
|
||||
|
||||
@@ -27,58 +27,85 @@ _RESERVATION_TABLES = {
|
||||
|
||||
|
||||
def get_reserved_qty_for_production_plan(item_code, warehouse):
|
||||
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:
|
||||
plan_reservations = _get_plan_reservations(item_code)
|
||||
if not plan_reservations:
|
||||
return None
|
||||
|
||||
for_production = flt(
|
||||
get_reserved_qty_for_production(
|
||||
item_code, warehouse, non_completed_production_plans, check_production_plan=True
|
||||
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
|
||||
)
|
||||
)
|
||||
if for_production > reserved:
|
||||
|
||||
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:
|
||||
return 0.0
|
||||
return reserved - for_production
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _production_plan_reserved_qty(item_code, warehouse, non_completed_production_plans):
|
||||
def _get_plan_reservations(item_code):
|
||||
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(Sum(child.required_bom_qty))
|
||||
.where(_plan_reserved_filter(table, child, item_code, warehouse))
|
||||
.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)
|
||||
)
|
||||
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
|
||||
return _group_by_plan_and_warehouse(query)
|
||||
|
||||
|
||||
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"]))
|
||||
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)
|
||||
)
|
||||
return _group_by_plan_and_warehouse(query)
|
||||
|
||||
|
||||
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 _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_reserved_qty_for_sub_assembly(item_code, warehouse):
|
||||
|
||||
@@ -6,7 +6,6 @@ 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,
|
||||
)
|
||||
@@ -1597,6 +1596,183 @@ 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
|
||||
|
||||
@@ -1612,7 +1788,6 @@ 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"
|
||||
@@ -1629,16 +1804,12 @@ 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)
|
||||
non_completed_plans = get_non_completed_production_plans()
|
||||
|
||||
for plan in plans:
|
||||
self.assertIn(plan, non_completed_plans)
|
||||
self.assertNotIn(frappe.db.get_value("Production Plan", pln.name, "status"), ["Completed", "Closed"])
|
||||
|
||||
def test_reserved_qty_for_production_plan_for_material_requests_with_multi_UOM(self):
|
||||
from erpnext.stock.utils import get_or_make_bin
|
||||
@@ -4047,6 +4218,51 @@ 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)
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Small Text",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "description",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -278,7 +278,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-12 00:00:00.000000",
|
||||
"modified": "2026-09-25 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Production Plan Sub Assembly Item",
|
||||
|
||||
@@ -18,7 +18,7 @@ class ProductionPlanSubAssemblyItem(Document):
|
||||
actual_qty: DF.Float
|
||||
bom_level: DF.Int
|
||||
bom_no: DF.Link | None
|
||||
description: DF.SmallText | None
|
||||
description: DF.TextEditor | None
|
||||
fg_warehouse: DF.Link | None
|
||||
indent: DF.Int
|
||||
item_name: DF.Data | None
|
||||
|
||||
@@ -35,11 +35,36 @@ 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:
|
||||
item_details = frappe.db.get_value(
|
||||
"Item", row.item_code, ["item_name", "stock_uom as uom"], as_dict=True
|
||||
)
|
||||
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]
|
||||
|
||||
for index in range(self.demand_number):
|
||||
if self.frequency == "Monthly":
|
||||
|
||||
@@ -1,4 +1,35 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
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")],
|
||||
)
|
||||
|
||||
@@ -49,6 +49,7 @@ _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:
|
||||
@@ -135,15 +136,17 @@ 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 = {
|
||||
@@ -156,7 +159,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, "Manufacture")
|
||||
row_wise_serial_batch = get_row_wise_serial_batch(self.doc.name, CONSUMPTION_PURPOSES)
|
||||
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)
|
||||
@@ -172,9 +175,11 @@ 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):
|
||||
@@ -663,7 +668,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(["Manufacture", "Material Consumption for Manufacture"]))
|
||||
& (stock_entry.purpose.isin(CONSUMPTION_PURPOSES))
|
||||
& (stock_entry.docstatus == 1)
|
||||
& (stock_entry_detail.s_warehouse.isnotnull())
|
||||
# An attributed row belongs to its original requirement, not both item codes.
|
||||
@@ -671,31 +676,27 @@ def _consumed_qty_filter(stock_entry, stock_entry_detail, work_order, item_code)
|
||||
)
|
||||
|
||||
|
||||
def get_reserved_qty_for_production(
|
||||
item_code: str,
|
||||
warehouse: str,
|
||||
non_completed_production_plans: list | None = None,
|
||||
check_production_plan: bool = False,
|
||||
) -> float:
|
||||
def get_reserved_qty_for_production(item_code: str, warehouse: str) -> 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(qty_field))
|
||||
.select(Sum(_production_reserved_qty_field(wo, wo_item)))
|
||||
.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
|
||||
|
||||
|
||||
@@ -708,28 +709,12 @@ 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": purpose, "docstatus": 1},
|
||||
filters={"work_order": work_order, "purpose": ("in", purposes), "docstatus": 1},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
|
||||
@@ -423,8 +423,8 @@ class StatusService:
|
||||
|
||||
doc = frappe.get_doc("Production Plan", self.doc.production_plan)
|
||||
doc.flags.ignore_permissions = True
|
||||
doc.set_status()
|
||||
doc.db_set("status", doc.status)
|
||||
doc.update_status_and_bin_qty()
|
||||
doc.update_raw_material_bin_qty({d.item_code for d in self.doc.required_items})
|
||||
|
||||
def _production_plan_ordered_qty(self):
|
||||
table = frappe.qb.DocType("Work Order")
|
||||
|
||||
@@ -1072,6 +1072,20 @@ 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 = [
|
||||
@@ -4552,6 +4566,166 @@ 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},
|
||||
|
||||
@@ -255,6 +255,11 @@ 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) {
|
||||
@@ -899,8 +904,6 @@ 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;
|
||||
@@ -1050,6 +1053,8 @@ erpnext.work_order = {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
erpnext.work_order.setup_stock_reservation(frm);
|
||||
},
|
||||
|
||||
add_start_button(frm) {
|
||||
@@ -1066,6 +1071,7 @@ 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)
|
||||
) {
|
||||
@@ -1077,13 +1083,11 @@ erpnext.work_order = {
|
||||
}
|
||||
|
||||
if (frm.events.has_reserved_stock(frm)) {
|
||||
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(
|
||||
__("Unreserve"),
|
||||
() => erpnext.stock_reservation.unreserve_stock(frm),
|
||||
__("Stock Reservation")
|
||||
);
|
||||
|
||||
frm.add_custom_button(
|
||||
__("Reserved Stock"),
|
||||
@@ -1501,3 +1505,215 @@ 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -18,33 +18,30 @@
|
||||
"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",
|
||||
@@ -60,6 +57,12 @@
|
||||
"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",
|
||||
@@ -77,6 +80,7 @@
|
||||
"secondary_items_section",
|
||||
"secondary_items",
|
||||
"reference_section",
|
||||
"update_consumed_material_cost_in_project",
|
||||
"project",
|
||||
"subcontracting_inward_order",
|
||||
"production_plan",
|
||||
@@ -285,10 +289,6 @@
|
||||
"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": "Small Text",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Item Description",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -503,10 +503,6 @@
|
||||
"options": "Work Order",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "settings_section",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_18",
|
||||
"fieldtype": "Column Break"
|
||||
@@ -521,7 +517,7 @@
|
||||
"description": "This is a location where raw materials are available.",
|
||||
"fieldname": "source_warehouse",
|
||||
"fieldtype": "Link",
|
||||
"label": "Source Warehouse",
|
||||
"label": "Default source Warehouse",
|
||||
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
|
||||
"options": "Warehouse",
|
||||
"read_only_depends_on": "eval:doc.subcontracting_inward_order"
|
||||
@@ -540,10 +536,6 @@
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Finished Good Serial / Batch"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_17",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fetch_from": "production_item.has_serial_no",
|
||||
@@ -604,7 +596,7 @@
|
||||
{
|
||||
"fieldname": "work_order_configuration",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Configuration"
|
||||
"label": "Scheduling & Costing"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
@@ -732,6 +724,40 @@
|
||||
"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,
|
||||
@@ -740,7 +766,7 @@
|
||||
"image_field": "image",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-08 12:00:00.000000",
|
||||
"modified": "2026-09-25 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order",
|
||||
|
||||
@@ -121,7 +121,7 @@ class WorkOrder(Document):
|
||||
bom_no: DF.Link
|
||||
company: DF.Link
|
||||
corrective_operation_cost: DF.Currency
|
||||
description: DF.SmallText | None
|
||||
description: DF.TextEditor | None
|
||||
disassembled_qty: DF.Float
|
||||
expected_delivery_date: DF.Date | None
|
||||
fg_warehouse: DF.Link | None
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Text",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Description",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -227,7 +227,7 @@
|
||||
"grid_page_length": 50,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-07 10:00:00.000000",
|
||||
"modified": "2026-09-25 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order Item",
|
||||
|
||||
@@ -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.Text | None
|
||||
description: DF.TextEditor | None
|
||||
include_item_in_manufacturing: DF.Check
|
||||
is_additional_item: DF.Check
|
||||
is_customer_provided_item: DF.Check
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
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)
|
||||
@@ -72,6 +74,9 @@ 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
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies_condition
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
columns, data = [], []
|
||||
@@ -77,6 +79,9 @@ 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)
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies_condition
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
columns, data = [], []
|
||||
@@ -36,8 +38,13 @@ 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=query_filters, order_by="report_date asc"
|
||||
"Quality Inspection", fields=fields, filters=inspection_filters, order_by="report_date asc"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ 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()
|
||||
@@ -96,9 +98,13 @@ 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={"docstatus": 1, "status": ("!=", "Completed")},
|
||||
filters=work_order_filters,
|
||||
fields=["name", "status", "bom_no", "qty", "produced_qty"],
|
||||
order_by="name",
|
||||
)
|
||||
|
||||
@@ -529,3 +529,4 @@ 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
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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)
|
||||
@@ -79,13 +79,13 @@
|
||||
{
|
||||
"depends_on": "eval:parent.doctype == 'Project Update'",
|
||||
"fieldname": "project_status",
|
||||
"fieldtype": "Text",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Project Status"
|
||||
}
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:21.859474",
|
||||
"modified": "2026-09-25 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Projects",
|
||||
"name": "Project User",
|
||||
|
||||
@@ -20,7 +20,7 @@ class ProjectUser(Document):
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
project_status: DF.Text | None
|
||||
project_status: DF.TextEditor | None
|
||||
user: DF.Link
|
||||
view_attachments: DF.Check
|
||||
welcome_email_sent: DF.Check
|
||||
|
||||
@@ -179,7 +179,11 @@ erpnext.buying = {
|
||||
|
||||
this.frm.set_value("billing_address", r.message.primary_address || "");
|
||||
|
||||
if (frappe.meta.has_field(this.frm.doc.doctype, "shipping_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
|
||||
) {
|
||||
this.frm.set_value("shipping_address", r.message.shipping_address || "");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -43,5 +43,6 @@ 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'
|
||||
|
||||
@@ -4,19 +4,21 @@
|
||||
frappe.ui.form.on("Sales Order", {
|
||||
refresh(frm) {
|
||||
erpnext.proforma.toggle_tab(frm, false);
|
||||
if (frm.doc.docstatus !== 1) return;
|
||||
if (frm.doc.docstatus === 0) return;
|
||||
|
||||
frappe.db.get_single_value("Selling Settings", "enable_proforma_invoice").then((enabled) => {
|
||||
if (!enabled) return;
|
||||
|
||||
// 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);
|
||||
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);
|
||||
}
|
||||
erpnext.proforma.render_list(frm);
|
||||
});
|
||||
},
|
||||
@@ -117,6 +119,12 @@ 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",
|
||||
@@ -205,11 +213,12 @@ 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) =>
|
||||
by_amount
|
||||
? { so_detail: row.so_detail, qty: row.qty, amount: row.amount }
|
||||
: { so_detail: row.so_detail, qty: row.qty }
|
||||
);
|
||||
.map((row) => ({
|
||||
so_detail: row.so_detail,
|
||||
description: row.description,
|
||||
qty: row.qty,
|
||||
amount: row.amount,
|
||||
}));
|
||||
|
||||
if (!items.length) {
|
||||
frappe.msgprint(__("Please enter a quantity or amount for at least one item."));
|
||||
@@ -314,6 +323,7 @@ Object.assign(erpnext.proforma, {
|
||||
],
|
||||
});
|
||||
list.refresh();
|
||||
if (frm.doc.docstatus !== 1) return;
|
||||
|
||||
frappe.ui
|
||||
.button({
|
||||
|
||||
@@ -216,8 +216,11 @@ $.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) {
|
||||
if (r.message.length > 0) {
|
||||
erpnext.stock_reservation.prepare_for_cancel_sre_entries(frm, 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);
|
||||
} else {
|
||||
frappe.msgprint(__("No reserved stock to unreserve."));
|
||||
}
|
||||
@@ -253,7 +256,7 @@ $.extend(erpnext.stock_reservation, {
|
||||
sre: sre.name,
|
||||
item_code: sre.item_code,
|
||||
warehouse: sre.warehouse,
|
||||
qty: flt(sre.reserved_qty) - flt(sre.delivered_qty),
|
||||
qty: erpnext.stock_reservation.get_held_qty(sre),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,6 +264,12 @@ $.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";
|
||||
|
||||
32
erpnext/public/js/subcontracting_inward_report_filters.js
Normal file
32
erpnext/public/js/subcontracting_inward_report_filters.js
Normal file
@@ -0,0 +1,32 @@
|
||||
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",
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
// },
|
||||
// });
|
||||
frappe.ui.form.on("Proforma Invoice", {
|
||||
refresh(frm) {
|
||||
frm.page.btn_primary.toggle(frm.doc.docstatus !== 2);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,8 +45,59 @@ 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"
|
||||
|
||||
@@ -80,6 +131,7 @@ 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")
|
||||
@@ -116,6 +168,7 @@ 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),
|
||||
@@ -155,37 +208,36 @@ def make_proforma_invoice(
|
||||
print_format: str | None = None,
|
||||
letter_head: str | None = None,
|
||||
) -> str:
|
||||
"""The sole creation path for a Proforma Invoice (the doctype is `in_create`).
|
||||
"""Create and submit a Proforma Invoice from the Sales Order dialog.
|
||||
|
||||
`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"
|
||||
proforma.print_format = (
|
||||
print_format
|
||||
or frappe.db.get_single_value("Selling Settings", "default_proforma_print_format")
|
||||
or "Proforma Invoice"
|
||||
)
|
||||
proforma.letter_head = letter_head
|
||||
|
||||
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)
|
||||
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"),
|
||||
},
|
||||
)
|
||||
|
||||
if not proforma.items:
|
||||
frappe.throw(_("Please enter a quantity or amount for at least one item."))
|
||||
@@ -195,32 +247,6 @@ 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)
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -24,6 +25,9 @@ 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)
|
||||
@@ -172,6 +176,74 @@ 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)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"field_order": [
|
||||
"item_code",
|
||||
"item_name",
|
||||
"description",
|
||||
"column_break_qty",
|
||||
"qty",
|
||||
"uom",
|
||||
@@ -32,6 +33,11 @@
|
||||
"label": "Item Name",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Description"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_qty",
|
||||
"fieldtype": "Column Break"
|
||||
@@ -79,7 +85,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-16 00:00:00.000000",
|
||||
"modified": "2026-09-24 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Proforma Invoice Item",
|
||||
|
||||
@@ -14,6 +14,7 @@ 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
|
||||
|
||||
@@ -123,7 +123,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar
|
||||
{
|
||||
"Quotation": {
|
||||
"doctype": "Sales Order",
|
||||
"validation": {"docstatus": ["=", 1]},
|
||||
"validation": {"docstatus": ["=", 1], "is_active": ["=", 1]},
|
||||
"field_no_map": ["payment_terms_template"],
|
||||
},
|
||||
"Quotation Item": {
|
||||
@@ -184,7 +184,10 @@ def _make_sales_invoice(source_name, target_doc=None, ignore_permissions=False,
|
||||
"Quotation",
|
||||
source_name,
|
||||
{
|
||||
"Quotation": {"doctype": "Sales Invoice", "validation": {"docstatus": ["=", 1]}},
|
||||
"Quotation": {
|
||||
"doctype": "Sales Invoice",
|
||||
"validation": {"docstatus": ["=", 1], "is_active": ["=", 1]},
|
||||
},
|
||||
"Quotation Item": {
|
||||
"doctype": "Sales Invoice Item",
|
||||
"postprocess": update_item,
|
||||
@@ -201,6 +204,32 @@ 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",
|
||||
|
||||
@@ -12,6 +12,7 @@ frappe.ui.form.on("Quotation", {
|
||||
|
||||
(frm.custom_make_buttons = {
|
||||
"Sales Order": "Sales Order",
|
||||
Quotation: "New Version",
|
||||
}),
|
||||
frm.set_query("quotation_to", function () {
|
||||
return {
|
||||
@@ -126,6 +127,7 @@ 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 ||
|
||||
@@ -144,9 +146,38 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
|
||||
|
||||
if (doc.status !== "Ordered" && this.frm.has_perm("write")) {
|
||||
this.frm.add_custom_button(__("Set as Lost"), () => {
|
||||
this.frm.trigger("set_as_lost_dialog");
|
||||
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")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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")) {
|
||||
@@ -201,6 +232,13 @@ 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");
|
||||
|
||||
@@ -18,11 +18,14 @@
|
||||
"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",
|
||||
@@ -214,6 +217,27 @@
|
||||
"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",
|
||||
@@ -246,6 +270,16 @@
|
||||
"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",
|
||||
@@ -1146,7 +1180,7 @@
|
||||
"idx": 82,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified": "2026-09-26 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Quotation",
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
|
||||
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 getdate, nowdate
|
||||
from frappe.utils import cint, formatdate, get_datetime, getdate, nowdate
|
||||
from pypika.terms import ExistsCriterion
|
||||
|
||||
from erpnext.controllers.selling_controller import SellingController
|
||||
@@ -15,6 +16,7 @@ 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):
|
||||
@@ -76,6 +78,8 @@ 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
|
||||
@@ -97,6 +101,7 @@ 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
|
||||
@@ -128,6 +133,25 @@ 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"
|
||||
@@ -146,6 +170,7 @@ 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
|
||||
@@ -164,6 +189,49 @@ 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")):
|
||||
@@ -269,10 +337,10 @@ class Quotation(SellingController):
|
||||
):
|
||||
self.check_permission("write")
|
||||
|
||||
if not (self.is_fully_ordered() or self.is_partially_ordered()):
|
||||
if not (self.is_fully_ordered() or self.is_partially_ordered() or self.has_ordered_versions):
|
||||
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")
|
||||
self.db_set({"status": "Lost", "is_active": 1})
|
||||
|
||||
if detailed_reason:
|
||||
self.db_set("order_lost_reason", detailed_reason)
|
||||
@@ -290,6 +358,7 @@ 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()
|
||||
@@ -297,6 +366,15 @@ 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(
|
||||
@@ -306,6 +384,75 @@ 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:
|
||||
@@ -316,6 +463,7 @@ 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
|
||||
@@ -358,6 +506,10 @@ 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
|
||||
|
||||
@@ -375,6 +527,26 @@ 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")
|
||||
|
||||
@@ -4,11 +4,14 @@ 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"]},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
frappe.listview_settings["Quotation"] = {
|
||||
add_fields: ["customer_name", "base_grand_total", "status", "company", "currency", "valid_till"],
|
||||
add_fields: [
|
||||
"customer_name",
|
||||
"base_grand_total",
|
||||
"status",
|
||||
"company",
|
||||
"currency",
|
||||
"valid_till",
|
||||
"is_active",
|
||||
"is_latest_revision",
|
||||
],
|
||||
|
||||
onload: function (listview) {
|
||||
if (listview.page.fields_dict.quotation_to) {
|
||||
@@ -26,14 +35,18 @@ frappe.listview_settings["Quotation"] = {
|
||||
},
|
||||
|
||||
get_indicator: function (doc) {
|
||||
if (doc.status === "Open") {
|
||||
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") {
|
||||
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"];
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ 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.selling.doctype.quotation.mapper import make_sales_order
|
||||
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.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -496,6 +498,297 @@ 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 (
|
||||
@@ -505,15 +798,14 @@ class TestQuotation(ERPNextTestSuite):
|
||||
|
||||
rate_with_margin = flt((1500 * 18.75) / 100 + 1500)
|
||||
|
||||
test_record = frappe.copy_doc(self.globalTestRecords["Quotation"][0])
|
||||
quotation = frappe.copy_doc(self.globalTestRecords["Quotation"][0])
|
||||
|
||||
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
|
||||
quotation.items[0].price_list_rate = 1500
|
||||
quotation.items[0].margin_type = "Percentage"
|
||||
quotation.items[0].margin_rate_or_amount = 18.75
|
||||
# set rate to zero, so that it is recalculated on save
|
||||
test_record.items[0].rate = 0
|
||||
quotation.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()
|
||||
|
||||
@@ -1257,6 +1257,7 @@ 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,
|
||||
|
||||
@@ -19,6 +19,7 @@ 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,
|
||||
@@ -274,6 +275,7 @@ 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):
|
||||
"""
|
||||
@@ -502,6 +504,12 @@ 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")
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"allow_roles": [
|
||||
{
|
||||
"role": "Stock Manager"
|
||||
},
|
||||
{
|
||||
"role": "Stock User"
|
||||
}
|
||||
],
|
||||
"creation": "2026-02-20 13:52:55.989409",
|
||||
"docstatus": 0,
|
||||
"doctype": "Module Onboarding",
|
||||
"idx": 0,
|
||||
"is_complete": 0,
|
||||
"modified": "2026-02-20 13:53:46.461261",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Stock Onboarding",
|
||||
"owner": "Administrator",
|
||||
"steps": [
|
||||
{
|
||||
"step": "Setup Warehouse"
|
||||
},
|
||||
{
|
||||
"step": "Create Item"
|
||||
},
|
||||
{
|
||||
"step": "Create Purchase Receipt"
|
||||
},
|
||||
{
|
||||
"step": "Create Delivery Note"
|
||||
},
|
||||
{
|
||||
"step": "View Stock Balance Report"
|
||||
},
|
||||
{
|
||||
"step": "View Stock Ledger Report"
|
||||
},
|
||||
{
|
||||
"step": "Create Stock Entry"
|
||||
}
|
||||
],
|
||||
"title": "Onboarding for Stock!"
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"action": "Create Entry",
|
||||
"action_label": "Create delivery note",
|
||||
"creation": "2026-02-20 13:42:13.571273",
|
||||
"docstatus": 0,
|
||||
"doctype": "Onboarding Step",
|
||||
"idx": 3,
|
||||
"is_complete": 0,
|
||||
"is_single": 0,
|
||||
"is_skipped": 0,
|
||||
"modified": "2026-02-23 17:38:50.095742",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Create Delivery Note",
|
||||
"owner": "Administrator",
|
||||
"reference_document": "Delivery Note",
|
||||
"show_form_tour": 0,
|
||||
"show_full_form": 0,
|
||||
"title": "Create Delivery Note",
|
||||
"validate_action": 1
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"action": "View Report",
|
||||
"action_label": "View Stock Balance Report",
|
||||
"creation": "2026-02-20 13:52:54.499787",
|
||||
"docstatus": 0,
|
||||
"doctype": "Onboarding Step",
|
||||
"idx": 0,
|
||||
"is_complete": 0,
|
||||
"is_single": 0,
|
||||
"is_skipped": 0,
|
||||
"modified": "2026-02-20 13:52:54.499787",
|
||||
"modified_by": "Administrator",
|
||||
"name": "View Stock Balance Report",
|
||||
"owner": "Administrator",
|
||||
"reference_report": "Stock Balance",
|
||||
"report_description": "View Stock Balance Report",
|
||||
"report_reference_doctype": "Stock Ledger Entry",
|
||||
"report_type": "Script Report",
|
||||
"show_form_tour": 0,
|
||||
"show_full_form": 0,
|
||||
"title": "View Stock Balance Report",
|
||||
"validate_action": 1
|
||||
}
|
||||
@@ -9,14 +9,14 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Print Format",
|
||||
"font_size": 0,
|
||||
"html": "<div class=\"proforma-print\">\n\t<style>\n\t\t.proforma-print { font-family: \"Inter\", sans-serif; color: #1f272e; font-size: 12px; }\n\t\t.proforma-print h2 { margin: 0; font-size: 20px; letter-spacing: 1px; }\n\t\t.proforma-print .muted { color: #6b7280; }\n\t\t.proforma-print table { width: 100%; border-collapse: collapse; }\n\t\t.proforma-print .meta-table td { padding: 2px 0; vertical-align: top; }\n\t\t.proforma-print .items-table th, .proforma-print .items-table td {\n\t\t\tborder-bottom: 1px solid #e5e7eb; padding: 8px 6px; text-align: left;\n\t\t}\n\t\t.proforma-print .items-table th { border-bottom: 2px solid #9ca3af; }\n\t\t.proforma-print .text-right { text-align: right !important; }\n\t\t.proforma-print .totals { width: 45%; margin-left: auto; margin-top: 12px; }\n\t\t.proforma-print .totals td { padding: 4px 6px; }\n\t\t.proforma-print .grand { border-top: 2px solid #9ca3af; font-weight: 600; font-size: 14px; }\n\t\t.proforma-print .footer-note { margin-top: 30px; font-size: 11px; color: #6b7280; }\n\t</style>\n\n\t<table class=\"meta-table\">\n\t\t<tr>\n\t\t\t<td style=\"width: 60%;\">\n\t\t\t\t<h2>{{ _(\"PROFORMA INVOICE\") }}</h2>\n\t\t\t\t<div class=\"muted\">{{ doc.company }}</div>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t<table style=\"width: 100%;\">\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Proforma No\") }}</td><td class=\"text-right\">{{ doc.proforma_no or doc.name }}</td></tr>\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Date\") }}</td><td class=\"text-right\">{{ frappe.utils.formatdate(doc.proforma_date) }}</td></tr>\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Against Sales Order\") }}</td><td class=\"text-right\">{{ doc.name }}</td></tr>\n\t\t\t\t</table>\n\t\t\t</td>\n\t\t</tr>\n\t</table>\n\n\t<hr style=\"border: none; border-top: 1px solid #e5e7eb; margin: 14px 0;\">\n\n\t<table class=\"meta-table\">\n\t\t<tr>\n\t\t\t<td><strong>{{ _(\"Bill To\") }}</strong><br>{{ doc.customer_name }}</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{% if doc.customer_address %}{{ doc.get_formatted(\"address_display\") }}{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</table>\n\n\t<table class=\"items-table\" style=\"margin-top: 16px;\">\n\t\t<thead>\n\t\t\t<tr>\n\t\t\t\t<th style=\"width: 5%;\">{{ _(\"Sr\") }}</th>\n\t\t\t\t<th style=\"width: 45%;\">{{ _(\"Item\") }}</th>\n\t\t\t\t{% if not doc.hide_item_qty %}<th class=\"text-right\" style=\"width: 14%;\">{{ _(\"Qty\") }}</th>{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}<th class=\"text-right\" style=\"width: 16%;\">{{ _(\"Rate\") }}</th>{% endif %}\n\t\t\t\t<th class=\"text-right\" style=\"width: 20%;\">{{ _(\"Amount\") }}</th>\n\t\t\t</tr>\n\t\t</thead>\n\t\t<tbody>\n\t\t\t{% for row in doc.items %}\n\t\t\t<tr>\n\t\t\t\t<td>{{ loop.index }}</td>\n\t\t\t\t<td><strong>{{ row.item_code }}</strong>{% if row.item_name != row.item_code %}<br><span class=\"muted\">{{ row.item_name }}</span>{% endif %}</td>\n\t\t\t\t{% if not doc.hide_item_qty %}<td class=\"text-right\">{{ row.get_formatted(\"qty\") }} {{ row.uom }}</td>{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}<td class=\"text-right\">{{ row.get_formatted(\"rate\", doc) }}</td>{% endif %}\n\t\t\t\t<td class=\"text-right\">{{ row.get_formatted(\"amount\", doc) }}</td>\n\t\t\t</tr>\n\t\t\t{% endfor %}\n\t\t</tbody>\n\t</table>\n\n\t<table class=\"totals\">\n\t\t<tr>\n\t\t\t<td class=\"muted\">{{ _(\"Net Total\") }}</td>\n\t\t\t<td class=\"text-right\">{{ doc.get_formatted(\"net_total\") }}</td>\n\t\t</tr>\n\t\t{% for tax in doc.taxes %}\n\t\t\t{% if tax.tax_amount %}\n\t\t\t<tr>\n\t\t\t\t<td class=\"muted\">{{ tax.description }}</td>\n\t\t\t\t<td class=\"text-right\">{{ tax.get_formatted(\"tax_amount\", doc) }}</td>\n\t\t\t</tr>\n\t\t\t{% endif %}\n\t\t{% endfor %}\n\t\t<tr class=\"grand\">\n\t\t\t<td>{{ _(\"Grand Total\") }}</td>\n\t\t\t<td class=\"text-right\">{{ doc.get_formatted(\"grand_total\") }}</td>\n\t\t</tr>\n\t</table>\n\n\t<div class=\"footer-note\">\n\t\t{{ _(\"This is a proforma invoice and is not a demand for payment or a tax invoice.\") }}\n\t</div>\n</div>\n",
|
||||
"html": "<div class=\"proforma-print\">\n\t<style>\n\t\t.proforma-print { font-family: \"Inter\", sans-serif; color: #1f272e; font-size: 12px; }\n\t\t.proforma-print h2 { margin: 0; font-size: 20px; letter-spacing: 1px; }\n\t\t.proforma-print .muted { color: #6b7280; }\n\t\t.proforma-print table { width: 100%; border-collapse: collapse; }\n\t\t.proforma-print .meta-table td { padding: 2px 0; vertical-align: top; }\n\t\t.proforma-print .items-table th, .proforma-print .items-table td {\n\t\t\tborder-bottom: 1px solid #e5e7eb; padding: 8px 6px; text-align: left;\n\t\t}\n\t\t.proforma-print .items-table th { border-bottom: 2px solid #9ca3af; }\n\t\t.proforma-print .text-right { text-align: right !important; }\n\t\t.proforma-print .totals { width: 45%; margin-left: auto; margin-top: 12px; }\n\t\t.proforma-print .totals td { padding: 4px 6px; }\n\t\t.proforma-print .grand { border-top: 2px solid #9ca3af; font-weight: 600; font-size: 14px; }\n\t</style>\n\n\t<table class=\"meta-table\">\n\t\t<tr>\n\t\t\t<td style=\"width: 60%;\">\n\t\t\t\t<h2>{{ _(\"PROFORMA INVOICE\") }}</h2>\n\t\t\t\t<div class=\"muted\">{{ doc.company }}</div>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t<table style=\"width: 100%;\">\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Proforma No\") }}</td><td class=\"text-right\">{{ doc.proforma_no or doc.name }}</td></tr>\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Date\") }}</td><td class=\"text-right\">{{ frappe.utils.formatdate(doc.proforma_date) }}</td></tr>\n\t\t\t\t</table>\n\t\t\t</td>\n\t\t</tr>\n\t</table>\n\n\t<hr style=\"border: none; border-top: 1px solid #e5e7eb; margin: 14px 0;\">\n\n\t<table class=\"meta-table\">\n\t\t<tr>\n\t\t\t<td>\n\t\t\t\t<strong>{{ _(\"Bill To\") }}</strong><br>{{ doc.customer_name }}\n\t\t\t\t{% if doc.customer_address %}<br>{{ doc.get_formatted(\"address_display\") }}{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</table>\n\n\t<table class=\"items-table\" style=\"margin-top: 16px;\">\n\t\t<thead>\n\t\t\t<tr>\n\t\t\t\t<th style=\"width: 5%;\">{{ _(\"Sr\") }}</th>\n\t\t\t\t<th style=\"width: 45%;\">{{ _(\"Item\") }}</th>\n\t\t\t\t{% if not doc.hide_item_qty %}<th class=\"text-right\" style=\"width: 14%;\">{{ _(\"Qty\") }}</th>{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}<th class=\"text-right\" style=\"width: 16%;\">{{ _(\"Rate\") }}</th>{% endif %}\n\t\t\t\t<th class=\"text-right\" style=\"width: 20%;\">{{ _(\"Amount\") }}</th>\n\t\t\t</tr>\n\t\t</thead>\n\t\t<tbody>\n\t\t\t{% for row in doc.items %}\n\t\t\t<tr>\n\t\t\t\t<td>{{ loop.index }}</td>\n\t\t\t\t<td>\n\t\t\t\t\t<strong>{{ row.item_code }}</strong>{% if row.item_name != row.item_code %}<br><span class=\"muted\">{{ row.item_name }}</span>{% endif %}\n\t\t\t\t\t{% if row.description and (row.description | striptags) != row.item_name %}<div class=\"muted\">{{ row.description }}</div>{% endif %}\n\t\t\t\t</td>\n\t\t\t\t{% if not doc.hide_item_qty %}<td class=\"text-right\">{{ row.get_formatted(\"qty\") }} {{ row.uom }}</td>{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}<td class=\"text-right\">{{ row.get_formatted(\"rate\", doc) }}</td>{% endif %}\n\t\t\t\t<td class=\"text-right\">{{ row.get_formatted(\"amount\", doc) }}</td>\n\t\t\t</tr>\n\t\t\t{% endfor %}\n\t\t</tbody>\n\t</table>\n\n\t<table class=\"totals\">\n\t\t<tr>\n\t\t\t<td class=\"muted\">{{ _(\"Net Total\") }}</td>\n\t\t\t<td class=\"text-right\">{{ doc.get_formatted(\"net_total\") }}</td>\n\t\t</tr>\n\t\t{% for tax in doc.taxes %}\n\t\t\t{% if tax.tax_amount %}\n\t\t\t<tr>\n\t\t\t\t<td class=\"muted\">{{ tax.description }}</td>\n\t\t\t\t<td class=\"text-right\">{{ tax.get_formatted(\"tax_amount\", doc) }}</td>\n\t\t\t</tr>\n\t\t\t{% endif %}\n\t\t{% endfor %}\n\t\t<tr class=\"grand\">\n\t\t\t<td>{{ _(\"Grand Total\") }}</td>\n\t\t\t<td class=\"text-right\">{{ doc.get_formatted(\"grand_total\") }}</td>\n\t\t</tr>\n\t</table>\n</div>\n",
|
||||
"idx": 0,
|
||||
"line_breaks": 0,
|
||||
"margin_bottom": 15.0,
|
||||
"margin_left": 15.0,
|
||||
"margin_right": 15.0,
|
||||
"margin_top": 15.0,
|
||||
"modified": "2026-07-16 00:00:00.000000",
|
||||
"modified": "2026-09-25 10:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Proforma Invoice",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_masters_condition
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
if not filters:
|
||||
@@ -71,15 +73,19 @@ def get_item_warehouse_quantity_map():
|
||||
# Components of every active product bundle: (bundle item code, component item, qty per bundle)
|
||||
pb = frappe.qb.DocType("Product Bundle")
|
||||
pbi = frappe.qb.DocType("Product Bundle Item")
|
||||
bundle_components = (
|
||||
query = (
|
||||
frappe.qb.from_(pbi)
|
||||
.inner_join(pb)
|
||||
.on(pbi.parent == pb.name)
|
||||
.select(pb.new_item_code.as_("parent"), pbi.item_code, pbi.qty)
|
||||
.where((pb.is_active == 1) & (pb.docstatus == 1))
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
if condition := get_allowed_masters_condition(pb.new_item_code, "Item"):
|
||||
query = query.where(condition)
|
||||
|
||||
bundle_components = query.run(as_dict=True)
|
||||
|
||||
if not bundle_components:
|
||||
return {}
|
||||
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
import calendar
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import cint, cstr, getdate
|
||||
from frappe.utils import cint, cstr, formatdate, getdate
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -85,7 +83,7 @@ def get_data_by_time(filters, common_columns):
|
||||
out.append(
|
||||
{
|
||||
"year": cstr(year),
|
||||
"month": calendar.month_name[month],
|
||||
"month": formatdate(f"{key}-01", "MMMM"),
|
||||
"new_customers": new[0],
|
||||
"repeat_customers": repeat[0],
|
||||
"total": new[0] + repeat[0],
|
||||
|
||||
@@ -8,6 +8,10 @@ from frappe.query_builder import Criterion
|
||||
|
||||
from erpnext import get_default_company
|
||||
from erpnext.accounts.party import _get_party_details
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import (
|
||||
get_allowed_masters_condition,
|
||||
get_allowed_warehouses_condition,
|
||||
)
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -94,8 +98,15 @@ def get_data(filters=None):
|
||||
customer_details = get_customer_details(filters)
|
||||
|
||||
items = get_selling_items(filters)
|
||||
bin_filters = []
|
||||
if condition := get_allowed_warehouses_condition(frappe.qb.DocType("Bin").warehouse):
|
||||
bin_filters.append(condition)
|
||||
|
||||
item_stock_map = frappe.get_all(
|
||||
"Bin", fields=["item_code", {"SUM": "actual_qty", "as": "available"}], group_by="item_code"
|
||||
"Bin",
|
||||
fields=["item_code", {"SUM": "actual_qty", "as": "available"}],
|
||||
filters=bin_filters,
|
||||
group_by="item_code",
|
||||
)
|
||||
item_stock_map = {item.item_code: item.available for item in item_stock_map}
|
||||
price_list_map = fetch_item_prices(
|
||||
@@ -135,9 +146,12 @@ def get_customer_details(filters):
|
||||
|
||||
def get_selling_items(filters):
|
||||
if filters.get("item"):
|
||||
item_filters = {"item_code": filters.get("item"), "is_sales_item": 1, "disabled": 0}
|
||||
item_filters = [{"item_code": filters.get("item"), "is_sales_item": 1, "disabled": 0}]
|
||||
else:
|
||||
item_filters = {"is_sales_item": 1, "disabled": 0}
|
||||
item_filters = [{"is_sales_item": 1, "disabled": 0}]
|
||||
|
||||
if condition := get_allowed_masters_condition(frappe.qb.DocType("Item").name, "Item"):
|
||||
item_filters.append(condition)
|
||||
|
||||
items = frappe.get_all(
|
||||
"Item", filters=item_filters, fields=["item_code", "item_name"], order_by="item_name"
|
||||
|
||||
@@ -7,15 +7,14 @@
|
||||
"doctype": "Report",
|
||||
"idx": 0,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2018-05-21 11:06:11.920622",
|
||||
"modified": "2026-09-26 10:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Customers Without Any Sales Transactions",
|
||||
"owner": "Administrator",
|
||||
"query": "SELECT\n\t`tabCustomer`.name as \"Customer:Link/Customer:120\",\n\t`tabCustomer`.customer_name as \"Customer Name::120\",\n\t`tabCustomer`.territory as \"Territory:Link/Territory:120\",\n\t`tabCustomer`.customer_group as \"Customer Group:Link/Customer Group:120\"\nFROM\n\t`tabCustomer`\nWHERE\n\tnot exists(select name from `tabSales Invoice` where `tabCustomer`.name = `tabSales Invoice`.customer and `tabSales Invoice`.docstatus=1 limit 1)\n\tand not exists(select name from `tabSales Order` where `tabCustomer`.name = `tabSales Order`.customer and `tabSales Order`.docstatus=1 limit 1)",
|
||||
"ref_doctype": "Sales Invoice",
|
||||
"report_name": "Customers Without Any Sales Transactions",
|
||||
"report_type": "Query Report",
|
||||
"report_type": "Script Report",
|
||||
"roles": [
|
||||
{
|
||||
"role": "Sales User"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from pypika.terms import ExistsCriterion
|
||||
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_masters_condition
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
return get_columns(), get_data()
|
||||
|
||||
|
||||
def get_columns():
|
||||
return [
|
||||
{
|
||||
"label": _("Customer"),
|
||||
"fieldname": "customer",
|
||||
"fieldtype": "Link",
|
||||
"options": "Customer",
|
||||
"width": 120,
|
||||
},
|
||||
{"label": _("Customer Name"), "fieldname": "customer_name", "fieldtype": "Data", "width": 120},
|
||||
{
|
||||
"label": _("Territory"),
|
||||
"fieldname": "territory",
|
||||
"fieldtype": "Link",
|
||||
"options": "Territory",
|
||||
"width": 120,
|
||||
},
|
||||
{
|
||||
"label": _("Customer Group"),
|
||||
"fieldname": "customer_group",
|
||||
"fieldtype": "Link",
|
||||
"options": "Customer Group",
|
||||
"width": 120,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_data():
|
||||
customer = frappe.qb.DocType("Customer")
|
||||
query = (
|
||||
frappe.qb.from_(customer)
|
||||
.select(
|
||||
customer.name.as_("customer"),
|
||||
customer.customer_name,
|
||||
customer.territory,
|
||||
customer.customer_group,
|
||||
)
|
||||
.where(ExistsCriterion(get_submitted_sales(customer, "Sales Invoice")).negate())
|
||||
.where(ExistsCriterion(get_submitted_sales(customer, "Sales Order")).negate())
|
||||
)
|
||||
|
||||
if condition := get_allowed_masters_condition(customer.name, "Customer"):
|
||||
query = query.where(condition)
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
def get_submitted_sales(customer, doctype):
|
||||
sales = frappe.qb.DocType(doctype)
|
||||
return (
|
||||
frappe.qb.from_(sales)
|
||||
.select(sales.name)
|
||||
.where((sales.customer == customer.name) & (sales.docstatus == 1))
|
||||
)
|
||||
@@ -8,6 +8,8 @@ from frappe.query_builder import Case
|
||||
from frappe.query_builder.functions import Count, CurDate, DateDiff, Max, Sum
|
||||
from frappe.utils import cint
|
||||
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies_condition
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
if not filters:
|
||||
@@ -56,7 +58,7 @@ def get_sales_details(doctype):
|
||||
# renders the bare CURRENT_DATE keyword. Yields the integer number of days.
|
||||
days_since_last_order = DateDiff(CurDate(), last_order_date)
|
||||
|
||||
return (
|
||||
query = (
|
||||
frappe.qb.from_(customer)
|
||||
.inner_join(sales_doctype)
|
||||
.on(customer.name == sales_doctype.customer)
|
||||
@@ -74,21 +76,31 @@ def get_sales_details(doctype):
|
||||
.where(sales_doctype.docstatus == 1)
|
||||
.groupby(customer.name)
|
||||
.orderby(days_since_last_order, order=frappe.qb.desc)
|
||||
).run(as_list=True)
|
||||
)
|
||||
|
||||
if condition := get_allowed_companies_condition(sales_doctype.company, doctype):
|
||||
query = query.where(condition)
|
||||
|
||||
return query.run(as_list=True)
|
||||
|
||||
|
||||
def get_last_sales_amt(customer, doctype):
|
||||
sales_doctype = frappe.qb.DocType(doctype)
|
||||
date_col = sales_doctype.transaction_date if doctype == "Sales Order" else sales_doctype.posting_date
|
||||
|
||||
res = (
|
||||
query = (
|
||||
frappe.qb.from_(sales_doctype)
|
||||
.select(sales_doctype.base_net_total)
|
||||
.where((sales_doctype.customer == customer) & (sales_doctype.docstatus == 1))
|
||||
.orderby(date_col, order=frappe.qb.desc)
|
||||
.orderby(sales_doctype.name, order=frappe.qb.desc)
|
||||
.limit(1)
|
||||
).run()
|
||||
)
|
||||
|
||||
if condition := get_allowed_companies_condition(sales_doctype.company, doctype):
|
||||
query = query.where(condition)
|
||||
|
||||
res = query.run()
|
||||
|
||||
return res and res[0][0] or 0
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ def get_data(company: str, from_date: str, to_date: str, group_by: Literal["Lost
|
||||
|
||||
lost_quotation_condition = (
|
||||
(q.status == "Lost")
|
||||
& (q.is_active == 1)
|
||||
& (q.docstatus == DocStatus.submitted())
|
||||
& (q.transaction_date >= from_date)
|
||||
& (q.transaction_date <= to_date)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.selling.doctype.quotation.mapper import make_revision
|
||||
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
|
||||
from erpnext.selling.report.lost_quotations.lost_quotations import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
@@ -33,6 +34,23 @@ class TestLostQuotations(ERPNextTestSuite):
|
||||
self.assertGreater(row_a[2], 0)
|
||||
self.assertLess(row_a[2], 100)
|
||||
|
||||
def test_lost_quotation_versions_are_counted_once(self):
|
||||
lost_quotations_before = self._count_lost_quotations()
|
||||
quotation = make_quotation(company=self.company, qty=1, rate=100)
|
||||
revision = make_revision(quotation.name)
|
||||
revision.insert()
|
||||
revision.submit()
|
||||
|
||||
revision.declare_enquiry_lost([{"lost_reason": self.reason_a}], [])
|
||||
|
||||
self.assertEqual(self._count_lost_quotations(), lost_quotations_before + 1)
|
||||
|
||||
def _count_lost_quotations(self):
|
||||
_columns, data = execute(
|
||||
frappe._dict({"company": self.company, "timespan": "This Year", "group_by": "Lost Reason"})
|
||||
)
|
||||
return sum(row[1] for row in data)
|
||||
|
||||
def _ensure_lost_reason(self, name):
|
||||
if not frappe.db.exists("Quotation Lost Reason", name):
|
||||
frappe.get_doc({"doctype": "Quotation Lost Reason", "order_lost_reason": name}).insert()
|
||||
|
||||
@@ -7,6 +7,8 @@ from frappe import _
|
||||
from frappe.query_builder.functions import Max, Sum
|
||||
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()
|
||||
@@ -75,7 +77,7 @@ def apply_representative_lines(rows, sales_orders):
|
||||
def get_data():
|
||||
so = frappe.qb.DocType("Sales Order")
|
||||
so_item = frappe.qb.DocType("Sales Order Item")
|
||||
sales_order_entry = (
|
||||
query = (
|
||||
frappe.qb.from_(so)
|
||||
.inner_join(so_item)
|
||||
.on(so.name == so_item.parent)
|
||||
@@ -93,9 +95,13 @@ def get_data():
|
||||
)
|
||||
.where((so.docstatus == 1) & so.status.notin(["Closed", "Completed", "Cancelled"]))
|
||||
.groupby(so.name, so_item.item_code)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
if condition := get_allowed_companies_condition(so.company, "Sales Order"):
|
||||
query = query.where(condition)
|
||||
|
||||
sales_order_entry = query.run(as_dict=1)
|
||||
|
||||
sales_orders = [row.name for row in sales_order_entry]
|
||||
apply_representative_lines(sales_order_entry, sales_orders)
|
||||
mr_records = frappe.get_all(
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
from frappe import _, msgprint, qb
|
||||
from frappe.query_builder import Criterion
|
||||
|
||||
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies_condition
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
if not filters:
|
||||
@@ -142,6 +144,9 @@ def get_conditions(dt, st, filters, date_field):
|
||||
if filters.get(field):
|
||||
conditions.append(dt[field].eq(filters.get(field)))
|
||||
|
||||
if condition := get_allowed_companies_condition(dt.company, filters["doc_type"]):
|
||||
conditions.append(condition)
|
||||
|
||||
if filters.get("sales_person"):
|
||||
conditions.append(st["sales_person"].eq(filters.get("sales_person")))
|
||||
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import NamedTuple
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.functions import IfNull
|
||||
from frappe.utils import comma_and
|
||||
from pypika.terms import Bracket, ExistsCriterion
|
||||
from pypika.terms import Bracket, Criterion, ExistsCriterion
|
||||
|
||||
RESTRICTABLE_MASTER_DOCTYPES = ("Item", "Customer", "Supplier")
|
||||
|
||||
RESTRICTION_INHERITED_FROM = {"Item Price": ("Item", "item_code")}
|
||||
|
||||
COMPANY_RESTRICTION_EXEMPT_DOCTYPES = frozenset(
|
||||
{
|
||||
"Asset",
|
||||
@@ -37,6 +37,12 @@ class CompanyRestrictionError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class MasterLink(NamedTuple):
|
||||
fieldname: str
|
||||
doctype: str
|
||||
doctype_fieldname: str | None = None
|
||||
|
||||
|
||||
class CompanyRestriction(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
@@ -62,6 +68,19 @@ def get_allowed_companies(user, doctype):
|
||||
return get_allowed_docs_for_doctype(user_permissions["Company"], doctype) or None
|
||||
|
||||
|
||||
def get_allowed_companies_condition(field, doctype):
|
||||
if allowed_companies := get_allowed_companies(frappe.session.user, doctype):
|
||||
return field.isin(allowed_companies)
|
||||
return None
|
||||
|
||||
|
||||
def get_allowed_warehouses_condition(field):
|
||||
warehouse = frappe.qb.DocType("Warehouse")
|
||||
if condition := get_allowed_companies_condition(warehouse.company, "Warehouse"):
|
||||
return field.isin(frappe.qb.from_(warehouse).select(warehouse.name).where(condition))
|
||||
return None
|
||||
|
||||
|
||||
def get_permission_query_conditions(user, doctype=None):
|
||||
if not doctype:
|
||||
return None
|
||||
@@ -73,23 +92,31 @@ def get_permission_query_conditions(user, doctype=None):
|
||||
return get_restriction_criterion(doctype, allowed_companies)
|
||||
|
||||
|
||||
def get_inherited_permission_query_conditions(user, doctype=None):
|
||||
if not (inherited := RESTRICTION_INHERITED_FROM.get(doctype)):
|
||||
def get_allowed_masters_condition(field, doctype, user=None):
|
||||
if doctype not in RESTRICTABLE_MASTER_DOCTYPES:
|
||||
return None
|
||||
|
||||
master_doctype, fieldname = inherited
|
||||
allowed_companies = get_allowed_companies(user, master_doctype)
|
||||
if not allowed_companies:
|
||||
allowed_companies = get_allowed_companies(user, doctype)
|
||||
if not allowed_companies or doctype not in get_restricted_master_doctypes():
|
||||
return None
|
||||
|
||||
child = frappe.qb.DocType(doctype)
|
||||
master = frappe.qb.DocType(master_doctype)
|
||||
master = frappe.qb.DocType(doctype)
|
||||
allowed_masters = (
|
||||
frappe.qb.from_(master)
|
||||
.select(master.name)
|
||||
.where(get_restriction_criterion(master_doctype, allowed_companies))
|
||||
.where(get_restriction_criterion(doctype, allowed_companies))
|
||||
)
|
||||
return child[fieldname].isin(allowed_masters)
|
||||
return field.isin(allowed_masters)
|
||||
|
||||
|
||||
def remove_restricted_masters(rows, fieldname, doctype):
|
||||
condition = get_allowed_masters_condition(frappe.qb.DocType(doctype).name, doctype)
|
||||
names = list({row.get(fieldname) for row in rows if row.get(fieldname)})
|
||||
if not condition or not names:
|
||||
return rows
|
||||
|
||||
allowed = set(frappe.get_all(doctype, filters=[{"name": ("in", names)}, condition], pluck="name"))
|
||||
return [row for row in rows if row.get(fieldname) in allowed]
|
||||
|
||||
|
||||
def get_restriction_criterion(doctype, companies):
|
||||
@@ -119,15 +146,82 @@ def has_permission(doc, ptype=None, user=None):
|
||||
return any(row.company in allowed_companies for row in doc.get("allowed_companies") or [])
|
||||
|
||||
|
||||
def get_inherited_permission_query_conditions(user, doctype=None):
|
||||
if not doctype:
|
||||
return None
|
||||
|
||||
links = []
|
||||
for link in get_inherited_master_links(doctype):
|
||||
if allowed_companies := get_allowed_companies(user, link.doctype):
|
||||
links.append((link, allowed_companies))
|
||||
|
||||
restricted_doctypes = get_restricted_master_doctypes() if links else []
|
||||
conditions = [
|
||||
get_linked_master_criterion(doctype, link, allowed_companies)
|
||||
for link, allowed_companies in links
|
||||
if link.doctype in restricted_doctypes
|
||||
]
|
||||
return Criterion.all(conditions) if conditions else None
|
||||
|
||||
|
||||
def get_restricted_master_doctypes():
|
||||
return frappe.get_all(
|
||||
"Company Restriction",
|
||||
filters={"parentfield": "allowed_companies", "parenttype": ("in", RESTRICTABLE_MASTER_DOCTYPES)},
|
||||
pluck="parenttype",
|
||||
distinct=True,
|
||||
)
|
||||
|
||||
|
||||
def get_linked_master_criterion(doctype, link, companies):
|
||||
table = frappe.qb.DocType(doctype)
|
||||
master = frappe.qb.DocType(link.doctype)
|
||||
blocked_masters = (
|
||||
frappe.qb.from_(master)
|
||||
.select(master.name)
|
||||
.where(get_restriction_criterion(link.doctype, companies).negate())
|
||||
)
|
||||
criterion = (IfNull(table[link.fieldname], "") == "") | table[link.fieldname].notin(blocked_masters)
|
||||
if link.doctype_fieldname:
|
||||
criterion = (IfNull(table[link.doctype_fieldname], "") != link.doctype) | criterion
|
||||
|
||||
return criterion
|
||||
|
||||
|
||||
def has_inherited_permission(doc, ptype=None, user=None):
|
||||
if not (inherited := RESTRICTION_INHERITED_FROM.get(doc.doctype)):
|
||||
if not get_inherited_master_links(doc.doctype):
|
||||
return True
|
||||
|
||||
master_doctype, fieldname = inherited
|
||||
if not (master_name := doc.get(fieldname)):
|
||||
return True
|
||||
references = defaultdict(set)
|
||||
collect_master_references([doc], references)
|
||||
for doctype, names in references.items():
|
||||
allowed_companies = get_allowed_companies(user, doctype)
|
||||
if allowed_companies and get_blocked_masters(doctype, names, allowed_companies):
|
||||
return False
|
||||
|
||||
return has_permission(frappe.get_cached_doc(master_doctype, master_name), ptype, user)
|
||||
return True
|
||||
|
||||
|
||||
def get_inherited_master_links(doctype):
|
||||
meta = frappe.get_meta(doctype)
|
||||
if doctype in RESTRICTABLE_MASTER_DOCTYPES or meta.istable or meta.issingle:
|
||||
return []
|
||||
|
||||
return get_master_links(meta)
|
||||
|
||||
|
||||
def get_master_links(meta):
|
||||
links = [
|
||||
MasterLink(field.fieldname, field.options)
|
||||
for field in meta.get_link_fields()
|
||||
if field.options in RESTRICTABLE_MASTER_DOCTYPES
|
||||
]
|
||||
for field in meta.get_dynamic_link_fields():
|
||||
links.extend(
|
||||
MasterLink(field.fieldname, doctype, field.options) for doctype in RESTRICTABLE_MASTER_DOCTYPES
|
||||
)
|
||||
|
||||
return links
|
||||
|
||||
|
||||
def validate_allowed_companies(doc, method=None):
|
||||
@@ -176,7 +270,7 @@ def validate_transaction_company(doc, method=None):
|
||||
|
||||
|
||||
def validate_masters_for_company(doctype, names, company):
|
||||
if blocked := get_blocked_masters(doctype, names, company):
|
||||
if blocked := get_blocked_masters(doctype, names, [company]):
|
||||
frappe.throw(
|
||||
_("{0} {1} cannot be used with Company {2} because of Company Restrictions").format(
|
||||
_(doctype),
|
||||
@@ -199,22 +293,16 @@ def get_master_references(doc):
|
||||
|
||||
|
||||
def collect_master_references(rows, references):
|
||||
meta = frappe.get_meta(rows[0].doctype)
|
||||
link_fields = [field for field in meta.get_link_fields() if field.options in RESTRICTABLE_MASTER_DOCTYPES]
|
||||
dynamic_link_fields = meta.get_dynamic_link_fields()
|
||||
|
||||
links = get_master_links(frappe.get_meta(rows[0].doctype))
|
||||
for row in rows:
|
||||
for field in link_fields:
|
||||
if value := row.get(field.fieldname):
|
||||
references[field.options].add(value)
|
||||
|
||||
for field in dynamic_link_fields:
|
||||
doctype = row.get(field.options)
|
||||
if doctype in RESTRICTABLE_MASTER_DOCTYPES and (value := row.get(field.fieldname)):
|
||||
references[doctype].add(value)
|
||||
for link in links:
|
||||
if link.doctype_fieldname and row.get(link.doctype_fieldname) != link.doctype:
|
||||
continue
|
||||
if value := row.get(link.fieldname):
|
||||
references[link.doctype].add(value)
|
||||
|
||||
|
||||
def get_blocked_masters(doctype, names, company):
|
||||
def get_blocked_masters(doctype, names, companies):
|
||||
restricted = frappe.get_all(
|
||||
doctype,
|
||||
filters={"name": ("in", sorted(names)), "restrict_to_companies": 1},
|
||||
@@ -229,7 +317,7 @@ def get_blocked_masters(doctype, names, company):
|
||||
"parenttype": doctype,
|
||||
"parentfield": "allowed_companies",
|
||||
"parent": ("in", restricted),
|
||||
"company": company,
|
||||
"company": ("in", companies),
|
||||
},
|
||||
pluck="parent",
|
||||
)
|
||||
|
||||
@@ -172,6 +172,21 @@ class TestCompanyRestriction(ERPNextTestSuite):
|
||||
.name
|
||||
)
|
||||
|
||||
def make_party_specific_item(self, customer, item_code):
|
||||
return (
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Party Specific Item",
|
||||
"party_type": "Customer",
|
||||
"party": customer,
|
||||
"restrict_based_on": "Item",
|
||||
"based_on_value": item_code,
|
||||
}
|
||||
)
|
||||
.insert()
|
||||
.name
|
||||
)
|
||||
|
||||
def test_item_price_inherits_item_company_restriction(self):
|
||||
restricted = make_item()
|
||||
allowed = make_item()
|
||||
@@ -202,6 +217,79 @@ class TestCompanyRestriction(ERPNextTestSuite):
|
||||
with self.set_user(user):
|
||||
self.assertTrue(frappe.has_permission("Item Price", doc=price))
|
||||
|
||||
def test_dynamic_link_inherits_master_company_restriction(self):
|
||||
item = make_item()
|
||||
restricted = make_customer("_Test Dynamic Link Restricted Customer")
|
||||
allowed = make_customer("_Test Dynamic Link Allowed Customer")
|
||||
self.restrict_to_companies("Customer", restricted, ["_Test Company 1"])
|
||||
records = {
|
||||
customer: self.make_party_specific_item(customer, item.name) for customer in (restricted, allowed)
|
||||
}
|
||||
|
||||
user = self.make_user_with_roles("test_dynamic_link_restriction@example.com", ["System Manager"])
|
||||
self.allow_company(user, "_Test Company")
|
||||
|
||||
with self.set_user(user):
|
||||
visible = frappe.get_list(
|
||||
"Party Specific Item",
|
||||
filters={"name": ("in", list(records.values()))},
|
||||
pluck="party",
|
||||
)
|
||||
self.assertEqual(visible, [allowed])
|
||||
|
||||
self.assertFalse(frappe.has_permission("Party Specific Item", doc=records[restricted]))
|
||||
self.assertTrue(frappe.has_permission("Party Specific Item", doc=records[allowed]))
|
||||
|
||||
def make_lead(self, customer, company=None):
|
||||
return (
|
||||
frappe.get_doc(
|
||||
{"doctype": "Lead", "first_name": "_Test", "customer": customer, "company": company}
|
||||
)
|
||||
.insert()
|
||||
.name
|
||||
)
|
||||
|
||||
def test_optional_company_record_inherits_master_company_restriction(self):
|
||||
restricted = make_customer("_Test Optional Company Restricted Customer")
|
||||
restricted_later = make_customer("_Test Optional Company Restricted Later Customer")
|
||||
allowed = make_customer("_Test Optional Company Allowed Customer")
|
||||
self.restrict_to_companies("Customer", restricted, ["_Test Company 1"])
|
||||
leads = {
|
||||
restricted: self.make_lead(restricted),
|
||||
restricted_later: self.make_lead(restricted_later, "_Test Company"),
|
||||
allowed: self.make_lead(allowed),
|
||||
}
|
||||
self.restrict_to_companies("Customer", restricted_later, ["_Test Company 1"])
|
||||
|
||||
user = self.make_user_with_roles("test_optional_company_restriction@example.com", ["Sales Manager"])
|
||||
self.allow_company(user, "_Test Company")
|
||||
|
||||
with self.set_user(user):
|
||||
visible = frappe.get_list(
|
||||
"Lead", filters={"name": ("in", list(leads.values()))}, pluck="customer"
|
||||
)
|
||||
self.assertEqual(visible, [allowed])
|
||||
|
||||
self.assertFalse(frappe.has_permission("Lead", doc=leads[restricted]))
|
||||
self.assertFalse(frappe.has_permission("Lead", doc=leads[restricted_later]))
|
||||
self.assertTrue(frappe.has_permission("Lead", doc=leads[allowed]))
|
||||
|
||||
def test_transaction_hides_when_master_is_restricted_later(self):
|
||||
customer = make_customer("_Test Restricted Later Quotation Customer")
|
||||
quotation = make_quotation(party_name=customer, do_not_submit=1)
|
||||
|
||||
user = self.make_user_with_roles("test_quotation_restriction@example.com", ["Sales User"])
|
||||
self.allow_company(user, "_Test Company")
|
||||
|
||||
with self.set_user(user):
|
||||
self.assertTrue(frappe.has_permission("Quotation", doc=quotation.name))
|
||||
|
||||
self.restrict_to_companies("Customer", customer, ["_Test Company 1"])
|
||||
|
||||
with self.set_user(user):
|
||||
self.assertEqual(frappe.get_list("Quotation", filters={"name": quotation.name}), [])
|
||||
self.assertFalse(frappe.has_permission("Quotation", doc=quotation.name))
|
||||
|
||||
def make_user_with_roles(self, email, roles):
|
||||
if not frappe.db.exists("User", email):
|
||||
frappe.get_doc(
|
||||
@@ -238,3 +326,172 @@ class TestCompanyRestriction(ERPNextTestSuite):
|
||||
doc.reload()
|
||||
self.assertEqual(doc.restrict_to_companies, 1)
|
||||
self.assertEqual([row.company for row in doc.allowed_companies], ["_Test Company"])
|
||||
|
||||
def make_report_user(self):
|
||||
user = self.make_user_with_roles(
|
||||
"test_company_restriction_reports@example.com", ["Stock User", "Sales User", "Accounts User"]
|
||||
)
|
||||
self.allow_company(user, "_Test Company")
|
||||
return user
|
||||
|
||||
def run_report(self, module, report, filters):
|
||||
execute = frappe.get_attr(f"erpnext.{module}.report.{report}.{report}.execute")
|
||||
return execute(frappe._dict(filters))[1]
|
||||
|
||||
def test_item_prices_report_hides_restricted_items(self):
|
||||
restricted, allowed = make_item(), make_item()
|
||||
self.restrict_to_companies("Item", restricted.name, ["_Test Company 1"])
|
||||
|
||||
items = {row[0] for row in self.run_report("stock", "item_prices", {})}
|
||||
self.assertTrue({restricted.name, allowed.name} <= items)
|
||||
|
||||
with self.set_user(self.make_report_user()):
|
||||
items = {row[0] for row in self.run_report("stock", "item_prices", {})}
|
||||
self.assertIn(allowed.name, items)
|
||||
self.assertNotIn(restricted.name, items)
|
||||
|
||||
def test_variant_reports_hide_restricted_variants(self):
|
||||
from erpnext.controllers.item_variant import create_variant
|
||||
|
||||
template = make_item(properties={"has_variants": 1, "attributes": [{"attribute": "Test Size"}]})
|
||||
restricted = create_variant(template.name, {"Test Size": "Small"}).insert()
|
||||
allowed = create_variant(template.name, {"Test Size": "Large"}).insert()
|
||||
self.restrict_to_companies("Item", restricted.name, ["_Test Company 1"])
|
||||
|
||||
with self.set_user(self.make_report_user()):
|
||||
rows = self.run_report("stock", "item_variant_details", {"item": template.name})
|
||||
self.assertEqual([row["variant_name"] for row in rows], [allowed.name])
|
||||
|
||||
rows = self.run_report("stock", "item_where_used", {"item": template.name})
|
||||
self.assertEqual([row.related_item for row in rows], [allowed.name])
|
||||
|
||||
def test_bom_search_hides_restricted_product_bundles(self):
|
||||
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
|
||||
|
||||
component = make_item()
|
||||
restricted = make_product_bundle(make_item(properties={"is_stock_item": 0}).name, [component.name])
|
||||
allowed = make_product_bundle(make_item(properties={"is_stock_item": 0}).name, [component.name])
|
||||
self.restrict_to_companies("Item", restricted.new_item_code, ["_Test Company 1"])
|
||||
|
||||
with self.set_user(self.make_report_user()):
|
||||
rows = self.run_report(
|
||||
"stock", "bom_search", {"search_sub_assemblies": 0, "item1": component.name}
|
||||
)
|
||||
self.assertEqual([row[0] for row in rows], [allowed.name])
|
||||
|
||||
def test_trial_balance_for_party_hides_restricted_customers(self):
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
|
||||
restricted = make_customer("_Test Trial Balance Restricted Customer")
|
||||
allowed = make_customer("_Test Trial Balance Allowed Customer")
|
||||
self.restrict_to_companies("Customer", restricted, ["_Test Company 1"])
|
||||
filters = {
|
||||
"company": "_Test Company",
|
||||
"fiscal_year": get_fiscal_year(frappe.utils.nowdate(), company="_Test Company")[0],
|
||||
"party_type": "Customer",
|
||||
"show_zero_values": 1,
|
||||
"exclude_zero_balance_parties": 0,
|
||||
}
|
||||
|
||||
with self.set_user(self.make_report_user()):
|
||||
rows = self.run_report("accounts", "trial_balance_for_party", filters)
|
||||
parties = {row.get("party") for row in rows}
|
||||
self.assertIn(allowed, parties)
|
||||
self.assertNotIn(restricted, parties)
|
||||
|
||||
def test_converted_query_reports_hide_restricted_masters(self):
|
||||
restricted_item, allowed_item = make_item(), make_item()
|
||||
restricted_customer = make_customer("_Test Query Report Restricted Customer")
|
||||
allowed_customer = make_customer("_Test Query Report Allowed Customer")
|
||||
self.restrict_to_companies("Item", restricted_item.name, ["_Test Company 1"])
|
||||
self.restrict_to_companies("Customer", restricted_customer, ["_Test Company 1"])
|
||||
|
||||
with self.set_user(self.make_report_user()):
|
||||
items = {row.item_code for row in self.run_report("stock", "item_balance", {})}
|
||||
self.assertIn(allowed_item.name, items)
|
||||
self.assertNotIn(restricted_item.name, items)
|
||||
|
||||
rows = self.run_report("selling", "customers_without_any_sales_transactions", {})
|
||||
customers = {row.customer for row in rows}
|
||||
self.assertIn(allowed_customer, customers)
|
||||
self.assertNotIn(restricted_customer, customers)
|
||||
|
||||
def make_stock_in_both_companies(self):
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
make_stock_entry(item_code="_Test Item", qty=5, to_warehouse="Stores - _TC", basic_rate=100)
|
||||
make_stock_entry(
|
||||
item_code="_Test Item",
|
||||
qty=5,
|
||||
to_warehouse="Stores - _TC1",
|
||||
company="_Test Company 1",
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
def test_reports_keep_to_permitted_companies(self):
|
||||
from erpnext.stock.report.stock_projected_qty.stock_projected_qty import (
|
||||
execute as stock_projected_qty,
|
||||
)
|
||||
|
||||
self.make_stock_in_both_companies()
|
||||
own_request = make_material_request()
|
||||
other_request = make_material_request(
|
||||
company="_Test Company 1", warehouse="Stores - _TC1", cost_center="Main - _TC1"
|
||||
)
|
||||
|
||||
with self.set_user(self.make_report_user()):
|
||||
columns, rows = stock_projected_qty(frappe._dict(item_code="_Test Item"))
|
||||
warehouse_index = [column["fieldname"] for column in columns].index("warehouse")
|
||||
warehouses = {row[warehouse_index] for row in rows}
|
||||
self.assertIn("Stores - _TC", warehouses)
|
||||
self.assertNotIn("Stores - _TC1", warehouses)
|
||||
|
||||
requests = {
|
||||
row["material_request_no"] for row in self.run_report("buying", "procurement_tracker", {})
|
||||
}
|
||||
self.assertIn(own_request.name, requests)
|
||||
self.assertNotIn(other_request.name, requests)
|
||||
|
||||
def test_item_balance_keeps_to_permitted_warehouses(self):
|
||||
self.make_stock_in_both_companies()
|
||||
|
||||
with self.set_user(self.make_report_user()):
|
||||
rows = self.run_report("stock", "item_balance", {})
|
||||
warehouses = {row.warehouse for row in rows if row.item_code == "_Test Item"}
|
||||
self.assertIn("Stores - _TC", warehouses)
|
||||
self.assertNotIn("Stores - _TC1", warehouses)
|
||||
|
||||
def test_batch_traceability_hides_other_company_sources(self):
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
item = make_item(
|
||||
properties={"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "TRACE-.#####"}
|
||||
).name
|
||||
own_entry = make_stock_entry(item_code=item, qty=5, to_warehouse="Stores - _TC", basic_rate=100)
|
||||
other_entry = make_stock_entry(
|
||||
item_code=item, qty=5, to_warehouse="Stores - _TC1", company="_Test Company 1", basic_rate=100
|
||||
)
|
||||
own_batch = frappe.db.get_value("Batch", {"reference_name": own_entry.name})
|
||||
other_batch = frappe.db.get_value("Batch", {"reference_name": other_entry.name})
|
||||
filters = {"batches": [own_batch, other_batch], "traceability_direction": "Backward"}
|
||||
|
||||
with self.set_user(self.make_report_user()):
|
||||
rows = self.run_report("stock", "serial_no_and_batch_traceability", filters)
|
||||
batches = {row.get("batch_no") for row in rows}
|
||||
self.assertIn(own_batch, batches)
|
||||
self.assertNotIn(other_batch, batches)
|
||||
|
||||
def test_batch_split_tree_hides_chosen_batch_of_restricted_item(self):
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
item = make_item(
|
||||
properties={"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "SPLIT-.#####"}
|
||||
).name
|
||||
entry = make_stock_entry(item_code=item, qty=5, to_warehouse="Stores - _TC", basic_rate=100)
|
||||
batch = frappe.db.get_value("Batch", {"reference_name": entry.name})
|
||||
self.restrict_to_companies("Item", item, ["_Test Company 1"])
|
||||
|
||||
self.assertEqual(len(self.run_report("stock", "batch_split_tree", {"batch": batch})), 1)
|
||||
|
||||
with self.set_user(self.make_report_user()):
|
||||
self.assertEqual(self.run_report("stock", "batch_split_tree", {"batch": batch}), [])
|
||||
|
||||
@@ -6,6 +6,7 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import cint, flt
|
||||
|
||||
from erpnext.accounts.utils import pre_submit_validation
|
||||
from erpnext.controllers.selling_controller import SellingController
|
||||
from erpnext.stock.doctype.delivery_note.services.billing_status import BillingStatusService
|
||||
from erpnext.stock.doctype.delivery_note.services.packing import PackingService
|
||||
@@ -293,6 +294,7 @@ class DeliveryNote(SellingController):
|
||||
|
||||
self.validate_against_stock_reservation_entries()
|
||||
self.reset_default_field_value("set_warehouse", "items", "warehouse")
|
||||
pre_submit_validation(self, check_credit_limit=True, check_packed_qty=True)
|
||||
|
||||
def validate_with_previous_doc(self):
|
||||
super().validate_with_previous_doc(
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "customer_address",
|
||||
"fieldtype": "Small Text",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Customer Address",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -124,7 +124,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "customer_contact",
|
||||
"fieldtype": "Small Text",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Customer Contact",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -183,7 +183,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:06:50.465672",
|
||||
"modified": "2026-09-25 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Delivery Stop",
|
||||
|
||||
@@ -17,8 +17,8 @@ class DeliveryStop(Document):
|
||||
address: DF.Link
|
||||
contact: DF.Link | None
|
||||
customer: DF.Link | None
|
||||
customer_address: DF.SmallText | None
|
||||
customer_contact: DF.SmallText | None
|
||||
customer_address: DF.TextEditor | None
|
||||
customer_contact: DF.TextEditor | None
|
||||
delivery_note: DF.Link | None
|
||||
details: DF.TextEditor | None
|
||||
distance: DF.Float
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user